Merge branch 'staging' into 'production'

Staging

See merge request mbugroup/lti-web-client!292
This commit is contained in:
Adnan Zahir
2026-01-30 16:13:35 +07:00
61 changed files with 3121 additions and 1770 deletions
+11 -1
View File
@@ -22,6 +22,7 @@ export interface CardProps
onCollapsedChange?: (collapsed: boolean) => void; onCollapsedChange?: (collapsed: boolean) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
wrapperContent?: string;
image?: string; image?: string;
body?: string; body?: string;
title?: string; title?: string;
@@ -122,6 +123,10 @@ const Card = ({
return cn(baseClasses, 'p-6', className?.body); return cn(baseClasses, 'p-6', className?.body);
}; };
const getCollapsibleClasses = () => {
return cn('', className?.collapsible);
};
const getTitleClasses = () => { const getTitleClasses = () => {
const sizeClasses = { const sizeClasses = {
sm: 'text-lg', sm: 'text-lg',
@@ -144,6 +149,10 @@ const Card = ({
return cn('border-t border-base-300 mt-4 pt-4', className?.footer); return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
}; };
const getWrapperContentClasses = () => {
return cn('space-y-4', className?.wrapperContent);
};
const renderCardContent = () => { const renderCardContent = () => {
const hasContent = children || actions || footer; const hasContent = children || actions || footer;
@@ -177,7 +186,7 @@ const Card = ({
); );
const cardContent = ( const cardContent = (
<div className='space-y-4'> <div className={getWrapperContentClasses()}>
{children} {children}
{actions && <div className={getActionsClasses()}>{actions}</div>} {actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>} {footer && <div className={getFooterClasses()}>{footer}</div>}
@@ -208,6 +217,7 @@ const Card = ({
titleClassName='w-full cursor-pointer' titleClassName='w-full cursor-pointer'
contentClassName='p-0' contentClassName='p-0'
fullWidth={true} fullWidth={true}
className={getCollapsibleClasses()}
> >
{cardContent} {cardContent}
</Collapse> </Collapse>
+5 -1
View File
@@ -12,6 +12,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth'; import { AuthApi } from '@/services/api/auth';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { useUiStore } from '@/stores/ui/ui.store';
interface NavbarProps { interface NavbarProps {
toggleSidebar?: () => void; toggleSidebar?: () => void;
@@ -21,6 +22,7 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
const { setUser } = useAuth(); const { setUser } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const navbarActions = useUiStore((state) => state.navbarActions);
const logoutClickHandler = async () => { const logoutClickHandler = async () => {
const logoutRes = await AuthApi.logout(); const logoutRes = await AuthApi.logout();
@@ -53,7 +55,9 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
</div> </div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2 items-center'>
{/* Page-specific actions */}
{navbarActions && <div className='mr-2'>{navbarActions}</div>}
<PopoverButton <PopoverButton
tabIndex={0} tabIndex={0}
variant='ghost' variant='ghost'
+12 -2
View File
@@ -42,6 +42,7 @@ interface TableClassNames {
footerRowClassName?: string; footerRowClassName?: string;
footerColumnClassName?: string; footerColumnClassName?: string;
paginationClassName?: string; paginationClassName?: string;
skeletonCellClassName?: string;
} }
export interface TableProps<TData extends object> { export interface TableProps<TData extends object> {
@@ -79,7 +80,9 @@ export interface TableProps<TData extends object> {
getSubRows?: (originalRow: TData, index: number) => TData[] | undefined; getSubRows?: (originalRow: TData, index: number) => TData[] | undefined;
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = Array.from({ length: 10 }, (_, index) => ({
id: index,
}));
const emptyContentDefaultValue = ( const emptyContentDefaultValue = (
<div className='w-full p-5 text-center'> <div className='w-full p-5 text-center'>
@@ -414,7 +417,14 @@ const Table = <TData extends object>({
cell.getContext() cell.getContext()
)} )}
{isLoading && <div className='skeleton w-full h-4' />} {isLoading && (
<div
className={cn(
'skeleton w-full h-4',
tableClassNames.skeletonCellClassName
)}
/>
)}
</td> </td>
))} ))}
</tr> </tr>
+11
View File
@@ -25,8 +25,10 @@ export interface TabsProps
wrapper?: string; wrapper?: string;
tab?: string; tab?: string;
content?: string; content?: string;
tabHeaderWrapper?: string;
}; };
onTabChange?: (tabId: string) => void; onTabChange?: (tabId: string) => void;
sideContent?: ReactNode;
} }
const Tabs = ({ const Tabs = ({
@@ -38,6 +40,7 @@ const Tabs = ({
activeTabId: controlledActiveId, activeTabId: controlledActiveId,
className, className,
onTabChange, onTabChange,
sideContent,
...props ...props
}: TabsProps) => { }: TabsProps) => {
// State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset // State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset
@@ -59,6 +62,7 @@ const Tabs = ({
wrapper: wrapperClassName, wrapper: wrapperClassName,
tab: tabClassName, tab: tabClassName,
content: contentClassName, content: contentClassName,
tabHeaderWrapper: tabHeaderWrapperClassName,
} = typeof className === 'object' } = typeof className === 'object'
? className ? className
: { wrapper: className, tab: undefined }; : { wrapper: className, tab: undefined };
@@ -102,6 +106,10 @@ const Tabs = ({
tabClassName tabClassName
); );
const getSideContentClasses = () => {
return cn('flex flex-row', tabHeaderWrapperClassName);
};
const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content; const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content;
return ( return (
@@ -112,6 +120,7 @@ const Tabs = ({
typeof className === 'string' ? className : containerClassName typeof className === 'string' ? className : containerClassName
)} )}
> >
<div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}> <div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => ( {tabs.map(({ id, label, disabled }) => (
<button <button
@@ -125,6 +134,8 @@ const Tabs = ({
</button> </button>
))} ))}
</div> </div>
{sideContent && sideContent}
</div>
{activeContent && ( {activeContent && (
<div className={cn('mt-4', contentClassName)}>{activeContent}</div> <div className={cn('mt-4', contentClassName)}>{activeContent}</div>
+9 -3
View File
@@ -9,15 +9,21 @@ export type ButtonFilterProps = ButtonProps & {
onClick: () => void; onClick: () => void;
}; };
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => { const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
return ( return (
<Button <Button
{...props} {...props}
onClick={onClick} onClick={onClick}
variant='outline'
color='none'
className={cn( className={cn(
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
getFilledFormikValuesCount(values) > 0 getFilledFormikValuesCount(values) > 0
? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200' ? 'border-primary-gradient text-primary rounded-lg!'
: '', : 'rounded-lg',
props.className props.className
)} )}
> >
@@ -31,7 +37,7 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
/> />
Filter Filter
{getFilledFormikValuesCount(values) > 0 && ( {getFilledFormikValuesCount(values) > 0 && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'> <span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)} {getFilledFormikValuesCount(values)}
</span> </span>
)} )}
+9 -2
View File
@@ -1,10 +1,17 @@
import Button from '@/components/Button';
const PermissionNotFound = () => { const PermissionNotFound = () => {
return ( return (
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'> <div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
<h2 className='text-2xl font-bold text-error'>Permission Not Found</h2> <h2 className='text-2xl font-bold text-error'>
Hak Akses Tidak Ditemukan
</h2>
<p className='text-gray-600 text-center'> <p className='text-gray-600 text-center'>
You do not have permission to access this page. Anda tidak memiliki hak akses untuk mengakses halaman ini.
</p> </p>
<Button href='/dashboard' className='text-base-100'>
Kembali ke Dashboard
</Button>
</div> </div>
); );
}; };
+3 -1
View File
@@ -72,8 +72,10 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
await AuthApi.refresh(); await AuthApi.refresh();
}; };
if (user) {
refreshUserSession(); refreshUserSession();
}, []); }
}, [user]);
if ( if (
(isLoadingUserResponse && !userResponse && !userErrorResponse) || (isLoadingUserResponse && !userResponse && !userErrorResponse) ||
+2
View File
@@ -27,6 +27,7 @@ const StatusBadge = ({
'bg-success/30': color === 'success', 'bg-success/30': color === 'success',
'bg-error/20': color === 'error', 'bg-error/20': color === 'error',
'bg-primary/20': color === 'info', 'bg-primary/20': color === 'info',
'bg-[#FF9A20]/12': color === 'warning',
}, },
className?.badge className?.badge
), ),
@@ -43,6 +44,7 @@ const StatusBadge = ({
'text-[#008000]': color === 'success', 'text-[#008000]': color === 'success',
'text-error': color === 'error', 'text-error': color === 'error',
'text-primary': color === 'info', 'text-primary': color === 'info',
'text-[#FF9A20]': color === 'warning',
})} })}
> >
<circle r='6' cx='6' cy='6' fill='currentColor' /> <circle r='6' cx='6' cy='6' fill='currentColor' />
@@ -58,6 +58,7 @@ const DrawerHeader = ({
if (leftIconOnClick) { if (leftIconOnClick) {
return ( return (
<button <button
type='button'
onClick={leftIconOnClick} onClick={leftIconOnClick}
className='hover:text-gray-400 bg-transparent border-none p-0' className='hover:text-gray-400 bg-transparent border-none p-0'
> >
@@ -72,12 +73,12 @@ const DrawerHeader = ({
return ( return (
<div <div
className={cn( className={cn(
'flex flex-row justify-between items-center px-4 pt-4', 'flex flex-row justify-between items-center px-4 pt-4 pb-4 border-b border-base-content/10',
className className
)} )}
> >
{/* Left Side */} {/* Left Side */}
<div className='flex flex-row h-full gap-2 items-center'> <div className='flex flex-row h-full gap-3 items-center'>
{renderLeftIcon()} {renderLeftIcon()}
{showDivider && subtitle && ( {showDivider && subtitle && (
@@ -85,7 +86,12 @@ const DrawerHeader = ({
)} )}
{subtitle && ( {subtitle && (
<div className={cn('text-sm text-neutral', subtitleClassName)}> <div
className={cn(
'text-sm font-medium text-base-content/50',
subtitleClassName
)}
>
{subtitle} {subtitle}
</div> </div>
)} )}
@@ -0,0 +1,32 @@
import IconSkeleton from '@/components/helper/skeleton/IconSkeleton';
import { Icon } from '@iconify/react';
const DataStateSkeleton = ({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) => {
return (
<div className='flex flex-col items-center justify-center'>
<IconSkeleton
className={{
outer: 'mb-2.25',
}}
>
{icon}
</IconSkeleton>
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
{title}
</h3>
<p className='text-base-content/50 text-xs text-center max-w-xs'>
{description}
</p>
</div>
);
};
export default DataStateSkeleton;
@@ -0,0 +1,33 @@
import { cn } from '@/lib/helper';
import { ReactNode } from 'react';
const IconSkeleton = ({
children,
className,
}: {
children: ReactNode;
className?: {
outer?: string;
inner?: string;
};
}) => {
return (
<div
className={cn(
'w-12.5 h-12.5 bg-[var(--main-color-base-100,#FFFFFF)] border border-base-content/10 rounded-[0.875rem] shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center',
className?.outer
)}
>
<div
className={cn(
'w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]',
className?.inner
)}
>
{children}
</div>
</div>
);
};
export default IconSkeleton;
+20 -7
View File
@@ -280,7 +280,7 @@ const DateInput = ({
ref={calendarModal.ref} ref={calendarModal.ref}
className={{ className={{
modal: 'rounded', modal: 'rounded',
modalBox: `!max-w-max min-h-${isRange ? '124' : '110'} flex flex-col`, modalBox: `max-w-max flex flex-col`,
}} }}
closeOnBackdrop closeOnBackdrop
> >
@@ -296,7 +296,11 @@ const DateInput = ({
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)} endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selectedRange as DateRange} selected={selectedRange as DateRange}
onSelect={handleSelectRange} onSelect={handleSelectRange}
footer={<div className='text-center mt-3'>{displayValue}</div>} footer={
<div className='text-center py-2 text-base-content/65 font-semibold text-xs'>
{displayValue}
</div>
}
disabled={ disabled={
[ [
minDate ? { before: minDate } : undefined, minDate ? { before: minDate } : undefined,
@@ -326,17 +330,26 @@ const DateInput = ({
)} )}
<div className='mt-auto flex flex-col gap-2'> <div className='mt-auto flex flex-col gap-2'>
{isRange && ( {isRange && (
<small className='text-secondary'> <small className='text-base-content/65'>
Tekan dua kali untuk memilih tanggal awal Tekan dua kali untuk reset tanggal awal
</small> </small>
)} )}
<div className='flex h-full justify-end items-end gap-2'> <div className='flex h-full justify-end items-end gap-1.5 mt-3'>
<Button type='button' color='warning' onClick={handleResetDate}> <Button
type='button'
color='none'
className='bg-transparent hover:bg-base-content/10 border-none text-base text-base-content/65 px-3'
onClick={handleResetDate}
>
Reset Reset
</Button> </Button>
{isRange && ( {isRange && (
<Button type='button' onClick={handleSaveDate}> <Button
type='button'
className='rounded-lg px-3 py-2 text-white'
onClick={handleSaveDate}
>
Simpan Simpan
</Button> </Button>
)} )}
+11 -7
View File
@@ -41,7 +41,7 @@ const FileInput = ({
return ( return (
<div <div
className={cn( className={cn(
'w-full flex flex-col gap-2 text-start', 'w-full flex flex-col gap-0 text-start rounded-lg',
className?.wrapper className?.wrapper
)} )}
> >
@@ -49,7 +49,7 @@ const FileInput = ({
<label <label
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full py-2 text-xs font-semibold leading-5',
{ {
'text-error': isError, 'text-error': isError,
}, },
@@ -77,15 +77,19 @@ const FileInput = ({
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
disabled={disabled} disabled={disabled}
className={cn('grow file-input w-full h-12 rounded', className?.input)} className={cn(
'grow file-input w-full h-fit px-3 py-1.5 text-sm font-normal leading-6 rounded-lg! outline-none! transition-all duration-200 bg-white border-base-content/10',
className?.input
)}
readOnly={readOnly} readOnly={readOnly}
/> />
{bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)} )}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div> </div>
); );
}; };
+167 -7
View File
@@ -54,6 +54,9 @@ interface SelectInputBaseProps<T = OptionType> {
wrapper?: string; wrapper?: string;
label?: string; label?: string;
select?: string; select?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
}; };
isError?: boolean; isError?: boolean;
errorMessage?: string; errorMessage?: string;
@@ -62,6 +65,8 @@ interface SelectInputBaseProps<T = OptionType> {
delay?: number; delay?: number;
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode; startAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
menuPortalTarget?: HTMLElement | null; menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean; closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean; hideSelectedOptions?: boolean;
@@ -84,7 +89,7 @@ const CustomControl = <
>( >(
props: ControlProps<Option, IsMulti, Group> props: ControlProps<Option, IsMulti, Group>
) => { ) => {
const { children } = props; const { children, innerProps } = props;
const customProps = props.selectProps as unknown as { const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean; shouldShowAdornment?: boolean;
@@ -96,7 +101,7 @@ const CustomControl = <
return ( return (
<ReactSelectComponents.Control {...props}> <ReactSelectComponents.Control {...props}>
<div className='flex-1 p-3! py-1.5 gap-1 flex items-center'> <div className='flex-1 pl-3 gap-1 flex items-center' {...innerProps}>
{shouldShowAdornment && startAdornment} {shouldShowAdornment && startAdornment}
{children} {children}
</div> </div>
@@ -153,6 +158,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
createables = false, createables = false,
onInputChange, onInputChange,
startAdornment, startAdornment,
inputPrefix,
inputSuffix,
menuPortalTarget, menuPortalTarget,
closeMenuOnSelect, closeMenuOnSelect,
hideSelectedOptions, hideSelectedOptions,
@@ -227,6 +234,154 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
</span> </span>
)} )}
{inputPrefix || inputSuffix ? (
<div
className={cn(
'relative flex text-sm',
className?.inputPrefixSuffixWrapper
)}
>
{inputPrefix && (
<div
className={cn(
'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputPrefix
)}
>
{inputPrefix}
</div>
)}
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled || readOnly}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full flex-1', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn('w-full border bg-white transition-shadow', 'rounded-lg!', {
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'rounded-l-none!': inputPrefix && !startAdornment,
'rounded-r-none!': inputSuffix && !startAdornment,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
}),
singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
indicatorsContainer: () =>
cn('flex items-center gap-1 pr-3 py-2'),
dropdownIndicator: ({ isFocused }) =>
cn('p-0! rounded hover:bg-gray-100', {
'text-gray-900': isFocused,
'text-gray-500': !isFocused,
'text-error!': isError,
}),
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
menu: () =>
cn(
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
),
menuList: () => cn('p-0! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected,
}),
multiValue: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
selectedValues[index]?.className
);
},
multiValueRemove: () => cn('p-0! w-3 h-3'),
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'p-0! text-base-content! text-xs!',
selectedValues[index]?.labelClassName
);
},
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
multiValue(base) {
return {
...base,
borderRadius: '8px',
};
},
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputSuffix
)}
>
{inputSuffix}
</div>
)}
</div>
) : (
<SelectComponent<T, boolean, GroupBase<T>> <SelectComponent<T, boolean, GroupBase<T>>
instanceId='select' instanceId='select'
value={value ?? (isMulti ? [] : null)} value={value ?? (isMulti ? [] : null)}
@@ -247,19 +402,23 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
hideSelectedOptions={hideSelectedOptions} hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)} className={cn('w-full', className?.select)}
classNames={{ classNames={{
...(!startAdornment && {
control: ({ isFocused, isDisabled }) => control: ({ isFocused, isDisabled }) =>
cn('w-full rounded-lg! border bg-white transition-shadow', { cn(
'w-full border bg-white transition-shadow',
// Gunakan rounded-lg untuk semua kasus
'rounded-lg!',
{
'cursor-pointer!': !readOnly && !isDisabled, 'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError, 'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused, 'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
'border-base-content/10!': !isError && !isFocused, 'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': 'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly, isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly, 'bg-transparent! cursor-not-allowed!': readOnly,
}), }
),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'), valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
}),
placeholder: () => placeholder: () =>
cn({ cn({
'text-gray-400 text-sm leading-tight': !isError, 'text-gray-400 text-sm leading-tight': !isError,
@@ -332,6 +491,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}} }}
onMenuScrollToBottom={onMenuScrollToBottom} onMenuScrollToBottom={onMenuScrollToBottom}
/> />
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} {isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
+4 -4
View File
@@ -102,7 +102,7 @@ const TextInput = ({
{inputPrefix && ( {inputPrefix && (
<div <div
className={cn( className={cn(
'inline-flex items-center px-3 py-2.5 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200', 'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{ {
'bg-gray-100 border-base-content/10': !disabled, 'bg-gray-100 border-base-content/10': !disabled,
'bg-gray-50 border-base-content/10': disabled, 'bg-gray-50 border-base-content/10': disabled,
@@ -165,10 +165,10 @@ const TextInput = ({
{inputSuffix && ( {inputSuffix && (
<div <div
className={cn( className={cn(
'inline-flex items-center px-3 py-2.5 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200', 'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{ {
'bg-gray-100 border-gray-300': !disabled, 'bg-gray-100 border-base-content/10': !disabled,
'bg-gray-50 border-gray-200': disabled, 'bg-gray-50 border-base-content/10': disabled,
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
}, },
@@ -4,10 +4,7 @@ import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInput, { import { OptionType, useSelect } from '@/components/input/SelectInput';
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
@@ -21,9 +18,9 @@ import {
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart'; import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton'; import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
import DashboardAllCharts, { import DashboardExportCharts, {
DashboardAllChartsRef, DashboardExportChartsRef,
} from '@/components/pages/dashboard/chart/DashboardAllCharts'; } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { import {
DashboardFilter, DashboardFilter,
@@ -40,6 +37,11 @@ import MenuItem from '@/components/menu/MenuItem';
import { useDashboardStore } from '@/stores/dashboard'; import { useDashboardStore } from '@/stores/dashboard';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { useUiStore } from '@/stores/ui/ui.store';
import { cn } from '@/lib/helper';
import DashboardExportStats, {
DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -59,6 +61,10 @@ const DashboardProduction = () => {
const { filterValues, setFilterValues, resetFilterValues } = const { filterValues, setFilterValues, resetFilterValues } =
useDashboardStore(); useDashboardStore();
// ===== UI STORE (for navbar actions) =====
const setNavbarActions = useUiStore((state) => state.setNavbarActions);
const clearNavbarActions = useUiStore((state) => state.clearNavbarActions);
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW' (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
); );
@@ -67,9 +73,8 @@ const DashboardProduction = () => {
normalizeToArray(filterValues.location) normalizeToArray(filterValues.location)
); );
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const statsRef = useRef<HTMLDivElement>(null); const allChartsRef = useRef<DashboardExportChartsRef>(null);
const chartRef = useRef<HTMLDivElement>(null); const allStatsRef = useRef<DashboardExportStatsRef>(null);
const allChartsRef = useRef<DashboardAllChartsRef>(null);
// ===== FETCH DATA ===== // ===== FETCH DATA =====
const { const {
@@ -194,12 +199,69 @@ const DashboardProduction = () => {
const handleExportPDF = async () => { const handleExportPDF = async () => {
await generateDashboardPDF({ await generateDashboardPDF({
filterValues: formik.values, filterValues: formik.values,
statsRef, allStatsRef,
allChartsRef, allChartsRef,
setExporting, setExporting,
}); });
}; };
// ===== Register Navbar Actions =====
const openFilterModalRef = useRef(filterModal.openModal);
openFilterModalRef.current = filterModal.openModal;
useEffect(() => {
setNavbarActions(
<div className='hidden sm:flex flex-row justify-end gap-3 '>
<ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline'
onClick={() => openFilterModalRef.current()}
/>
<Dropdown
trigger={
<Button
variant='outline'
color='none'
className={cn(
'rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon width={20} height={20} icon='heroicons:cloud-arrow-down' />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button>
}
className={{
content: 'w-full mt-1 p-0',
}}
>
<Menu
className={`p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg ${exporting ? 'hidden' : ''}`}
>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPDF}
/>
</Menu>
</Dropdown>
</div>
);
}, [formik.values, exporting, setNavbarActions]);
// Cleanup only on unmount
useEffect(() => {
return () => {
clearNavbarActions();
};
}, [clearNavbarActions]);
if (isLoadingDashboardProductionData) { if (isLoadingDashboardProductionData) {
return ( return (
<div className='w-full min-h-screen flex items-center justify-center'> <div className='w-full min-h-screen flex items-center justify-center'>
@@ -210,48 +272,62 @@ const DashboardProduction = () => {
return ( return (
<> <>
<section className='w-full p-4 space-y-6'> <section className='w-full p-3 space-y-3'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'> <div className='flex sm:hidden flex-row justify-end gap-3 '>
<div></div>
<div className='flex flex-row justify-end gap-2'>
<ButtonFilter <ButtonFilter
values={{ values={{
...formik.values, ...formik.values,
analysisMode: undefined, analysisMode: undefined,
}} }}
variant='outline' variant='outline'
className='min-w-28 rounded-lg' onClick={() => openFilterModalRef.current()}
onClick={() => filterModal.openModal()}
/> />
<Dropdown <Dropdown
trigger={ trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'> <Button
<Icon icon='heroicons:arrow-down-tray' /> variant='outline'
color='none'
className={cn(
'p-2 rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon
width={20}
height={20}
icon='heroicons:cloud-arrow-down'
/>
Export Export
<Icon icon='heroicons:chevron-down' /> <div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button> </Button>
} }
className={{ className={{
content: 'w-full', content:
'w-full mt-1 p-0 shadow-button-soft border border-base-content/10 rounded-lg',
}} }}
> >
<Menu className={exporting ? 'hidden' : ''}> <Menu
<MenuItem title='PDF' onClick={handleExportPDF} /> className={`p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg ${exporting ? 'hidden' : ''}`}
>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPDF}
/>
</Menu> </Menu>
</Dropdown> </Dropdown>
</div> </div>
</div>
{/* Dashboard Stats */} {/* Dashboard Stats */}
<div ref={statsRef}> <div>
<DashboardStats <DashboardStats
data={dashboardProductionData?.statistics_data ?? []} data={dashboardProductionData?.statistics_data ?? []}
/> />
</div> </div>
{/* Use DashboardLineChart component or skeleton */} {/* Use DashboardLineChart component or skeleton */}
<div ref={chartRef}> <div>
{isLoadingDashboardProductionData ? ( {isLoadingDashboardProductionData ? (
<DashboardLineChartSkeleton /> <DashboardLineChartSkeleton />
) : dashboardProductionData && ) : dashboardProductionData &&
@@ -287,6 +363,8 @@ const DashboardProduction = () => {
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */} {/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
{dashboardProductionData && ( {dashboardProductionData && (
<>
{/* Export Stats Charts */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -295,7 +373,22 @@ const DashboardProduction = () => {
width: '1200px', // Fixed width for consistent PDF rendering width: '1200px', // Fixed width for consistent PDF rendering
}} }}
> >
<DashboardAllCharts <DashboardExportStats
ref={allStatsRef}
data={dashboardProductionData?.statistics_data ?? []}
/>
</div>
{/* Export ALL Charts */}
<div
style={{
position: 'absolute',
left: '-9999px',
top: 0,
width: '1200px', // Fixed width for consistent PDF rendering
}}
>
<DashboardExportCharts
ref={allChartsRef} ref={allChartsRef}
data={dashboardProductionData} data={dashboardProductionData}
analysisMode={ analysisMode={
@@ -309,6 +402,7 @@ const DashboardProduction = () => {
} }
/> />
</div> </div>
</>
)} )}
</section> </section>
@@ -316,58 +410,55 @@ const DashboardProduction = () => {
ref={filterModal.ref} ref={filterModal.ref}
className={{ className={{
modal: 'p-0', modal: 'p-0',
modalBox: 'p-0 rounded-xl', modalBox: 'p-0 rounded-[0.875rem]',
}} }}
> >
<div className='space-y-6'> <div className='flex flex-col'>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300'> <div className='flex items-center justify-between p-4 border-b border-base-content/10'>
<div className='flex items-center gap-2 ms-4'> <div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} /> <Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3> <h3 className='font-medium text-sm'>Filter Data</h3>
</div> </div>
<Button <Button
variant='link' variant='link'
onClick={() => filterModal.closeModal()} onClick={() => filterModal.closeModal()}
className='text-gray-500 hover:text-gray-700 me-4 ' className='text-gray-500 hover:text-gray-700'
> >
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form <form
className='space-y-4' className='flex flex-col'
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
onReset={handleResetFilter} onReset={handleResetFilter}
> >
<div className='flex flex-col p-4 gap-1.5'>
{/* Rentang Waktu */} {/* Rentang Waktu */}
<div className='px-4'> <div>
<label className='flex items-center gap-2 mb-3'>Tanggal</label> <label className='flex text-xs items-center gap-2 py-2 font-semibold'>
<div className='flex items-start gap-2'> Tanggal
</label>
<div className='flex items-center gap-2'>
<DateInput <DateInput
name='startDate' name='startDate'
placeholder='Tanggal Mulai' placeholder='Tanggal Mulai'
value={formik.values.startDate} value={formik.values.startDate}
errorMessage={formik.errors.startDate} errorMessage={formik.errors.startDate}
onChange={formik.handleChange} onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={ isError={
Boolean(formik.errors.startDate) && Boolean(formik.errors.startDate) &&
Boolean(formik.touched.startDate) Boolean(formik.touched.startDate)
} }
/> />
<div className='hidden md:block mt-3 text-center'></div> <hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<DateInput <DateInput
name='endDate' name='endDate'
placeholder='Tanggal Akhir' placeholder='Tanggal Akhir'
value={formik.values.endDate} value={formik.values.endDate}
errorMessage={formik.errors.endDate} errorMessage={formik.errors.endDate}
onChange={formik.handleChange} onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={ isError={
Boolean(formik.errors.endDate) && Boolean(formik.errors.endDate) &&
Boolean(formik.touched.endDate) Boolean(formik.touched.endDate)
@@ -377,14 +468,18 @@ const DashboardProduction = () => {
</div> </div>
{/* Analysis Mode */} {/* Analysis Mode */}
<div className='px-4'> <div>
<label className='block mb-3'>Analysis Mode</label> <label className='block py-2 text-xs font-semibold'>
Analysis Mode
</label>
<RadioGroup <RadioGroup
name='analysisMode' name='analysisMode'
value={formik.values.analysisMode} value={formik.values.analysisMode}
onChange={(e) => { onChange={(e) => {
formik.handleChange(e); formik.handleChange(e);
setAnalysisMode(e.target.value as 'OVERVIEW' | 'COMPARISON'); setAnalysisMode(
e.target.value as 'OVERVIEW' | 'COMPARISON'
);
// Reset all dependent fields when analysis mode changes // Reset all dependent fields when analysis mode changes
formik.setFieldValue('location', []); formik.setFieldValue('location', []);
formik.setFieldValue('flock', []); formik.setFieldValue('flock', []);
@@ -394,24 +489,27 @@ const DashboardProduction = () => {
}} }}
color='primary' color='primary'
className={{ className={{
wrapper: 'w-full my-6 font-semibold text-neutral-500', wrapper:
'w-full flex flex-row items-center font-medium text-base-content/50',
radioWrapper: 'w-full grid grid-cols-2 gap-0 p-0',
}} }}
> >
<RadioGroupItem <RadioGroupItem
color='primary' color='primary'
value='OVERVIEW' value='OVERVIEW'
label='Performance Overview' label='Performance Overview'
className='w-full p-3'
/> />
<RadioGroupItem <RadioGroupItem
color='primary' color='primary'
value='COMPARISON' value='COMPARISON'
label='Performance Comparison' label='Performance Comparison'
className='w-full p-3'
/> />
</RadioGroup> </RadioGroup>
</div> </div>
{formik.values.analysisMode === 'COMPARISON' && ( {formik.values.analysisMode === 'COMPARISON' && (
<div className='px-4'>
<SelectInputRadio <SelectInputRadio
label='Compared By' label='Compared By'
value={comparisonTypeOptions.find( value={comparisonTypeOptions.find(
@@ -430,12 +528,13 @@ const DashboardProduction = () => {
Boolean(formik.errors.comparisonType) && Boolean(formik.errors.comparisonType) &&
Boolean(formik.touched.comparisonType) Boolean(formik.touched.comparisonType)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
</div>
)} )}
{/* Location */} {/* Location */}
<div className='px-4'>
{comparisonTypeOptions.find( {comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType (option) => option.value === formik.values.comparisonType
)?.value === 'FARM' ? ( )?.value === 'FARM' ? (
@@ -465,6 +564,9 @@ const DashboardProduction = () => {
Boolean(formik.errors.location) && Boolean(formik.errors.location) &&
Boolean(formik.touched.location) Boolean(formik.touched.location)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -493,9 +595,11 @@ const DashboardProduction = () => {
Boolean(formik.errors.location) && Boolean(formik.errors.location) &&
Boolean(formik.touched.location) Boolean(formik.touched.location)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
)} )}
</div>
{/* Flock */} {/* Flock */}
{!( {!(
@@ -505,7 +609,7 @@ const DashboardProduction = () => {
formik.values.comparisonType === 'KANDANG' formik.values.comparisonType === 'KANDANG'
) )
) && ( ) && (
<div className='px-4'> <>
{comparisonTypeOptions.find( {comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType (option) => option.value === formik.values.comparisonType
)?.value === 'FLOCK' ? ( )?.value === 'FLOCK' ? (
@@ -530,6 +634,9 @@ const DashboardProduction = () => {
Boolean(formik.errors.flock) && Boolean(formik.errors.flock) &&
Boolean(formik.touched.flock) Boolean(formik.touched.flock)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -553,9 +660,12 @@ const DashboardProduction = () => {
Boolean(formik.errors.flock) && Boolean(formik.errors.flock) &&
Boolean(formik.touched.flock) Boolean(formik.touched.flock)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
)} )}
</div> </>
)} )}
{/* Kandang */} {/* Kandang */}
@@ -563,7 +673,7 @@ const DashboardProduction = () => {
formik.values.analysisMode === 'COMPARISON' && formik.values.analysisMode === 'COMPARISON' &&
!(formik.values.comparisonType === 'KANDANG') !(formik.values.comparisonType === 'KANDANG')
) && ( ) && (
<div className='px-4'> <>
{comparisonTypeOptions.find( {comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType (option) => option.value === formik.values.comparisonType
)?.value === 'KANDANG' ? ( )?.value === 'KANDANG' ? (
@@ -588,6 +698,9 @@ const DashboardProduction = () => {
Boolean(formik.errors.kandang) && Boolean(formik.errors.kandang) &&
Boolean(formik.touched.kandang) Boolean(formik.touched.kandang)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -611,26 +724,38 @@ const DashboardProduction = () => {
Boolean(formik.errors.kandang) && Boolean(formik.errors.kandang) &&
Boolean(formik.touched.kandang) Boolean(formik.touched.kandang)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
)} )}
</div> </>
)} )}
<div className='w-full p-4'> {formErrorList.length > 0 && (
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <div className='w-full'>
<AlertErrorList
formErrorList={formErrorList}
onClose={close}
/>
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between gap-4 p-4 border-t border-base-content/10 bg-gray-100'>
<Button <Button
type='reset' type='reset'
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='rounded-lg p-3 bg-gray-100 border-gray-100 text-base-content/65 hover:bg-base-content/10'
> >
Reset Filter Reset Filter
</Button> </Button>
<Button type='submit' className='me-4 min-w-36 rounded-lg'> <Button
Terapkan Filter type='submit'
className='min-w-40 text-sm p-3 text-white rounded-lg'
>
Apply Filter
</Button> </Button>
</div> </div>
</form> </form>
@@ -1,6 +1,7 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
@@ -147,11 +148,12 @@ const DashboardLineChart = ({
return ( return (
<Card <Card
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}} }}
variant='bordered' variant='bordered'
> >
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'> <div className='flex flex-col sm:flex-row justify-between items-start gap-4 mb-3'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Performance{' '} Performance{' '}
<Icon <Icon
@@ -165,26 +167,28 @@ const DashboardLineChart = ({
<Dropdown <Dropdown
align='end' align='end'
direction='bottom' direction='bottom'
className={{
content: 'mt-1 min-w-full',
}}
trigger={ trigger={
<Button <Button
variant='outline' variant='outline'
color='none' color='none'
className='text-neutral-500 hover:text-neutral-700 rounded-lg px-4 py-2 border-neutral-300' className='py-2.5 pl-3 pr-2 text-base-content/50 rounded-lg text-sm border-base-content/10 shadow-button-soft'
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
{chartTypeLabels[chartData]}{' '} {chartTypeLabels[chartData]}{' '}
<div className='divider divider-horizontal p-0 m-0 before:bg-neutral-300 after:bg-neutral-300'></div> <div className='w-6 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon icon='heroicons:chevron-down' width={20} height={20} /> <Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button> </Button>
} }
className={{
content: 'w-52 mt-3',
}}
controlled={open} controlled={open}
> >
<Menu> <Menu className='p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg'>
<MenuItem <MenuItem
title='Body weight' title='Body weight'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('body_weight'); setChartData('body_weight');
setOpen(!open); setOpen(!open);
@@ -192,6 +196,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Performance' title='Performance'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('performance'); setChartData('performance');
setOpen(!open); setOpen(!open);
@@ -199,6 +204,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='FCR' title='FCR'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('fcr'); setChartData('fcr');
setOpen(!open); setOpen(!open);
@@ -206,6 +212,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Quality Control' title='Quality Control'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('quality_control'); setChartData('quality_control');
setOpen(!open); setOpen(!open);
@@ -213,6 +220,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Deplesi' title='Deplesi'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('deplesi'); setChartData('deplesi');
setOpen(!open); setOpen(!open);
@@ -248,8 +256,8 @@ const DashboardLineChart = ({
.includes('std'); .includes('std');
return ( return (
<button <Button
key={series.id} key={`${series.id}-${index}`}
onClick={() => { onClick={() => {
const newVisible = new Set(visibleSeries); const newVisible = new Set(visibleSeries);
if (isVisible) { if (isVisible) {
@@ -259,14 +267,16 @@ const DashboardLineChart = ({
} }
setVisibleSeries(newVisible); setVisibleSeries(newVisible);
}} }}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${ variant='outline'
color='none'
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
isVisible isVisible
? 'border-neutral-400 bg-neutral-50' ? 'border-base-content/10 hover:bg-base-content/4'
: 'border-neutral-300 hover:bg-neutral-50' : 'border-base-content/10 bg-base-content/4'
}`} }`}
> >
<div <div
className={`w-6 h-0.5 ${ className={`w-5 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : '' isStandard ? 'border-t-2 border-dashed' : ''
} ${!isVisible ? 'opacity-30' : ''}`} } ${!isVisible ? 'opacity-30' : ''}`}
style={{ style={{
@@ -279,17 +289,17 @@ const DashboardLineChart = ({
}} }}
></div> ></div>
<span <span
className={`text-sm ${isVisible ? 'text-neutral-900 font-medium' : 'text-neutral-700'}`} className={`font-semibold text-sm ${isVisible ? 'text-base-content/50' : 'text-base-content/50'}`}
> >
{series.label} {series.label}
</span> </span>
<Icon <Icon
icon='heroicons:information-circle' icon='heroicons:eye'
width={16} width={16}
height={16} height={16}
className='text-neutral-400' className='text-base-content/40'
/> />
</button> </Button>
); );
}); });
})()} })()}
@@ -335,20 +345,68 @@ const DashboardLineChart = ({
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' /> <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis <XAxis
dataKey='week' dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }} tick={{
fontSize: 12,
fill: '#18181B',
opacity: 0.5,
fontWeight: 600,
}}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb' }} axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
label={{ label={{
value: 'Weeks', value: 'Weeks',
position: 'insideBottom', position: 'insideBottom',
offset: -5, offset: -5,
style: { fontSize: 12, fill: '#9ca3af' }, style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}} }}
/> />
<YAxis <YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }} tick={{
fontSize: 12,
fill: '#18181B',
opacity: 0.5,
fontWeight: 600,
}}
label={
(chartData === 'body_weight' || chartData === 'performance') &&
analysisMode === 'OVERVIEW'
? {
value:
chartData === 'body_weight'
? 'Body Weight'
: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: analysisMode === 'COMPARISON'
? {
value: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: undefined
}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb' }} axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
domain={(() => { domain={(() => {
// Calculate dynamic domain based on visible data // Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = []; let seriesData: DashboardChartsSeries[] = [];
@@ -399,14 +457,12 @@ const DashboardLineChart = ({
})()} })()}
ticks={(() => { ticks={(() => {
// Calculate dynamic ticks based on domain // Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = []; let dataset: DashboardChartsDataset[] = [];
if ( if (
analysisMode === 'OVERVIEW' && analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts) isOverviewCharts(data.charts)
) { ) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || []; dataset = data.charts[chartData]?.dataset || [];
} else if ( } else if (
analysisMode === 'COMPARISON' && analysisMode === 'COMPARISON' &&
@@ -416,7 +472,6 @@ const DashboardLineChart = ({
data.charts.farm || data.charts.farm ||
data.charts.flock || data.charts.flock ||
data.charts.kandang; data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || []; dataset = comparisonChart?.dataset || [];
} }
@@ -436,6 +491,20 @@ const DashboardLineChart = ({
const minValue = Math.min(...allValues); const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues); const maxValue = Math.max(...allValues);
// Handle edge case where min equals max
if (minValue === maxValue) {
const value = Math.round(minValue);
const padding = Math.max(10, Math.abs(value) * 0.2);
return [
Math.floor(value - padding),
Math.floor(value - padding / 2),
value,
Math.ceil(value + padding / 2),
Math.ceil(value + padding),
];
}
const padding = (maxValue - minValue) * 0.1; const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding)); const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding); const domainMax = Math.ceil(maxValue + padding);
@@ -444,21 +513,25 @@ const DashboardLineChart = ({
const range = domainMax - domainMin; const range = domainMax - domainMin;
const step = range / 4; const step = range / 4;
return [ // Use Set to ensure unique values
const tickSet = new Set([
domainMin, domainMin,
Math.round(domainMin + step), Math.round(domainMin + step),
Math.round(domainMin + step * 2), Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3), Math.round(domainMin + step * 3),
domainMax, domainMax,
]; ]);
return Array.from(tickSet).sort((a, b) => a - b);
})()} })()}
tickFormatter={(value) => formatNumber(Number(value))}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
backgroundColor: '#1f2937', backgroundColor: '#1f2937',
border: 'none', border: 'none',
borderRadius: '8px', borderRadius: '8px',
padding: '8px 12px', padding: '12px 12px',
color: 'white', color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} labelStyle={{ color: 'white', marginBottom: '4px' }}
@@ -466,8 +539,8 @@ const DashboardLineChart = ({
labelFormatter={(value) => `Week ${value}`} labelFormatter={(value) => `Week ${value}`}
content={(props) => { content={(props) => {
return ( return (
<div className='flex flex-col gap-2 rounded-lg bg-neutral-950 p-4 text-white'> <div className='flex flex-col gap-1.5 rounded-lg bg-neutral-950 p-4 text-white'>
<p className='text-neutral-300 text-xs font-semibold text-start'> <p className='text-white/50 text-xs font-semibold text-start'>
{analysisMode === 'OVERVIEW' {analysisMode === 'OVERVIEW'
? selectedKandang ? selectedKandang
? selectedKandang.label || 'Overview Performance' ? selectedKandang.label || 'Overview Performance'
@@ -506,12 +579,12 @@ const DashboardLineChart = ({
return ( return (
<li <li
key={item.name} key={`${item.name}-${index}`}
className='flex w-full justify-between items-center flex-row gap-6 p-0' className='flex w-full justify-between items-center flex-row gap-y-1.5 gap-x-3 p-0'
> >
<span className='flex flex-row gap-1 items-center'> <span className='flex flex-row gap-1.5 items-center'>
<div <div
className='h-4 w-4 m-0 rounded-md' className='h-5 w-5 m-0 rounded'
style={{ style={{
backgroundColor: color, backgroundColor: color,
}} }}
@@ -526,7 +599,7 @@ const DashboardLineChart = ({
); );
})} })}
</ul> </ul>
<p className='text-neutral-300 text-xs text-start'> <p className='text-white/50 text-xs text-start'>
Week {props.label} Week {props.label}
</p> </p>
</div> </div>
@@ -598,7 +671,7 @@ const DashboardLineChart = ({
return ( return (
<Line <Line
key={series.id} key={`${series.id}--${index}`}
type='monotone' type='monotone'
dataKey={dataKey} dataKey={dataKey}
name={series.label} name={series.label}
@@ -649,22 +722,18 @@ const DashboardLineChart = ({
return ( return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'> <div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */} {/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'> <DataStateSkeleton
icon={
<Icon <Icon
icon='heroicons:chart-bar' icon='heroicons:chart-bar'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/>
}
title='Data Not Yet Available'
description='Please change your filters to get the data.'
/> />
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</div> </div>
); );
} }
@@ -21,7 +21,7 @@ const CARD_CONFIG = [
key: 'Avg. Selling Price', key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar', icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const, alertColor: 'success' as const,
suffix: ' /Kg', suffix: ' /Kg Telur',
prefix: '', prefix: '',
}, },
{ {
@@ -48,7 +48,7 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
icon: isPositive icon: isPositive
? 'heroicons:arrow-trending-up' ? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down', : 'heroicons:arrow-trending-down',
color: isPositive ? 'text-success' : 'text-error', color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent), value: Math.abs(percent),
}; };
}; };
@@ -60,14 +60,16 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
{prefix} {prefix}
{formatNumber(value)} {formatNumber(value)}
{suffix && ( {suffix && (
<span className='text-sm font-normal text-neutral-500'>{suffix}</span> <span className='text-sm font-normal text-base-content/50'>
{suffix}
</span>
)} )}
</> </>
); );
}; };
return ( return (
<div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-6'> <div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-3'>
{CARD_CONFIG.map((config) => { {CARD_CONFIG.map((config) => {
// Find matching data from API // Find matching data from API
const cardData = data.find((item) => item.label === config.key); const cardData = data.find((item) => item.label === config.key);
@@ -78,35 +80,41 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Card <Card
key={config.key} key={config.key}
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-xl border border-base-content/10',
body: 'p-0', body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}} }}
variant='bordered' variant='bordered'
footer={ footer={
<div className='flex flex-row justify-between px-4 pb-4'> <div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-neutral-400 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
From last month From last month
</div> </div>
<div className='text-neutral-400 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
Filter Required Filter Required
</div> </div>
</div> </div>
} }
> >
<div className='flex flex-row items-center gap-4 px-4 pt-4'> <div className='flex flex-row items-center gap-3 px-4 py-4'>
<Alert variant='soft' className='rounded-lg p-3 bg-neutral-100'> <Alert
variant='soft'
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon <Icon
icon={config.icon} icon={config.icon}
width={32} width={24}
height={32} height={24}
className='text-neutral-400' className='text-base-content/50'
/> />
</Alert> </Alert>
<div> <div>
<h3 className='text-neutral-400 font-semibold text-sm'> <h3 className='text-base-content/50 font-semibold text-sm'>
{config.key} {config.key}
</h3> </h3>
<p className='text-2xl font-semibold text-neutral-400'> <p className='text-xl font-semibold text-base-content/50'>
******** ********
</p> </p>
</div> </div>
@@ -121,17 +129,20 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Card <Card
key={config.key} key={config.key}
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-xl border border-base-content/10',
body: 'p-0', body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}} }}
variant='bordered' variant='bordered'
footer={ footer={
<div className='flex flex-row justify-between px-4 pb-4'> <div className='flex flex-row justify-between px-4 pb-4'>
<div className='text-neutral-500 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
From last month From last month
</div> </div>
<div <div
className={`${trend.color} font-semibold flex flex-row items-center gap-1 text-sm`} className={`${trend.color} font-semibold flex flex-row items-center gap-2 text-xs`}
> >
<Icon icon={trend.icon} width={16} height={16} /> <Icon icon={trend.icon} width={16} height={16} />
{trend.value}% {trend.value}%
@@ -143,15 +154,15 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Alert <Alert
variant='soft' variant='soft'
color={config.alertColor} color={config.alertColor}
className='rounded-lg p-3' className={`rounded-lg p-3 bg-[${config.alertColor}]/12 flex items-center justify-center`}
> >
<Icon icon={config.icon} width={32} height={32} /> <Icon icon={config.icon} width={24} height={24} />
</Alert> </Alert>
<div> <div className='space-y-1'>
<h3 className='text-neutral-500 font-semibold text-sm'> <h3 className='text-base-content/50 font-semibold text-sm'>
{cardData.label} {cardData.label}
</h3> </h3>
<p className='text-2xl font-semibold'> <p className='text-xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)} {formatValue(cardData.value, config.prefix, config.suffix)}
</p> </p>
</div> </div>
@@ -17,12 +17,12 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
type DashboardAllChartsProps = { type DashboardExportChartsProps = {
data: Dashboard; data: Dashboard;
analysisMode: string; analysisMode: string;
}; };
export type DashboardAllChartsRef = { export type DashboardExportChartsRef = {
getChartRefs: () => { getChartRefs: () => {
key: string; key: string;
ref: HTMLDivElement | null; ref: HTMLDivElement | null;
@@ -99,9 +99,9 @@ const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
deplesi: 'Deplesi', deplesi: 'Deplesi',
}; };
const DashboardAllCharts = forwardRef< const DashboardExportCharts = forwardRef<
DashboardAllChartsRef, DashboardExportChartsRef,
DashboardAllChartsProps DashboardExportChartsProps
>(({ data, analysisMode }, ref) => { >(({ data, analysisMode }, ref) => {
// Create refs for charts - use string keys for flexibility // Create refs for charts - use string keys for flexibility
const chartRefs = useRef<{ const chartRefs = useRef<{
@@ -189,7 +189,8 @@ const DashboardAllCharts = forwardRef<
> >
<Card <Card
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}} }}
variant='bordered' variant='bordered'
> >
@@ -338,6 +339,6 @@ const DashboardAllCharts = forwardRef<
); );
}); });
DashboardAllCharts.displayName = 'DashboardAllCharts'; DashboardExportCharts.displayName = 'DashboardExportCharts';
export default DashboardAllCharts; export default DashboardExportCharts;
@@ -0,0 +1,197 @@
import Alert from '@/components/Alert';
import Card from '@/components/Card';
import { formatNumber } from '@/lib/helper';
import { DashboardStatisticsData } from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface DashboardStatsProps {
data: DashboardStatisticsData[];
}
export type DashboardExportStatsRef = {
getStatsRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
getContainerRef: () => HTMLDivElement | null;
};
// Konfigurasi untuk setiap kartu
const CARD_CONFIG = [
{
key: 'HPP Global',
icon: 'heroicons:banknotes',
alertColor: 'warning' as const,
suffix: ' /Kg',
prefix: 'RP ',
},
{
key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const,
suffix: ' /Kg',
prefix: '',
},
{
key: 'FCR',
icon: 'heroicons:clipboard-document-list',
alertColor: 'info' as const,
suffix: '',
prefix: '',
},
{
key: 'Mortality',
icon: 'heroicons:exclamation-triangle',
alertColor: 'error' as const,
suffix: ' %',
prefix: '',
},
];
const DashboardExportStats = forwardRef<
DashboardExportStatsRef,
DashboardStatsProps
>(({ data }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
// Helper to get trend icon and color
const getTrendDisplay = (percent: number) => {
const isPositive = percent >= 0;
return {
icon: isPositive
? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down',
color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent),
};
};
// Helper to format value
const formatValue = (value: number, prefix: string, suffix: string) => {
return (
<>
{prefix}
{formatNumber(value)}
{suffix && (
<span className='text-sm font-normal text-base-content/50'>
{suffix}
</span>
)}
</>
);
};
// Expose container ref through imperative handle
useImperativeHandle(ref, () => ({
getStatsRefs: () => [],
getContainerRef: () => containerRef.current,
}));
return (
<div ref={containerRef} className='grid grid-cols-4 gap-3'>
{CARD_CONFIG.map((config) => {
// Find matching data from API
const cardData = data.find((item) => item.label === config.key);
if (!cardData) {
// Show placeholder card for missing data (FCR & Mortality)
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-base-content/50 font-semibold text-xs'>
From last month
</div>
<div className='text-base-content/50 font-semibold text-xs'>
Filter Required
</div>
</div>
}
>
<div className='flex flex-row items-center gap-3 px-4 pt-4'>
<Alert
variant='soft'
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon
icon={config.icon}
width={24}
height={24}
className='text-base-content/50'
/>
</Alert>
<div>
<h3 className='text-base-content/50 font-semibold text-sm'>
{config.key}
</h3>
<p className='text-xl font-semibold text-base-content/50'>
********
</p>
</div>
</div>
</Card>
);
}
const trend = getTrendDisplay(cardData.percent_last_month);
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg border border-base-content/10',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-base-content/50 font-semibold text-xs'>
From last month
</div>
<div
className={`${trend.color} font-semibold flex flex-row items-center gap-2 text-xs`}
>
<Icon icon={trend.icon} width={16} height={16} />
{trend.value}%
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert
variant='soft'
color={config.alertColor}
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon icon={config.icon} width={24} height={24} />
</Alert>
<div>
<h3 className='text-base-content/50 font-semibold text-sm'>
{cardData.label}
</h3>
<p className='text-xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)}
</p>
</div>
</div>
</Card>
);
})}
</div>
);
});
DashboardExportStats.displayName = 'DashboardExportStats';
export default DashboardExportStats;
@@ -3,18 +3,19 @@ import { toPng } from 'html-to-image';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts'; import { DashboardExportChartsRef } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { DashboardExportStatsRef } from '@/components/pages/dashboard/export/DashboardExportStats';
interface DashboardPDFExportParams { interface DashboardPDFExportParams {
filterValues: DashboardFilterType; filterValues: DashboardFilterType;
statsRef: React.RefObject<HTMLDivElement | null>; allStatsRef: React.RefObject<DashboardExportStatsRef | null>;
allChartsRef: React.RefObject<DashboardAllChartsRef | null>; allChartsRef: React.RefObject<DashboardExportChartsRef | null>;
setExporting: (value: boolean) => void; setExporting: (value: boolean) => void;
} }
export const generateDashboardPDF = async ({ export const generateDashboardPDF = async ({
filterValues, filterValues,
statsRef, allStatsRef,
allChartsRef, allChartsRef,
setExporting, setExporting,
}: DashboardPDFExportParams): Promise<void> => { }: DashboardPDFExportParams): Promise<void> => {
@@ -168,8 +169,10 @@ export const generateDashboardPDF = async ({
yPosition += 10; yPosition += 10;
// Capture and add stats if available // Capture and add stats if available
if (statsRef.current) { if (allStatsRef.current) {
const statsImage = await toPng(statsRef.current, { const statsContainer = allStatsRef.current.getContainerRef();
if (statsContainer) {
const statsImage = await toPng(statsContainer, {
quality: 1, quality: 1,
pixelRatio: 2, pixelRatio: 2,
}); });
@@ -194,6 +197,7 @@ export const generateDashboardPDF = async ({
); );
yPosition += statsHeight + 10; yPosition += statsHeight + 10;
} }
}
if (allChartsRef.current) { if (allChartsRef.current) {
// Get all individual chart refs // Get all individual chart refs
@@ -1,95 +1,88 @@
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { DashboardMeta } from '@/types/api/dashboard/dashboard'; import { DashboardMeta } from '@/types/api/dashboard/dashboard';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => { const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => {
return ( return (
<div className='w-full bg-white rounded-lg shadow-sm border border-gray-200 p-6 relative'> <div className='w-full bg-white rounded-xl border border-base-content/10 p-4 relative'>
{/* Header with title skeleton */} {/* Header with title skeleton */}
<div className='text-lg font-semibold'> <div className='text-base font-semibold'>
Performance{' '} Performance{' '}
<Icon <Icon
icon='heroicons:information-circle' icon='heroicons:information-circle'
width={20} width={20}
height={20} height={20}
className='inline text-neutral-500' className='inline text-base-content/50'
/> />
</div> </div>
{/* Chart area with axes skeleton */} {/* Chart area with axes skeleton */}
<div className='relative mt-6'> <div className='relative mt-6 '>
{/* Main chart container */}
<div className='flex gap-4'>
{/* Y-axis skeleton (left side) */}
<div className='flex flex-col justify-between py-4 space-y-4'>
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className='h-4 w-12 bg-gray-100 rounded animate-pulse'
></div>
))}
</div>
{/* Chart content area */} {/* Chart content area */}
<div className='flex-1 relative'> <div className='flex-1 relative'>
{/* Empty state centered in chart area */} {/* Empty state centered in chart area */}
<div className='absolute inset-0 flex flex-col items-center justify-center pb-12'> <div className='absolute inset-0 flex flex-col items-center justify-center pb-10'>
{!meta?.filters && ( {!meta?.filters && (
<> <>
{/* Filter icon */} {/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'> <DataStateSkeleton
icon={
<Icon <Icon
icon='heroicons:funnel' icon='heroicons:funnel'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/>
}
title='No Filters Selected'
description='Please choose filters to narrow down your results and make your search easier.'
/> />
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
No Filters Selected
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please choose filters to narrow down your results and make
your search easier.
</p>
</> </>
)} )}
{meta?.filters && ( {meta?.filters && (
<> <>
{/* Filter icon */} {/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'> <DataStateSkeleton
icon={
<Icon <Icon
icon='heroicons:chart-bar' icon='heroicons:chart-bar'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/>
}
title='Data Not Yet Available'
description='Please change your filters to get the data.'
/> />
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</> </>
)} )}
</div> </div>
{/* Placeholder for chart height */} <div className='flex flex-row w-full items-center gap-4'>
<div className='h-64'></div> <div className='flex-1 h-full min-w-4'>
<div className='h-28.5 w-4 bg-base-content/4 rounded'></div>
</div>
<div className='w-full grid grid-cols-1 gap-y-13.25 mb-2'>
{[1, 2, 3, 4].map((item) => (
<div key={item} className='flex items-center w-full h-4 gap-4'>
<div className='h-4 w-6 bg-base-content/4 rounded'></div>
<div className='h-0.25 w-full bg-base-content/4 rounded'></div>
</div>
))}
</div>
</div>
{/* X-axis skeleton (bottom) */} {/* X-axis skeleton (bottom) */}
<div className='flex justify-between pt-4 border-t border-gray-100'> <div className='grid grid-cols-10 gap-15 mt-4 ps-13 sm:ps-26 overflow-x-hidden'>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
<div <div
key={item} key={item}
className='h-4 w-8 bg-gray-100 rounded animate-pulse' className='h-4 w-9.5 bg-base-content/4 rounded'
></div> ></div>
))} ))}
</div> </div>
<div className='flex justify-center pt-4 ps-13 sm:ps-26'>
<div className='h-4 w-28.5 bg-base-content/4 rounded'></div>
</div> </div>
</div> </div>
</div> </div>
@@ -372,23 +372,6 @@ const ExpenseRequestForm = ({
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='grid grid-cols-12 gap-4'> <div className='grid grid-cols-12 gap-4'>
<SelectInput <SelectInput
label='Kategori' label='Kategori'
@@ -557,6 +540,24 @@ const ExpenseRequestForm = ({
/> />
</div> </div>
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
@@ -27,12 +27,12 @@ type MovementFormSchemaType = {
product_qty: number | string; product_qty: number | string;
}[]; }[];
deliveries: { deliveries: {
delivery_cost?: number | string; delivery_cost?: number | string | null;
delivery_cost_per_item?: number | string; delivery_cost_per_item?: number | string | null;
document?: File | MovementDocument | null; document?: File | MovementDocument | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name?: string | null;
vehicle_plate: string; vehicle_plate?: string | null;
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
@@ -59,12 +59,12 @@ export type ProductSchema = {
}; };
export type DeliverySchema = { export type DeliverySchema = {
delivery_cost?: number | string; delivery_cost?: number | string | null;
delivery_cost_per_item?: number | string; delivery_cost_per_item?: number | string | null;
document?: File | MovementDocument | null; document?: File | MovementDocument | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name?: string | null;
vehicle_plate: string; vehicle_plate?: string | null;
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
@@ -120,32 +120,64 @@ const DeliveryDocumentSchema = Yup.mixed<File | MovementDocument>()
const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
delivery_cost: Yup.number() delivery_cost: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .transform((value) =>
isNaN(value) || value === '' || value === null ? undefined : value
)
.when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Biaya pengiriman wajib diisi!')
.min(1, 'Biaya minimal 1!') .min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!') .typeError('Biaya harus berupa angka!'),
.test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) { otherwise: (schema) =>
const { delivery_cost_per_item } = this.parent; schema
return ( .optional()
(value !== undefined && value > 0) || .nullable()
(delivery_cost_per_item !== undefined && delivery_cost_per_item > 0) .min(1, 'Biaya minimal 1!')
); .typeError('Biaya harus berupa angka!'),
}), }),
delivery_cost_per_item: Yup.number() delivery_cost_per_item: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .transform((value) =>
isNaN(value) || value === '' || value === null ? undefined : value
)
.when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Biaya per item wajib diisi!')
.min(1, 'Biaya per item minimal 1!') .min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!') .typeError('Biaya per item harus berupa angka!'),
.test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) { otherwise: (schema) =>
const { delivery_cost } = this.parent; schema
return ( .optional()
(value !== undefined && value > 0) || .nullable()
(delivery_cost !== undefined && delivery_cost > 0) .min(1, 'Biaya per item minimal 1!')
); .typeError('Biaya per item harus berupa angka!'),
}), }),
document_path: Yup.string().nullable().optional(), document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: DeliveryDocumentSchema, document: DeliveryDocumentSchema,
driver_name: Yup.string().required('Nama sopir wajib diisi!'), driver_name: Yup.string().when('supplier_id', {
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Nama sopir wajib diisi!')
.min(1, 'Nama sopir wajib diisi!'),
otherwise: (schema) => schema.optional().nullable(),
}),
vehicle_plate: Yup.string().when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Plat nomor wajib diisi!')
.min(1, 'Plat nomor wajib diisi!'),
otherwise: (schema) => schema.optional().nullable(),
}),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -279,12 +311,12 @@ export const getMovementFormInitialValues = (
}) ?? [], }) ?? [],
})) ?? [ })) ?? [
{ {
delivery_cost: undefined, delivery_cost: null,
delivery_cost_per_item: undefined, delivery_cost_per_item: null,
document: null, document: null,
document_path: null, document_path: null,
driver_name: '', driver_name: null,
vehicle_plate: '', vehicle_plate: null,
supplier: null, supplier: null,
supplier_id: 0, supplier_id: 0,
products: [ products: [
@@ -86,6 +86,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
const {
setInputValue: setSourceWarehouseSelectInputValue,
isLoadingOptions: isLoadingSourceWarehouses,
loadMore: loadMoreSourceWarehouses,
rawData: sourceWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
transfer_context: 'inventory_transfer',
});
const { const {
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses, isLoadingOptions: isLoadingWarehouses,
@@ -136,6 +145,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return stockMap; return stockMap;
}, [allProductWarehouses]); }, [allProductWarehouses]);
const sourceWarehouseOptions = useMemo(() => {
if (!isResponseSuccess(sourceWarehouses)) return [];
return (
sourceWarehouses?.data.map((w) => {
warehouseStockMap.get(w.id);
return {
value: w.id,
label: w.name,
area: w.area?.name,
location:
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
? w.location?.name
: undefined,
};
}) || []
);
}, [sourceWarehouses, warehouseStockMap]);
const warehouseOptions = useMemo(() => { const warehouseOptions = useMemo(() => {
if (!isResponseSuccess(warehouses)) return []; if (!isResponseSuccess(warehouses)) return [];
@@ -228,19 +256,49 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
} }
return { const deliveryObj: {
delivery_cost: parseInt((d.delivery_cost || '').toString()) || 0, products: Array<{ product_id: number; product_qty: number }>;
delivery_cost_per_item: delivery_cost?: number;
parseInt((d.delivery_cost_per_item || '').toString()) || 0, delivery_cost_per_item?: number;
document_index: documentIndex, document_index?: number;
driver_name: d.driver_name, driver_name?: string;
vehicle_plate: d.vehicle_plate, vehicle_plate?: string;
supplier_id: d.supplier_id, supplier_id?: number;
} = {
products: d.products.map((p) => ({ products: d.products.map((p) => ({
product_id: p.product_id, product_id: p.product_id,
product_qty: parseInt(p.product_qty.toString()) || 0, product_qty: parseInt(p.product_qty.toString()) || 0,
})), })),
}; };
const deliveryCost = parseInt((d.delivery_cost || '').toString()) || 0;
if (deliveryCost > 0) {
deliveryObj.delivery_cost = deliveryCost;
}
const deliveryCostPerItem =
parseInt((d.delivery_cost_per_item || '').toString()) || 0;
if (deliveryCostPerItem > 0) {
deliveryObj.delivery_cost_per_item = deliveryCostPerItem;
}
if (documentIndex >= 0) {
deliveryObj.document_index = documentIndex;
}
if (d.driver_name) {
deliveryObj.driver_name = d.driver_name;
}
if (d.vehicle_plate) {
deliveryObj.vehicle_plate = d.vehicle_plate;
}
if (d.supplier_id) {
deliveryObj.supplier_id = d.supplier_id;
}
return deliveryObj;
}); });
const payload: CreateMovementPayload = { const payload: CreateMovementPayload = {
@@ -844,32 +902,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
(warehouseId: number) => { (warehouseId: number) => {
const stockInfo = warehouseStockMap.get(warehouseId); const stockInfo = warehouseStockMap.get(warehouseId);
if (!stockInfo) { if (!stockInfo) {
return ( return <span className='text-xs'>Kosong</span>;
<Badge
variant='ghost'
color='neutral'
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
Kosong
</Badge>
);
} }
const { productCount } = stockInfo; const { productCount } = stockInfo;
let color: 'neutral' | 'success' | 'warning' = 'neutral';
if (productCount === 0) color = 'warning';
else if (productCount > 0) color = 'success';
return ( return (
<Badge <span className='text-xs whitespace-nowrap'>
variant='soft'
color={color}
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
Tersedia {productCount} produk Tersedia {productCount} produk
</Badge> </span>
); );
}, },
[warehouseStockMap] [warehouseStockMap]
@@ -971,6 +1012,28 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[formik.values.deliveries, formik.values.products, type] [formik.values.deliveries, formik.values.products, type]
); );
const isSupplierSelected = useCallback(
(deliveryIdx: number) => {
const delivery = formik.values.deliveries?.[deliveryIdx];
return (
delivery &&
delivery.supplier_id !== null &&
delivery.supplier_id !== undefined &&
delivery.supplier_id > 0
);
},
[formik.values.deliveries]
);
const hasAnySupplierSelected = useMemo(() => {
return formik.values.deliveries?.some(
(delivery) =>
delivery.supplier_id !== null &&
delivery.supplier_id !== undefined &&
delivery.supplier_id > 0
);
}, [formik.values.deliveries]);
// ===== COMPUTED VALUES ===== // ===== COMPUTED VALUES =====
const invalidQtyRows = useMemo( const invalidQtyRows = useMemo(
() => () =>
@@ -1263,25 +1326,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{movementFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{movementFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Top card - Movement details */} {/* Top card - Movement details */}
<Card <Card
title='Detail Movement' title='Detail Movement'
@@ -1338,10 +1382,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
placeholder='Pilih gudang asal...' placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse} value={formik.values.source_warehouse}
onChange={handleSourceWarehouseChange} onChange={handleSourceWarehouseChange}
options={warehouseOptions} options={sourceWarehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setSourceWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses} onMenuScrollToBottom={loadMoreSourceWarehouses}
isLoading={isLoadingWarehouses} isLoading={isLoadingSourceWarehouses}
isError={ isError={
formik.touched.source_warehouse_id && formik.touched.source_warehouse_id &&
Boolean(formik.errors.source_warehouse_id) Boolean(formik.errors.source_warehouse_id)
@@ -1349,7 +1393,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.source_warehouse_id as string} errorMessage={formik.errors.source_warehouse_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
startAdornment={ inputPrefix={
formik.values.source_warehouse_id formik.values.source_warehouse_id
? getWarehouseStockAdornment( ? getWarehouseStockAdornment(
formik.values.source_warehouse_id formik.values.source_warehouse_id
@@ -1407,7 +1451,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.destination_warehouse_id as string} errorMessage={formik.errors.destination_warehouse_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
startAdornment={ inputPrefix={
formik.values.destination_warehouse_id formik.values.destination_warehouse_id
? getWarehouseStockAdornment( ? getWarehouseStockAdornment(
formik.values.destination_warehouse_id formik.values.destination_warehouse_id
@@ -1665,43 +1709,61 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
</th> </th>
<th>Supplier</th>
<th> <th>
Plat Nomor Supplier
{hasAnySupplierSelected && (
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required jika supplier dipilih'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
)}
</th>
<th>
Plat Nomor
{hasAnySupplierSelected && (
<span
className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required jika supplier dipilih'
>
<span className='text-error'>*</span>
</span>
)}
</th> </th>
<th>Dokumen</th> <th>Dokumen</th>
<th> <th>
Biaya Pengiriman (Rp.) Biaya Pengiriman (Rp.)
{hasAnySupplierSelected && (
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required jika supplier dipilih'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
)}
</th> </th>
<th> <th>
Biaya Per Item (Rp.) Biaya Per Item (Rp.)
{hasAnySupplierSelected && (
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required jika supplier dipilih'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
)}
</th> </th>
<th> <th>
Nama Sopir Nama Sopir
{hasAnySupplierSelected && (
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required' data-tip='required jika supplier dipilih'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
)}
</th> </th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
@@ -1799,10 +1861,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td> </td>
<td> <td>
<TextInput <TextInput
required
name={`deliveries.${idx}.vehicle_plate`} name={`deliveries.${idx}.vehicle_plate`}
placeholder='Masukkan plat nomor...' placeholder='Masukkan plat nomor...'
value={delivery.vehicle_plate} value={delivery.vehicle_plate ?? ''}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError( {...isRepeaterInputError(
@@ -1811,6 +1872,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -1890,10 +1952,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td> </td>
<td> <td>
<NumberInput <NumberInput
required
name={`deliveries.${idx}.delivery_cost`} name={`deliveries.${idx}.delivery_cost`}
placeholder='Masukkan biaya pengiriman...' placeholder='Masukkan biaya pengiriman...'
value={delivery.delivery_cost || ''} value={delivery.delivery_cost ?? ''}
onChange={handleDeliveryCostChangeWrapper(idx)} onChange={handleDeliveryCostChangeWrapper(idx)}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={0} decimalScale={0}
@@ -1907,6 +1968,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -1914,10 +1976,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td> </td>
<td> <td>
<NumberInput <NumberInput
required
name={`deliveries.${idx}.delivery_cost_per_item`} name={`deliveries.${idx}.delivery_cost_per_item`}
placeholder='Masukkan biaya per item...' placeholder='Masukkan biaya per item...'
value={delivery.delivery_cost_per_item || ''} value={delivery.delivery_cost_per_item ?? ''}
onChange={handleDeliveryCostPerItemChangeWrapper(idx)} onChange={handleDeliveryCostPerItemChangeWrapper(idx)}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={0} decimalScale={0}
@@ -1931,6 +1992,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -1938,10 +2000,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</td> </td>
<td> <td>
<TextInput <TextInput
required
name={`deliveries.${idx}.driver_name`} name={`deliveries.${idx}.driver_name`}
placeholder='Masukkan nama sopir...' placeholder='Masukkan nama sopir...'
value={delivery.driver_name} value={delivery.driver_name ?? ''}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError( {...isRepeaterInputError(
@@ -1950,6 +2011,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -2007,6 +2069,27 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
)} )}
</Card> </Card>
<div className='w-full'>
{movementFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{movementFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
</div>
{/* Action buttons */} {/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'detail' && ( {type !== 'detail' && (
@@ -159,19 +159,6 @@ const ProductCategoryForm = ({
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<TextInput <TextInput
required required
@@ -240,6 +227,21 @@ const ProductCategoryForm = ({
</div> </div>
)} )}
<div className='w-full mb-4'>
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -273,19 +273,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{productFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='grid grid-cols-1 gap-4'> <div className='grid grid-cols-1 gap-4'>
<TextInput <TextInput
required required
@@ -627,6 +614,22 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
)} )}
</div> </div>
)} )}
<div className='mb-4 w-full'>
{productFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -1125,16 +1125,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
return ( return (
<Badge <span className={'whitespace-nowrap text-xs'}>
variant='soft'
color={color}
size='sm'
className={{
badge: 'whitespace-nowrap font-semibold text-xs px-2',
}}
>
Periode {projectFlockKandangLookup.project_flock?.period} Periode {projectFlockKandangLookup.project_flock?.period}
</Badge> </span>
); );
}, [recordedProjectFlockKandangIds, projectFlockKandangLookup]); }, [recordedProjectFlockKandangIds, projectFlockKandangLookup]);
@@ -1150,33 +1143,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const hasOvkFlag = productWarehouse.product.flags?.includes('OVK'); const hasOvkFlag = productWarehouse.product.flags?.includes('OVK');
if (hasPakanFlag) { if (hasPakanFlag) {
return ( return <span className={'whitespace-nowrap text-xs'}>PAKAN</span>;
<Badge
variant='soft'
color='info'
size='sm'
className={{
badge: 'whitespace-nowrap font-semibold text-xs px-2',
}}
>
PAKAN
</Badge>
);
} }
if (hasOvkFlag) { if (hasOvkFlag) {
return ( return <span className={'whitespace-nowrap text-xs'}>OVK</span>;
<Badge
variant='soft'
color='secondary'
size='sm'
className={{
badge: 'whitespace-nowrap font-semibold text-xs px-2',
}}
>
OVK
</Badge>
);
} }
return null; return null;
@@ -1734,22 +1705,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{recordingFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{recordingFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList formErrorList={formErrorList} onClose={close} />
)}
{/* Basic Info Card */} {/* Basic Info Card */}
{(type === 'add' || type === 'edit') && ( {(type === 'add' || type === 'edit') && (
<Card <Card
@@ -1842,7 +1797,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
Boolean(formik.errors.kandang_id) Boolean(formik.errors.kandang_id)
} }
errorMessage={formik.errors.kandang_id as string} errorMessage={formik.errors.kandang_id as string}
startAdornment={ inputPrefix={
projectFlockKandangLookup || projectFlockKandangDetail projectFlockKandangLookup || projectFlockKandangDetail
? getProjectFlockBadgeAdornment() ? getProjectFlockBadgeAdornment()
: undefined : undefined
@@ -2474,7 +2429,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
!formik.values.project_flock_kandang_id !formik.values.project_flock_kandang_id
} }
isClearable={type !== 'detail'} isClearable={type !== 'detail'}
startAdornment={ inputPrefix={
stock.product_warehouse_id stock.product_warehouse_id
? getProductFlagBadgeAdornment( ? getProductFlagBadgeAdornment(
stock.product_warehouse_id stock.product_warehouse_id
@@ -3010,6 +2965,24 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</Card> </Card>
)} )}
<div className='w-full'>
{recordingFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{recordingFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList formErrorList={formErrorList} onClose={close} />
)}
</div>
{/* Action buttons */} {/* Action buttons */}
<div className='flex flex-col sm:flex-row sm:justify-between gap-2'> <div className='flex flex-col sm:flex-row sm:justify-between gap-2'>
{/* Left side - Detail & Edit actions */} {/* Left side - Detail & Edit actions */}
@@ -98,6 +98,7 @@ const TransferToLayingFormModal = () => {
'search', 'search',
{ {
category: 'GROWING', category: 'GROWING',
transfer_context: 'transfer_to_laying',
} }
); );
@@ -5,6 +5,7 @@ import UniformityGaugeChart from '@/components/pages/production/uniformity/chart
import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton'; import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton';
import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton'; import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton';
import { Uniformity, type ChartData } from '@/types/api/production/uniformity'; import { Uniformity, type ChartData } from '@/types/api/production/uniformity';
import { Icon } from '@iconify/react';
interface UniformityChartProps { interface UniformityChartProps {
uniformityData?: Uniformity | null; uniformityData?: Uniformity | null;
@@ -101,15 +102,26 @@ const UniformityChart = ({
const shouldShowEmptyState = !isFiltered; const shouldShowEmptyState = !isFiltered;
return ( return (
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'> <section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-[1fr_350px] gap-3'>
<Card <Card
title='Performance Overview ⓘ'
variant='bordered' variant='bordered'
className={{ className={{
wrapper: 'xl:col-span-1 2xl:col-span-3 w-full', wrapper:
body: 'h-96', '2xl:col-span-1 w-full rounded-xl border border-base-content/10',
body: 'h-96 p-4',
}} }}
> >
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-3'>
<div className='text-base font-semibold leading-7 flex gap-3 items-center'>
Performance Overview{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
</div>
<div className='w-full h-full flex items-center justify-center'> <div className='w-full h-full flex items-center justify-center'>
{shouldShowEmptyState || {shouldShowEmptyState ||
!uniformityData || !uniformityData ||
@@ -120,26 +132,31 @@ const UniformityChart = ({
)} )}
</div> </div>
</Card> </Card>
<Card
variant='bordered'
className={{
wrapper:
'2xl:col-span-1 w-full rounded-xl border border-base-content/10',
body:
shouldShowEmptyState || !uniformityData || !gaugeChartData
? 'h-110 p-4'
: 'p-4',
}}
>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-3'>
<div className='text-base font-semibold leading-7 flex gap-3 items-center'>
Weekly Performance{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
</div>
{shouldShowEmptyState || !uniformityData || !gaugeChartData ? ( {shouldShowEmptyState || !uniformityData || !gaugeChartData ? (
<Card
variant='bordered'
title='Weekly Performance ⓘ'
className={{
wrapper: 'xl:col-span-1 2xl:col-span-1 w-full',
body: 'h-110',
}}
>
<UniformityGaugeChartSkeleton /> <UniformityGaugeChartSkeleton />
</Card>
) : ( ) : (
<Card
variant='bordered'
title='Weekly Performance ⓘ'
className={{
wrapper: 'xl:col-span-1 2xl:col-span-1 w-full',
body: 'p-4',
}}
>
<UniformityGaugeChart <UniformityGaugeChart
value={gaugeChartData.value} value={gaugeChartData.value}
label={gaugeChartData.label} label={gaugeChartData.label}
@@ -150,8 +167,8 @@ const UniformityChart = ({
hasPrevWeek={gaugeChartData.hasPrevWeek} hasPrevWeek={gaugeChartData.hasPrevWeek}
hasNextWeek={gaugeChartData.hasNextWeek} hasNextWeek={gaugeChartData.hasNextWeek}
/> />
</Card>
)} )}
</Card>
</section> </section>
); );
}; };
@@ -3,7 +3,7 @@
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import Drawer from '@/components/Drawer'; import Drawer from '@/components/Drawer';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable'; import Uniformity from '@/app/production/uniformity/page';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
export default function UniformityPageWrapper({ export default function UniformityPageWrapper({
@@ -40,8 +40,8 @@ export default function UniformityPageWrapper({
return ( return (
<> <>
<div className='w-full p-4'> <div className='w-full'>
<UniformityTable /> <Uniformity />
</div> </div>
<Drawer <Drawer
@@ -19,11 +19,11 @@ import { isResponseSuccess } from '@/lib/api-helper';
import { type BaseApiResponse } from '@/types/api/api-general'; import { type BaseApiResponse } from '@/types/api/api-general';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Card from '@/components/Card';
import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton'; import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store'; import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
@@ -45,12 +45,11 @@ import {
getStatusColor, getStatusColor,
getStatusIndicatorColor, getStatusIndicatorColor,
getStatusText, getStatusText,
getStatusBadgeColor,
} from '@/components/pages/production/uniformity/uniformity-utils'; } from '@/components/pages/production/uniformity/uniformity-utils';
import { generateUniformityPDF } from '@/components/pages/production/uniformity/export/UniformityExportPDF'; import { generateUniformityPDF } from '@/components/pages/production/uniformity/export/UniformityExportPDF';
import { generateUniformityExcel } from '@/components/pages/production/uniformity/export/UniformityExportExcel'; import { generateUniformityExcel } from '@/components/pages/production/uniformity/export/UniformityExportExcel';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { import {
UniformityTableFilterSchema, UniformityTableFilterSchema,
@@ -113,12 +112,10 @@ const UniformityConfirmationPreview = ({
const columns: ColumnDef<DetailOptionType>[] = [ const columns: ColumnDef<DetailOptionType>[] = [
{ {
accessorKey: 'label',
header: 'Label', header: 'Label',
cell: (props) => props.row.original.label, cell: (props) => props.row.original.label,
}, },
{ {
accessorKey: 'value',
header: 'Value', header: 'Value',
cell: (props) => { cell: (props) => {
const id = props.row.original.id; const id = props.row.original.id;
@@ -819,7 +816,7 @@ const UniformityTable = () => {
); );
return ( return (
<div className='w-full flex flex-row justify-center'> <div className='w-full flex flex-row xl:justify-center justify-end'>
<CheckboxInput <CheckboxInput
name='allRow' name='allRow'
checked={isAllSelected} checked={isAllSelected}
@@ -832,7 +829,7 @@ const UniformityTable = () => {
}, },
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div> <div className='w-full flex flex-row xl:justify-center justify-end'>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
@@ -862,8 +859,11 @@ const UniformityTable = () => {
{ {
accessorKey: 'week', accessorKey: 'week',
header: 'Tanggal (Week)', header: 'Tanggal (Week)',
cell: (props) => cell: (props) => (
`${formatDate(props.row.original.applied_at, 'DD MMM YYYY')} (${props.row.original.week})`, <span className='text-nowrap'>
{`${formatDate(props.row.original.applied_at, 'DD MMM YYYY')} (${props.row.original.week})`}
</span>
),
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@@ -872,20 +872,11 @@ const UniformityTable = () => {
const uniformity = props.row.original; const uniformity = props.row.original;
const status = const status =
uniformity.latest_approval?.action ?? uniformity.status; uniformity.latest_approval?.action ?? uniformity.status;
return (
<div className='w-full'> const badgeColor = getStatusBadgeColor(status);
<Badge const statusText = getStatusText(status);
statusIndicator={true}
variant='soft' return <StatusBadge color={badgeColor} text={statusText} />;
className={{
badge: `rounded-xl w-full justify-start border border-gray-200 text-black ${getStatusColor(status)}`,
status: getStatusIndicatorColor(status),
}}
>
{getStatusText(status)}
</Badge>
</div>
);
}, },
}, },
{ {
@@ -900,60 +891,145 @@ const UniformityTable = () => {
[] []
); );
// ===== CALCULATE FILTER COUNT =====
const filterCount = useMemo(() => {
let count = 0;
if (filterStartDate && filterEndDate) {
count += 1;
}
if (filterLocation) {
count += 1;
}
if (filterProjectFlock) {
count += 1;
}
if (filterKandang) {
count += 1;
}
return count;
}, [
filterStartDate,
filterEndDate,
filterLocation,
filterProjectFlock,
filterKandang,
]);
const isFilterActive = filterCount > 0;
return ( return (
<> <>
<section className='[&_button]:w-full [&_button]:sm:w-fit [&_button]:last:mt-2 [&_button]:last:sm:mt-0 sm:flex sm:justify-between grid grid-cols-1 sm:gap-0 gap-2'> <div className='@container w-full'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.production.uniformity.create'> <RequirePermission permissions='lti.production.uniformity.create'>
<Button color='primary' href='/production/uniformity/add'> <Button
<Icon icon='ic:round-plus' width={18} height={18} /> href='/production/uniformity/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Uniformity Add Uniformity
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
<div className='sm:flex gap-2'> <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<Button variant='outline' onClick={filterModal.openModal}> <Button
<Icon icon='heroicons:funnel' width={18} height={18} /> variant='outline'
color='none'
onClick={filterModal.openModal}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': isFilterActive,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter Filter
{isFilterActive && (
<Badge
className={{
badge:
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
}}
>
{filterCount}
</Badge>
)}
</Button> </Button>
<Dropdown <Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={ trigger={
<Button variant='outline' isLoading={isAnyExportLoading}> <Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon <Icon
icon='heroicons:cloud-arrow-down' icon='heroicons:cloud-arrow-down'
width={18} width={20}
height={18} height={20}
/> />
Export
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button> </Button>
} }
align='end'
> >
<Menu> <Button
<MenuItem title='Excel' onClick={handleExportExcel} /> variant='ghost'
<MenuItem title='PDF' onClick={handleExportPDF} /> color='none'
</Menu> onClick={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPDF}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap hover:bg-base-content/5'
>
<Icon icon='heroicons:document-text' width={20} height={20} />
Export to PDF
</Button>
</Dropdown> </Dropdown>
</div> </div>
</section> </div>
<div className='my-4 divider'></div> <section className='p-3'>
<section>
<UniformityChartWrapper <UniformityChartWrapper
uniformitySwrKey={uniformitySwrKey} uniformitySwrKey={uniformitySwrKey}
isFiltered={isSubmitted} isFiltered={isSubmitted}
/> />
</section> </section>
</div>
<Card
variant='bordered'
className={{
wrapper: 'my-4 w-full relative',
}}
>
<Table<Uniformity> <Table<Uniformity>
data={isResponseSuccess(uniformities) ? uniformities?.data : []} data={isResponseSuccess(uniformities) ? uniformities?.data : []}
columns={uniformityColumns} columns={uniformityColumns}
@@ -971,19 +1047,15 @@ const UniformityTable = () => {
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
className={{ className={{
containerClassName: cn({ containerClassName: cn('p-3 pt-0', {
'mb-20': 'mb-20':
isResponseSuccess(uniformities) && isResponseSuccess(uniformities) &&
uniformities?.data?.length === 0, uniformities?.data?.length === 0,
}), }),
tableWrapperClassName: 'overflow-x-auto min-h-full ',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName: headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', 'first:pl-3 first:pr-0 xl:first:pl-3 py-3 text-nowrap',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName: bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end', 'first:pl-3 first:pr-0 xl:first:pl-3 py-3 text-nowrap',
}} }}
emptyContent={<UniformityTableSkeleton />} emptyContent={<UniformityTableSkeleton />}
/> />
@@ -1137,7 +1209,8 @@ const UniformityTable = () => {
ref={bulkRejectModal.ref} ref={bulkRejectModal.ref}
type='error' type='error'
iconPosition='left' iconPosition='left'
text={`Apakah anda yakin ingin menolak ${selectedRowIds.length} data Uniformity yang dipilih?`} text={`Reject This Submission?`}
subtitleText={`Are you sure you want to reject this submission? (${selectedRowIds.length} data)`}
secondaryButton={{ secondaryButton={{
text: 'Cancel', text: 'Cancel',
}} }}
@@ -1169,65 +1242,59 @@ const UniformityTable = () => {
ref={filterModal.ref} ref={filterModal.ref}
className={{ className={{
modal: 'p-0', modal: 'p-0',
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-[0.875rem]',
}} }}
> >
<div className='space-y-6'> <div className='flex flex-col'>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'> <div className='flex items-center justify-between p-4 border-b border-base-content/10'>
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} /> <Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3> <h3 className='font-medium text-sm'>Filter Data</h3>
</div> </div>
<Button <Button
variant='link' variant='link'
onClick={filterModal.closeModal} onClick={filterModal.closeModal}
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer' className='text-gray-500 hover:text-gray-700'
> >
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
{/* Error List Alert */} <form
{formErrorList.length > 0 && ( className='flex flex-col'
<div className='w-full px-4'> onSubmit={handleFormSubmit}
<AlertErrorList formErrorList={formErrorList} onClose={close} /> onReset={handleResetFilters}
</div> >
)} <div className='flex flex-col p-4 gap-1.5'>
{/* Rentang Waktu */}
<div className='space-y-4 px-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
<div> <div>
<label className='flex text-xs items-center gap-2 py-2 font-semibold'>
Tanggal
</label>
<div className='flex items-center gap-2'>
<DateInput <DateInput
required
label='Tanggal mulai'
name='start_date' name='start_date'
placeholder='Tanggal Mulai'
value={filterFormik.values.start_date} value={filterFormik.values.start_date}
errorMessage={filterFormik.errors.start_date}
onChange={handleFilterStartDateChange} onChange={handleFilterStartDateChange}
onBlur={filterFormik.handleBlur}
isError={ isError={
filterFormik.touched.start_date && filterFormik.touched.start_date &&
Boolean(filterFormik.errors.start_date) Boolean(filterFormik.errors.start_date)
} }
errorMessage={filterFormik.errors.start_date}
className={{ wrapper: 'w-full' }}
/> />
</div> <hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<div>
<DateInput <DateInput
required
label='Tanggal akhir'
name='end_date' name='end_date'
placeholder='Tanggal Akhir'
value={filterFormik.values.end_date} value={filterFormik.values.end_date}
errorMessage={filterFormik.errors.end_date}
onChange={handleFilterEndDateChange} onChange={handleFilterEndDateChange}
onBlur={filterFormik.handleBlur}
isError={ isError={
filterFormik.touched.end_date && filterFormik.touched.end_date &&
Boolean(filterFormik.errors.end_date) Boolean(filterFormik.errors.end_date)
} }
errorMessage={filterFormik.errors.end_date}
className={{ wrapper: 'w-full' }}
/> />
</div> </div>
</div> </div>
@@ -1299,24 +1366,34 @@ const UniformityTable = () => {
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div>
{formErrorList.length > 0 && (
<div className='w-full'>
<AlertErrorList
formErrorList={formErrorList}
onClose={close}
/>
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between gap-4 p-4 border-t border-base-content/10 bg-gray-100'>
<Button <Button
type='reset'
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='rounded-lg p-3 bg-gray-100 border-gray-100 text-base-content/65 hover:bg-base-content/10'
onClick={handleResetFilters}
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button
className='me-4 min-w-36 rounded-lg' type='submit'
onClick={handleApplyFilters} className='min-w-40 text-sm p-3 text-white rounded-lg'
> >
Apply Filter Apply Filter
</Button> </Button>
</div> </div>
</form>
</div> </div>
</Modal> </Modal>
@@ -1366,7 +1443,6 @@ const UniformityTable = () => {
selectedRowIds={selectedRowIds} selectedRowIds={selectedRowIds}
onClose={handleCloseFab} onClose={handleCloseFab}
/> />
</Card>
</> </>
); );
}; };
@@ -319,18 +319,19 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
<DrawerHeader <DrawerHeader
leftIconHref='/production/uniformity' leftIconHref='/production/uniformity'
subtitle={`Details`} subtitle={`Details`}
subtitleClassName='text-sm text-neutral' subtitleClassName='text-sm font-medium text-base-content/50'
showDivider showDivider
/> />
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <section className='w-full p-4'>
<section className='w-full px-6 mb-6'>
{initialValues ? ( {initialValues ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Info Umum */} {/* Info Umum */}
<div className=''> <div className=''>
<p className='text-sm font-medium mb-5'>Informasi Umum</p> <h2 className='text-base font-medium text-base-content/50 font-roboto mb-5'>
Informasi Umum
</h2>
<Table<DetailOptionType> <Table<DetailOptionType>
data={infoUmumTableData} data={infoUmumTableData}
columns={columnsInfoUmum} columns={columnsInfoUmum}
@@ -345,7 +346,9 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
{/* Sampling and Range */} {/* Sampling and Range */}
{initialValues.sampling && ( {initialValues.sampling && (
<div className=''> <div className=''>
<p className='text-sm font-medium mb-5'>Sampling and Range</p> <h2 className='text-base font-medium text-base-content/50 font-roboto mb-5'>
Sampling and Range
</h2>
<Table<DetailOptionType> <Table<DetailOptionType>
data={samplingTableData} data={samplingTableData}
columns={columnsSampling} columns={columnsSampling}
@@ -361,7 +364,9 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
{/* Result */} {/* Result */}
{initialValues.result && ( {initialValues.result && (
<div className=''> <div className=''>
<p className='text-sm font-medium mb-5'>Result</p> <h2 className='text-base font-medium text-base-content/50 font-roboto mb-5'>
Result
</h2>
<Table<DetailOptionType> <Table<DetailOptionType>
data={resultTableData} data={resultTableData}
columns={resultColumns} columns={resultColumns}
@@ -10,11 +10,10 @@ import {
UniformityInfoUmum, UniformityInfoUmum,
} from '@/types/api/production/uniformity'; } from '@/types/api/production/uniformity';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Badge from '@/components/Badge'; import StatusBadge from '@/components/helper/StatusBadge';
import { import {
getWeightStatusColor,
getWeightStatusIndicatorColor,
getWeightStatusText, getWeightStatusText,
getWeightStatusBadgeColor,
} from '@/components/pages/production/uniformity/uniformity-utils'; } from '@/components/pages/production/uniformity/uniformity-utils';
import { BodyWeightData } from '@/types/api/production/uniformity'; import { BodyWeightData } from '@/types/api/production/uniformity';
@@ -51,7 +50,7 @@ const UniformityDetailsPreview = ({
() => [ () => [
{ {
accessorKey: 'number', accessorKey: 'number',
header: 'No', header: 'Number',
cell: (props) => props.row.original.number, cell: (props) => props.row.original.number,
}, },
{ {
@@ -65,30 +64,12 @@ const UniformityDetailsPreview = ({
cell: (props) => { cell: (props) => {
const status = props.row.original.status; const status = props.row.original.status;
return status ? ( return status ? (
<div className='w-full'> <StatusBadge
<Badge color={getWeightStatusBadgeColor(status)}
statusIndicator={true} text={getWeightStatusText(status)}
variant='soft' />
className={{
badge: `rounded-xl w-full justify-start border border-gray-200 text-black ${getWeightStatusColor(status)}`,
status: getWeightStatusIndicatorColor(status),
}}
>
{getWeightStatusText(status)}
</Badge>
</div>
) : ( ) : (
<Badge <StatusBadge color='info' text='Unknown' />
statusIndicator={true}
variant='soft'
className={{
badge:
'rounded-xl w-full justify-start border border-gray-200 text-black bg-info/10',
status: 'bg-info',
}}
>
Unknown
</Badge>
); );
}, },
}, },
@@ -102,7 +83,7 @@ const UniformityDetailsPreview = ({
<DrawerHeader <DrawerHeader
leftIcon='' leftIcon=''
subtitle={info_umum?.file_name ?? 'Uniformity Details'} subtitle={info_umum?.file_name ?? 'Uniformity Details'}
subtitleClassName='text-sm text-neutral line-clamp-1' subtitleClassName='text-sm font-medium text-base-content/50 line-clamp-1'
showDivider={false} showDivider={false}
> >
<button <button
@@ -114,8 +95,7 @@ const UniformityDetailsPreview = ({
</DrawerHeader> </DrawerHeader>
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <section className='w-full p-4'>
<section className='w-full px-6'>
{info_umum ? ( {info_umum ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Body Weight Details */} {/* Body Weight Details */}
@@ -493,43 +493,25 @@ const UniformityForm = ({
<> <>
<section className='w-full'> <section className='w-full'>
<DrawerHeader <DrawerHeader
leftIcon={formType == 'add' ? 'mdi:close' : 'mdi:arrow-left'} leftIcon={formType == 'add' ? 'heroicons:x-mark' : 'mdi:arrow-left'}
leftIconSize={24} leftIconSize={20}
leftIconHref={ leftIconHref={
formType == 'add' formType == 'add'
? '/production/uniformity' ? '/production/uniformity'
: `/production/uniformity/detail` : `/production/uniformity/detail`
} }
leftIconClassName='hover:text-gray-400' leftIconClassName='hover:text-base-content'
subtitle={formType == 'add' ? 'Add Uniformity' : 'Update Uniformity'} subtitle={formType == 'add' ? 'Add Uniformity' : 'Update Uniformity'}
subtitleClassName='text-sm text-neutral' subtitleClassName='text-sm font-medium text-base-content/50'
showDivider showDivider
/> />
<div className='divider mt-3'></div> <section className='w-full p-4'>
<section className='w-full px-6 mb-6'> <h2 className='text-base font-medium text-base-content/50 font-roboto'>
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2> Informasi Umum
</h2>
<form onSubmit={handleFormSubmit} className='flex flex-col gap-6'>
{uniformityFormErrorMessage && (
<div className='alert alert-error' role='alert'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{uniformityFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<form onSubmit={handleFormSubmit} className='flex flex-col gap-1.5'>
<DateInput <DateInput
required required
label='Tanggal' label='Tanggal'
@@ -753,12 +735,35 @@ const UniformityForm = ({
)} )}
</div> </div>
<div className='w-full'>
{uniformityFormErrorMessage && (
<div className='alert alert-error' role='alert'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{uniformityFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
</div>
{!isNextStep && ( {!isNextStep && (
<>
<div className='border-t border-base-content/20 mt-3' />
<RequirePermission permissions='lti.production.uniformity.create'> <RequirePermission permissions='lti.production.uniformity.create'>
<Button <Button
type='submit' type='submit'
color='primary' color='primary'
className='w-full' className='w-full mt-4'
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
> >
{formik.isSubmitting ? ( {formik.isSubmitting ? (
@@ -768,6 +773,7 @@ const UniformityForm = ({
)} )}
</Button> </Button>
</RequirePermission> </RequirePermission>
</>
)} )}
</form> </form>
</section> </section>
@@ -50,7 +50,7 @@ const UniformityPreviewForm = () => {
() => [ () => [
{ {
accessorKey: 'number', accessorKey: 'number',
header: 'No', header: 'Number',
cell: (props) => props.row.original.number, cell: (props) => props.row.original.number,
}, },
{ {
@@ -68,19 +68,18 @@ const UniformityPreviewForm = () => {
<DrawerHeader <DrawerHeader
leftIcon='' leftIcon=''
subtitle={uniformityFormData?.file_name || 'Add Body Weight'} subtitle={uniformityFormData?.file_name || 'Add Body Weight'}
subtitleClassName='text-sm text-neutral line-clamp-1' subtitleClassName='text-sm font-medium text-base-content/50 line-clamp-1'
showDivider={false} showDivider={false}
> >
<Button variant='link' className='p-0 text-error' onClick={handleClose}> <Button variant='link' className='p-0 text-error' onClick={handleClose}>
<Tooltip content='Hapus' position='left'> <Tooltip content='Hapus' position='left'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} /> <Icon icon='heroicons-outline:trash' width={18} height={18} />
</Tooltip> </Tooltip>
</Button> </Button>
</DrawerHeader> </DrawerHeader>
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <section className='w-full p-4'>
<section className='w-full px-6'>
{verifyUniformityResult ? ( {verifyUniformityResult ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<Table<BodyWeightData> <Table<BodyWeightData>
@@ -14,12 +14,11 @@ import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { UniformityApi } from '@/services/api/uniformity'; import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import Badge from '@/components/Badge'; import StatusBadge from '@/components/helper/StatusBadge';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { import {
getWeightStatusColor,
getWeightStatusIndicatorColor,
getWeightStatusText, getWeightStatusText,
getWeightStatusBadgeColor,
} from '@/components/pages/production/uniformity/uniformity-utils'; } from '@/components/pages/production/uniformity/uniformity-utils';
import { DetailOptionType } from '@/types/api/production/uniformity'; import { DetailOptionType } from '@/types/api/production/uniformity';
import { import {
@@ -190,7 +189,7 @@ const UniformityResultForm = () => {
() => [ () => [
{ {
accessorKey: 'number', accessorKey: 'number',
header: 'No', header: 'Number',
cell: (props) => props.row.original.number, cell: (props) => props.row.original.number,
}, },
{ {
@@ -204,30 +203,12 @@ const UniformityResultForm = () => {
cell: (props) => { cell: (props) => {
const status = props.row.original.status; const status = props.row.original.status;
return status ? ( return status ? (
<div className='w-full'> <StatusBadge
<Badge color={getWeightStatusBadgeColor(status)}
statusIndicator={true} text={getWeightStatusText(status)}
variant='soft' />
className={{
badge: `rounded-xl w-full justify-start border border-gray-200 text-black ${getWeightStatusColor(status)}`,
status: getWeightStatusIndicatorColor(status),
}}
>
{getWeightStatusText(status)}
</Badge>
</div>
) : ( ) : (
<Badge <StatusBadge color='info' text='Unknown' />
statusIndicator={true}
variant='soft'
className={{
badge:
'rounded-xl w-full justify-start border border-gray-200 text-black bg-info/10',
status: 'bg-info',
}}
>
Unknown
</Badge>
); );
}, },
}, },
@@ -241,23 +222,24 @@ const UniformityResultForm = () => {
<DrawerHeader <DrawerHeader
leftIcon='' leftIcon=''
subtitle={uniformityFormData?.document_name || 'Uniformity Result'} subtitle={uniformityFormData?.document_name || 'Uniformity Result'}
subtitleClassName='text-sm text-neutral line-clamp-1' subtitleClassName='text-sm font-medium text-base-content/50 line-clamp-1'
showDivider={false} showDivider={false}
> >
<Button variant='link' className='p-0 text-error' onClick={handleClose}> <Button variant='link' className='p-0 text-error' onClick={handleClose}>
<Tooltip content='Hapus' position='left'> <Tooltip content='Hapus' position='left'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} /> <Icon icon='heroicons-outline:trash' width={20} height={20} />
</Tooltip> </Tooltip>
</Button> </Button>
</DrawerHeader> </DrawerHeader>
{/* Form Section */} {/* Form Section */}
<div className='divider mt-3.5'></div> <section className='w-full p-4'>
<section className='w-full px-6'>
{verifyUniformityResult ? ( {verifyUniformityResult ? (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className=''> <div className=''>
<p className='text-sm font-medium mb-5'>Sampling and Range</p> <h2 className='text-base font-medium text-base-content/50 font-roboto mb-5'>
Sampling and Range
</h2>
<Table<DetailOptionType> <Table<DetailOptionType>
data={samplingTableData} data={samplingTableData}
columns={columnsSampling} columns={columnsSampling}
@@ -270,7 +252,9 @@ const UniformityResultForm = () => {
</div> </div>
<div className=''> <div className=''>
<p className='text-sm font-medium mb-5'>Result</p> <h2 className='text-base font-medium text-base-content/50 font-roboto mb-5'>
Result
</h2>
<Table<DetailOptionType> <Table<DetailOptionType>
data={resultTableData} data={resultTableData}
columns={resultColumns} columns={resultColumns}
@@ -1,4 +1,3 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
const LeftLegend = () => { const LeftLegend = () => {
@@ -45,11 +44,11 @@ const ChartArea = () => {
))} ))}
</div> </div>
<div className='flex justify-between gap-2 sm:gap-4 md:gap-8 lg:gap-12 px-2 sm:px-4 py-2'> <div className='flex justify-between gap-1 xs:gap-2 sm:gap-3 md:gap-4 lg:gap-6 px-1 xs:px-2 sm:px-3 md:px-4 py-2'>
{ranges.map((range) => ( {ranges.map((range) => (
<div <div
key={range} key={range}
className='skeleton h-3 w-8 sm:w-12 md:w-16 shrink-0' className='skeleton h-3 w-6 xs:w-8 sm:w-10 md:w-12 flex-1 max-w-12 xs:max-w-14 sm:max-w-16'
/> />
))} ))}
</div> </div>
@@ -65,28 +64,38 @@ const ChartArea = () => {
const EmptyState = () => { const EmptyState = () => {
return ( return (
<> <div className='absolute inset-0 flex items-center justify-center z-10'>
<div className='absolute inset-0 flex flex-col items-center justify-center z-10 gap-2'> <div className='flex flex-col items-center justify-center'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center my-2'> {/* Filter icon */}
<Button className='rounded-2xl border border-sky-500 bg-primary text-white'> <div className='mb-2'>
<Icon icon={'heroicons:funnel'} className='text-4xl text-whitd' /> <div className='w-12.5 h-12.5 bg-(--main-color-base-100,#FFFFFF) border border-base-content/10 rounded-[0.875rem] shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center'>
</Button> <div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
</div> </div>
<span className='text-xl font-semibold text-[#18181B80] leading-5'> </div>
</div>
{/* Empty state text */}
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
No Filters Selected No Filters Selected
</span> </h3>
<span className='text-xs font-light text-[#18181B80] leading-4 text-center max-w-xs px-4'> <p className='text-base-content/50 text-xs text-center max-w-xs'>
Please choose filters to narrow down your results and make your search Please choose filters to narrow down your results and make your search
easier. easier.
</span> </p>
</div>
</div> </div>
</>
); );
}; };
const UniformityBarChartSkeleton = () => { const UniformityBarChartSkeleton = () => {
return ( return (
<div className='relative w-full h-full min-h-[300px] xl:min-h-[350px]'> <div className='relative w-full h-full min-h-[270px] xl:min-h-[330px]'>
<div className='sm:flex hidden h-full gap-4'> <div className='sm:flex hidden h-full gap-4'>
<LeftLegend /> <LeftLegend />
<ChartArea /> <ChartArea />
@@ -1,4 +1,3 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import React from 'react'; import React from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts'; import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts';
@@ -55,22 +54,29 @@ const UniformityGaugeChartSkeleton: React.FC<
</Pie> </Pie>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className='absolute inset-x-0 top-24 flex flex-col items-center justify-center'> <div className='absolute inset-x-0 top-24 flex flex-col items-center justify-center mt-4'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center mt-5'> {/* Filter icon */}
<Button className='rounded-2xl border border-sky-500 bg-primary text-white'> <div className='mb-2 mt-5'>
<div className='w-12.5 h-12.5 bg-(--main-color-base-100,#FFFFFF) border border-base-content/10 rounded-[0.875rem] shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center'>
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon <Icon
icon={'heroicons:funnel'} icon='heroicons:funnel'
className='text-4xl text-whitd' className='text-white'
width={20}
height={20}
/> />
</Button>
</div> </div>
<span className='text-lg font-semibold text-[#18181B80] leading-5 my-3'> </div>
</div>
{/* Empty state text */}
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
No Filters Selected No Filters Selected
</span> </h3>
<span className='text-xs font-light text-[#18181B80] leading-4 text-center max-w-xs px-4'> <p className='text-base-content/50 text-xs text-center max-w-xs'>
Please choose filters to narrow down your results and make your Please choose filters to narrow down your results and make your
search easier. search easier.
</span> </p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,24 +1,30 @@
import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
const UniformityTableSkeleton = () => { const UniformityTableSkeleton = () => {
return ( return (
<div className='flex flex-col items-center justify-center gap-2 my-20'> <div className='flex flex-col items-center justify-center my-20'>
<div className='border border-[#18181B]/25 rounded-2xl p-1 flex items-center justify-center'> {/* Document icon */}
<Button className='rounded-2xl border border-sky-500 bg-primary text-white'> <div className='mb-2'>
<div className='w-12.5 h-12.5 bg-(--main-color-base-100,#FFFFFF) border border-base-content/10 rounded-[0.875rem] shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center'>
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon <Icon
icon={'heroicons-outline:chart-bar'} icon='heroicons:document-text'
className='text-4xl text-whitd' className='text-white'
width={20}
height={20}
/> />
</Button>
</div> </div>
<span className='text-xl font-semibold text-[#18181B80] leading-5'> </div>
</div>
{/* Empty state text */}
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
No Data Available No Data Available
</span> </h3>
<span className='text-xs font-light text-[#18181B80] leading-4 text-center max-w-xs px-4'> <p className='text-base-content/50 text-xs text-center max-w-xs'>
There is no uniformity data displayed. Enter uniformity check data to There is no uniformity data displayed. Enter uniformity check data to
get started. get started.
</span> </p>
</div> </div>
); );
}; };
@@ -25,6 +25,20 @@ export const getWeightStatusText = (status: string): string => {
return weightStatusTextMap[status] || status; return weightStatusTextMap[status] || status;
}; };
export const weightStatusBadgeColorMap: Record<
string,
'success' | 'error' | 'neutral' | 'info'
> = {
ideal: 'success',
outside: 'error',
};
export const getWeightStatusBadgeColor = (
status: string
): 'success' | 'error' | 'neutral' | 'info' => {
return weightStatusBadgeColorMap[status] || 'neutral';
};
export const statusColorMap: Record<string, string> = { export const statusColorMap: Record<string, string> = {
APPROVED: 'bg-[#00D39033]', APPROVED: 'bg-[#00D39033]',
Disetujui: 'bg-[#00D39033]', Disetujui: 'bg-[#00D39033]',
@@ -63,3 +77,29 @@ export const getStatusIndicatorColor = (status: string): string => {
export const getStatusText = (status: string): string => { export const getStatusText = (status: string): string => {
return statusTextMap[status] || status; return statusTextMap[status] || status;
}; };
export const statusBadgeColorMap: Record<
string,
'success' | 'error' | 'neutral' | 'info'
> = {
APPROVED: 'success',
Disetujui: 'success',
approved: 'success',
disetujui: 'success',
REJECTED: 'error',
Ditolak: 'error',
rejected: 'error',
ditolak: 'error',
CREATED: 'neutral',
Pengajuan: 'neutral',
created: 'neutral',
pengajuan: 'neutral',
PENDING: 'neutral',
pending: 'neutral',
};
export const getStatusBadgeColor = (
status: string
): 'success' | 'error' | 'neutral' | 'info' => {
return statusBadgeColorMap[status] || 'neutral';
};
@@ -370,25 +370,6 @@ const PurchaseOrderAcceptApprovalForm = ({
? 'Konfirmasi Penerimaan Produk' ? 'Konfirmasi Penerimaan Produk'
: 'Edit Penerimaan Produk'} : 'Edit Penerimaan Produk'}
</h2> </h2>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
<table className='table'> <table className='table'>
<thead> <thead>
@@ -709,6 +690,27 @@ const PurchaseOrderAcceptApprovalForm = ({
/> />
</div> </div>
<div className='w-full mt-4'>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
</div>
{/* Action buttons */} {/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap mt-5'> <div className='flex flex-row justify-between gap-2 flex-wrap mt-5'>
<div className='flex flex-row justify-end gap-2 w-full'> <div className='flex flex-row justify-end gap-2 w-full'>
@@ -688,25 +688,6 @@ const PurchaseOrderStaffApprovalForm = ({
? 'Konfirmasi Item Pembelian' ? 'Konfirmasi Item Pembelian'
: 'Edit Item Pembelian'} : 'Edit Item Pembelian'}
</h2> </h2>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
{groupedPurchaseItems.length > 0 ? ( {groupedPurchaseItems.length > 0 ? (
<div> <div>
@@ -1186,6 +1167,27 @@ const PurchaseOrderStaffApprovalForm = ({
/> />
</div> </div>
<div className='w-full mt-4'>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
</div>
{/* Action buttons */} {/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap mt-5'> <div className='flex flex-row justify-between gap-2 flex-wrap mt-5'>
<div className='flex flex-row justify-end gap-2 w-full'> <div className='flex flex-row justify-end gap-2 w-full'>
@@ -494,25 +494,6 @@ const PurchaseRequestForm = ({
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{purchaseRequestFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseRequestFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Basic Info Card */} {/* Basic Info Card */}
<Card <Card
title='Informasi Purchase Request' title='Informasi Purchase Request'
@@ -912,6 +893,27 @@ const PurchaseRequestForm = ({
)} )}
</Card> </Card>
<div className='w-full mb-4'>
{purchaseRequestFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseRequestFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
</div>
{/* Action buttons */} {/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'detail' && ( {type !== 'detail' && (
@@ -10,7 +10,13 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Collapse from '@/components/Collapse'; import Collapse from '@/components/Collapse';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { DailyMarketingRow } from '@/types/api/report/marketing'; import { DailyMarketingRow } from '@/types/api/report/marketing';
import { MarketingReportApi } from '@/services/api/report/marketing-report'; import { MarketingReportApi } from '@/services/api/report/marketing-report';
@@ -94,7 +100,9 @@ const DailyMarketingsTable = ({
accessorKey: 'vehicle_number', accessorKey: 'vehicle_number',
header: 'No. Polisi', header: 'No. Polisi',
cell: (props) => ( cell: (props) => (
<span className='text-nowrap'>{props.row.original.vehicle_number}</span> <span className='text-nowrap'>
{formatVechicleNumber(props.row.original.vehicle_number)}
</span>
), ),
}, },
{ {
@@ -1,28 +1,43 @@
'use client'; 'use client';
import { useState } from 'react';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
const FinanceTabs = () => { const FinanceTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useFinanceTabStore((state) => state.tabActions);
const tabs = [ const tabs = [
{ {
id: '1', id: '1',
label: 'Rekapitulasi Hutang Ke Supplier', label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab tabId={'1'} />,
content: <DebtSupplierTab />,
}, },
{ {
id: '2', id: '2',
label: 'Kontrol Pembayaran Customer', label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab tabId={'2'} />,
content: <CustomerPaymentTab />,
}, },
]; ];
return ( return (
<section className='w-full p-4'> <section className='w-full'>
<Tabs tabs={tabs} variant='lifted' /> <Tabs
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
tab: 'w-fit',
content: 'p-0 m-0',
}}
sideContent={tabActions[activeTabId] || null}
/>
</section> </section>
); );
}; };
@@ -281,16 +281,16 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text> <Text>No</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>No. PR</Text> <Text>No. PR</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>No. PO</Text> <Text>No. PO</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 0.7 }]}>
<Text>Tgl Terima/Bayar</Text> <Text>Tgl Terima/Bayar</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 0.7 }]}>
<Text>Tgl PO</Text> <Text>Tgl PO</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.6 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 0.6 }]}>
@@ -320,7 +320,12 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Status</Text> <Text>Status</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View
style={[
pdfStyles.tableCellHeader,
{ flex: 1, borderRight: 'none' },
]}
>
<Text>No. Perjalanan</Text> <Text>No. Perjalanan</Text>
</View> </View>
</View> </View>
@@ -330,16 +335,16 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}> <View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text></Text> {/* NO */} <Text></Text> {/* NO */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* No. PR */} <Text></Text> {/* No. PR */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* No. PO */} <Text></Text> {/* No. PO */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text></Text> {/* Tgl Terima/Bayar */} <Text></Text> {/* Tgl Terima/Bayar */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text></Text> {/* Tgl PO */} <Text></Text> {/* Tgl PO */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
@@ -381,8 +386,13 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}> <View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text> {/* Status */} <Text></Text> {/* Status */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View
<Text></Text> {/* No. Perjalanan */} style={[
pdfStyles.tableCell, // No. Perjalanan
{ flex: 1, borderRight: 'none' },
]}
>
<Text></Text>
</View> </View>
</View> </View>
@@ -400,13 +410,13 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}> <View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>{index + 1}</Text> <Text>{index + 1}</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pr_number || '-'}</Text> <Text>{item.pr_number || '-'}</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.po_number || '-'}</Text> <Text>{item.po_number || '-'}</Text>
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text> <Text>
{item.received_date {item.received_date
? item.received_date != '-' ? item.received_date != '-'
@@ -415,7 +425,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
: '-'} : '-'}
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.7 }]}>
<Text> <Text>
{item.po_date {item.po_date
? item.po_date != '-' ? item.po_date != '-'
@@ -526,7 +536,12 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text>-</Text> <Text>-</Text>
)} )}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View
style={[
pdfStyles.tableCell, // No. Perjalanan
{ flex: 1, borderRight: 'none' },
]}
>
<Text>{item.travel_number || '-'}</Text> <Text>{item.travel_number || '-'}</Text>
</View> </View>
</View> </View>
@@ -538,18 +553,18 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}> <View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>Total</Text> <Text>Total</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 0.7 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 0.7 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text>{formatNumber(supplierReport.total.aging)} Hari</Text> <Text>{formatNumber(supplierReport.total.aging)} Hari</Text>
</View> </View>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
import { ColumnDef } from '@tanstack/react-table';
const CustomerSupplierSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ColumnDef<CustomerPaymentRow>[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default CustomerSupplierSkeleton;
@@ -0,0 +1,38 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { DebtRow } from '@/types/api/report/debt-supplier';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
const DebtSupplierSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ColumnDef<DebtRow>[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default DebtSupplierSkeleton;
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Card from '@/components/Card'; import Card from '@/components/Card';
@@ -11,9 +11,10 @@ import { FinanceApi } from '@/services/api/report/finance-report';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper';
import { import {
CustomerPaymentReport, CustomerPaymentReport,
CustomerPaymentRow,
CustomerPaymentSummary, CustomerPaymentSummary,
} from '@/types/api/report/customer-payment'; } from '@/types/api/report/customer-payment';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -26,8 +27,14 @@ import { useModal } from '@/components/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX'; import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
const CustomerPaymentTab = () => { interface CustomerPaymentTabProps {
tabId: string;
}
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
@@ -111,6 +118,10 @@ const CustomerPaymentTab = () => {
}; };
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterModalOpen = useCallback(() => {
filterModal.openModal();
}, [filterModal]);
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
setIsSubmitted(false); setIsSubmitted(false);
setFilterCustomer([]); setFilterCustomer([]);
@@ -298,6 +309,92 @@ const CustomerPaymentTab = () => {
} }
}, [customerPaymentExport]); }, [customerPaymentExport]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5',
'rounded-lg! font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
hasFilters && 'border-primary-gradient text-primary'
)}
>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
<Dropdown
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className={cn(
'px-3 py-2.5',
'rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button>
}
align='end'
className={{
content:
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
}}
>
<Menu className='p-0 w-full'>
<MenuItem
className='text-sm p-3'
title='Excel'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPdf}
/>
</Menu>
</Dropdown>
</div>
);
}, [
tabId,
hasFilters,
activeFiltersCount,
isAnyExportLoading,
handleExportExcel,
handleExportPdf,
filterModal.open,
setTabActions,
]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [tabId, clearTabActions]);
const getTableColumns = ( const getTableColumns = (
summary: CustomerPaymentSummary summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => { ): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
@@ -552,58 +649,135 @@ const CustomerPaymentTab = () => {
}; };
return ( return (
<div className='w-full p-0 sm:p-4'> <>
<Card <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
subtitle='Laporan > Kontrol Pembayaran Customer' {!isSubmitted ? (
className={{ wrapper: 'w-full', body: 'p-1!' }} <CustomerSupplierSkeleton
> columns={getTableColumns({} as CustomerPaymentSummary)}
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'> icon={
<Button
variant='outline'
onClick={filterModal.openModal}
className={
hasFilters
? 'bg-linear-to-b from-[#0069E0]/40 to-white text-[#0069E0] rounded-lg'
: 'rounded-lg'
}
>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
{hasFilters && (
<Badge
variant='default'
className={{
badge:
'rounded-lg px-1.5 py-2.5 text-xs font-semibold bg-error text-white',
}}
>
{activeFiltersCount}
</Badge>
)}
</Button>
<Dropdown
trigger={
<Button
variant='outline'
isLoading={isAnyExportLoading}
className='rounded-lg'
>
<Icon <Icon
icon='heroicons:cloud-arrow-down' icon='heroicons:funnel'
width={18} className='text-white'
height={18} width={20}
height={20}
/> />
Export
</Button>
} }
align='end' title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<CustomerSupplierSkeleton
columns={getTableColumns({} as CustomerPaymentSummary)}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
data.map((customerReport) => {
const summary = customerReport.summary || {
total_qty: 0,
total_weight: 0,
total_final_amount: 0,
total_grand_amount: 0,
total_payment: 0,
total_accounts_receivable: 0,
};
const tableColumns = getTableColumns(summary);
return (
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`(${customerReport.customer.address})`}
className={{
wrapper: 'w-full rounded-lg border-none',
body: 'p-0',
title:
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
subtitle:
'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
collapsible: 'rounded-lg',
}}
variant='bordered'
collapsible={true}
defaultCollapsed={true}
> >
<Menu className={'w-full'}> <Table
<MenuItem title='Excel' onClick={handleExportExcel} /> data={[
<MenuItem title='PDF' onClick={handleExportPdf} /> {
</Menu> accounts_receivable: customerReport.initial_balance,
</Dropdown> } as CustomerPaymentReport['rows'][0],
...customerReport.rows,
]}
columns={tableColumns}
pageSize={customerReport.rows.length + 1}
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
renderCustomRow={(row) => {
if (row.index === 0) {
return (
<tr
key={row.index}
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={13}
></td>
<td className='px-4 py-3 text-xs whitespace-nowrap'>
<div
className={`text-right ${
row.original.accounts_receivable < 0
? 'text-error'
: ''
}`}
>
{formatCurrency(row.original.accounts_receivable)}
</div>
</td>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={4}
></td>
</tr>
);
}
}}
/>
</Card>
);
})
)}
</div> </div>
{/* Filter Modal */} {/* Filter Modal */}
@@ -611,61 +785,59 @@ const CustomerPaymentTab = () => {
ref={filterModal.ref} ref={filterModal.ref}
className={{ className={{
modal: 'p-0', modal: 'p-0',
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}} }}
> >
<div className='space-y-6'>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'> <div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} /> <Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3> <h3 className='font-medium text-sm'>Filter Data</h3>
</div> </div>
<Button <Button
variant='link' variant='link'
onClick={filterModal.closeModal} onClick={filterModal.closeModal}
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer' className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
> >
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<div className='space-y-4 px-4'> <div className='p-4 flex flex-col gap-1.5'>
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
<div> <div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput <DateInput
label='Tanggal'
name='start_date' name='start_date'
value={filterStartDate} value={filterStartDate}
onChange={(e) => { onChange={(e) => {
setFilterStartDate(e.target.value); setFilterStartDate(e.target.value);
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal
/> />
</div> <hr className='w-full max-w-3 h-px border-base-content/10' />
<div>
<DateInput <DateInput
label=' '
name='end_date' name='end_date'
value={filterEndDate} value={filterEndDate}
onChange={(e) => { onChange={(e) => {
setFilterEndDate(e.target.value); setFilterEndDate(e.target.value);
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal
/> />
</div> </div>
</div> </div>
<div>
<SelectInputCheckbox <SelectInputCheckbox
label='Customer' label='Customer'
placeholder='Pilih Customer' placeholder='Pilih Customer'
options={customerOptions} options={customerOptions}
value={filterCustomer} value={filterCustomer}
onChange={(val) => { onChange={(val) => {
setFilterCustomer( setFilterCustomer(Array.isArray(val) ? val : val ? [val] : []);
Array.isArray(val) ? val : val ? [val] : []
);
}} }}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers} isLoading={isLoadingCustomers}
@@ -673,7 +845,6 @@ const CustomerPaymentTab = () => {
onMenuScrollToBottom={loadMoreCustomers} onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div>
{/* TODO: Uncomment when BE is ready */} {/* TODO: Uncomment when BE is ready */}
{/* <div> {/* <div>
@@ -704,134 +875,26 @@ const CustomerPaymentTab = () => {
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> */} </div> */}
</div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> </div>
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={handleResetFilters} onClick={handleResetFilters}
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button
className='me-4 min-w-36 rounded-lg' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
onClick={handleApplyFilters} onClick={handleApplyFilters}
> >
Apply Filter Apply Filter
</Button> </Button>
</div> </div>
</div>
</Modal> </Modal>
</>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan klik tombol Filter untuk mengatur filter dan menampilkan
data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
data.map((customerReport) => {
const summary = customerReport.summary || {
total_qty: 0,
total_weight: 0,
total_final_amount: 0,
total_grand_amount: 0,
total_payment: 0,
total_accounts_receivable: 0,
};
const tableColumns = getTableColumns(summary);
return (
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`(${customerReport.customer.address})`}
className={{
wrapper: 'w-full rounded-2xl',
body: 'p-0',
title:
'py-1.5 px-3 bg-primary text-white text-lg font-normal',
subtitle:
'px-3 pb-1 bg-primary text-white text-sm font-normal',
}}
variant='bordered'
collapsible={true}
>
<Table
data={[
{
accounts_receivable: customerReport.initial_balance,
} as CustomerPaymentReport['rows'][0],
...customerReport.rows,
]}
columns={tableColumns}
pageSize={customerReport.rows.length + 1}
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
renderCustomRow={(row) => {
if (row.index === 0) {
return (
<tr
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
key={row.index}
>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={13}
></td>
<td className='px-4 py-3 text-xs whitespace-nowrap'>
<div
className={`text-right ${
row.original.accounts_receivable < 0
? 'text-error'
: ''
}`}
>
{formatCurrency(row.original.accounts_receivable)}
</div>
</td>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={4}
></td>
</tr>
);
}
}}
/>
</Card>
);
})
)}
</Card>
</div>
); );
}; };
@@ -2,10 +2,7 @@ import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInput, { import { OptionType, useSelect } from '@/components/input/SelectInput';
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
@@ -22,7 +19,7 @@ import { generateDebtSupplierExcel } from '@/components/pages/report/finance/exp
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF'; import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier'; import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
@@ -32,11 +29,14 @@ import {
DebtSupplierFilterType, DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter'; } from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Badge from '@/components/Badge';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
import StatusBadge from '@/components/helper/StatusBadge';
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
const dueStatus: Record<string, Color> = { const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error', 'Sudah Jatuh Tempo': 'error',
@@ -60,22 +60,14 @@ const getPillBadge = (
? dueStatus[statusText] || 'neutral' ? dueStatus[statusText] || 'neutral'
: paymentStatus[statusText] || 'neutral'; : paymentStatus[statusText] || 'neutral';
return ( return <StatusBadge color={color as Color} text={statusText} />;
<Badge
color={color as Color}
size='sm'
variant='soft'
className={{
badge: `py-2.5 px-2 font-medium text-base-content rounded-full border border-${color}`,
}}
statusIndicator
>
{statusText}
</Badge>
);
}; };
const DebtSupplierTab = () => { interface DebtSupplierTabProps {
tabId: string;
}
const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
@@ -271,7 +263,78 @@ const DebtSupplierTab = () => {
} }
}, [debtSupplierExport]); }, [debtSupplierExport]);
const getTableColumns = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [ // ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3 '>
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className={cn(
'px-3 py-2.5',
'rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button>
}
align='end'
className={{
content:
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
}}
>
<Menu className='p-0 w-full'>
<MenuItem
className='text-sm p-3'
title='Excel'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPdf}
/>
</Menu>
</Dropdown>
</div>
);
}, [
tabId,
formik.values,
isAnyExportLoading,
handleExportExcel,
handleExportPdf,
setTabActions,
]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [tabId, clearTabActions]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{ {
id: 'no', id: 'no',
header: 'No', header: 'No',
@@ -337,8 +400,10 @@ const DebtSupplierTab = () => {
return <div className='text-center'>{formatNumber(value)} Hari</div>; return <div className='text-center'>{formatNumber(value)} Hari</div>;
}, },
footer: () => { footer: () => {
const value = supplier.total.aging; const value = supplier?.total.aging;
return <div className='text-center'>{formatNumber(value)} Hari</div>; return (
<div className='text-center'>{formatNumber(value || 0)} Hari</div>
);
}, },
}, },
{ {
@@ -399,10 +464,10 @@ const DebtSupplierTab = () => {
); );
}, },
footer: () => { footer: () => {
const value = supplier.total.total_price; const value = supplier?.total.total_price;
return ( return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}> <div className={`text-right ${value || 0 < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)} {formatCurrency(value || 0)}
</div> </div>
); );
}, },
@@ -421,10 +486,10 @@ const DebtSupplierTab = () => {
); );
}, },
footer: () => { footer: () => {
const value = supplier.total.payment_price; const value = supplier?.total.payment_price;
return ( return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}> <div className={`text-right ${value || 0 < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)} {formatCurrency(value || 0)}
</div> </div>
); );
}, },
@@ -443,10 +508,10 @@ const DebtSupplierTab = () => {
); );
}, },
footer: () => { footer: () => {
const value = supplier.total.debt_price; const value = supplier?.total.debt_price;
return ( return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}> <div className={`text-right ${value || 0 < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)} {formatCurrency(value || 0)}
</div> </div>
); );
}, },
@@ -478,52 +543,39 @@ const DebtSupplierTab = () => {
]; ];
return ( return (
<> <>
<div className='w-full p-0 sm:p-4 flex flex-col gap-4'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
<Card
subtitle='Laporan > Rekapitulasi Hutang ke Supplier'
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
variant='outline'
/>
<Dropdown
trigger={
<Button variant='outline' isLoading={isAnyExportLoading}>
<Icon
icon='heroicons:cloud-arrow-down'
width={18}
height={18}
/>
Export
</Button>
}
align='end'
>
<Menu>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
</Card>
{!isSubmitted ? ( {!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'> <DebtSupplierSkeleton
Silakan klik tombol Filter untuk mengatur filter dan menampilkan columns={getTableColumns()}
data. icon={
</div> <Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? ( ) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
</div> </div>
) : data.length === 0 ? ( ) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'> <DebtSupplierSkeleton
Tidak ada data yang dapat ditampilkan... columns={getTableColumns()}
</div> icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : ( ) : (
data.map((supplierReport) => { data.map((supplierReport) => {
return ( return (
@@ -531,10 +583,11 @@ const DebtSupplierTab = () => {
key={supplierReport.supplier.id} key={supplierReport.supplier.id}
title={supplierReport.supplier.name} title={supplierReport.supplier.name}
className={{ className={{
wrapper: 'w-full !rounded-lg', wrapper: 'w-full rounded-lg border-none',
body: 'p-0 rounded-lg', body: 'p-0',
title: title:
'ps-2 pt-1 pb-1 font-normal text-md bg-primary text-white', 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
collapsible: 'rounded-lg',
}} }}
variant='bordered' variant='bordered'
collapsible={true} collapsible={true}
@@ -551,8 +604,9 @@ const DebtSupplierTab = () => {
pageSize={supplierReport.rows.length + 1} pageSize={supplierReport.rows.length + 1}
renderFooter={supplierReport.rows.length > 0} renderFooter={supplierReport.rows.length > 0}
className={{ className={{
containerClassName: 'w-full', containerClassName: 'w-full mb-0',
tableWrapperClassName: 'overflow-x-auto', tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
headerColumnClassName: cn( headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName, TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap' 'whitespace-nowrap'
@@ -617,33 +671,34 @@ const DebtSupplierTab = () => {
ref={filterModal.ref} ref={filterModal.ref}
className={{ className={{
modal: 'p-0', modal: 'p-0',
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}} }}
> >
<form <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
className='space-y-6'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'> <div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} /> <Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3> <h3 className='font-medium text-sm'>Filter Data</h3>
</div> </div>
<Button <Button
variant='link' variant='link'
type='button'
onClick={filterModal.closeModal} onClick={filterModal.closeModal}
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer' className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
> >
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<div className='space-y-4 px-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'> {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div> <div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput <DateInput
label='Tanggal'
name='startDate' name='startDate'
value={formik.values.startDate || ''} value={formik.values.startDate || ''}
onChange={(e) => { onChange={(e) => {
@@ -654,12 +709,10 @@ const DebtSupplierTab = () => {
formik.touched.startDate && !!formik.errors.startDate formik.touched.startDate && !!formik.errors.startDate
} }
errorMessage={formik.errors.startDate} errorMessage={formik.errors.startDate}
isNestedModal
/> />
</div> <hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<div className='mt-auto'>
<DateInput <DateInput
label=' '
name='endDate' name='endDate'
value={formik.values.endDate || ''} value={formik.values.endDate || ''}
onChange={(e) => { onChange={(e) => {
@@ -668,6 +721,7 @@ const DebtSupplierTab = () => {
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={formik.touched.endDate && !!formik.errors.endDate} isError={formik.touched.endDate && !!formik.errors.endDate}
errorMessage={formik.errors.endDate} errorMessage={formik.errors.endDate}
isNestedModal
/> />
</div> </div>
</div> </div>
@@ -730,15 +784,19 @@ const DebtSupplierTab = () => {
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' color='none'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
type='reset' type='reset'
> >
Reset Filter Reset Filter
</Button> </Button>
<Button className='me-4 min-w-36 rounded-lg' type='submit'> <Button
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
type='submit'
>
Apply Filter Apply Filter
</Button> </Button>
</div> </div>
+25
View File
@@ -5,6 +5,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
text: 'Dashboard', text: 'Dashboard',
link: '/dashboard', link: '/dashboard',
icon: 'heroicons-outline:chart-bar-square', icon: 'heroicons-outline:chart-bar-square',
permission: ['lti.dashboard.list'],
}, },
{ {
text: 'Daily Checklist', text: 'Daily Checklist',
@@ -114,11 +115,13 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
text: 'Penjualan', text: 'Penjualan',
link: '/marketing', link: '/marketing',
icon: 'heroicons-outline:currency-dollar', icon: 'heroicons-outline:currency-dollar',
permission: ['lti.marketing.delivery_order.list'],
}, },
{ {
text: 'Keuangan', text: 'Keuangan',
link: '/finance', link: '/finance',
icon: 'heroicons-outline:banknotes', icon: 'heroicons-outline:banknotes',
permission: ['lti.finance.transactions.list'],
}, },
{ {
text: 'Biaya', text: 'Biaya',
@@ -136,26 +139,46 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
text: 'Laporan', text: 'Laporan',
link: '/report', link: '/report',
icon: 'mdi:chart-box-outline', icon: 'mdi:chart-box-outline',
permission: [
'lti.repport.debtsupplier.list',
'lti.repport.customerpayment.list',
'lti.repport.purchasesupplier.list',
'lti.repport.expense.list',
'lti.repport.delivery.list',
'lti.repport.gethppperkandang.list',
'lti.repport.production_result.list',
],
submenu: [ submenu: [
{ {
text: 'Keuangan', text: 'Keuangan',
link: '/report/finance', link: '/report/finance',
permission: [
'lti.repport.debtsupplier.list',
'lti.repport.customerpayment.list',
],
}, },
{ {
text: 'Logistik & Persediaan', text: 'Logistik & Persediaan',
link: '/report/logistic-stock', link: '/report/logistic-stock',
permission: ['lti.repport.purchasesupplier.list'],
}, },
{ {
text: 'Biaya Operasional', text: 'Biaya Operasional',
link: '/report/expense', link: '/report/expense',
permission: ['lti.repport.expense.list'],
}, },
{ {
text: 'Penjualan', text: 'Penjualan',
link: '/report/marketing', link: '/report/marketing',
permission: [
'lti.repport.delivery.list',
'lti.repport.gethppperkandang.list',
],
}, },
{ {
text: 'Hasil Produksi', text: 'Hasil Produksi',
link: '/report/production-result', link: '/report/production-result',
permission: ['lti.repport.production_result.list'],
}, },
], ],
}, },
@@ -204,6 +227,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
'lti.master.suppliers.list', 'lti.master.suppliers.list',
'lti.master.uoms.list', 'lti.master.uoms.list',
'lti.master.warehouses.list', 'lti.master.warehouses.list',
'lti.master.production_standards.list',
], ],
submenu: [ submenu: [
{ {
@@ -274,6 +298,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{ {
text: 'Standar Produksi', text: 'Standar Produksi',
link: '/master-data/production-standard', link: '/master-data/production-standard',
permission: ['lti.master.production_standards.list'],
}, },
], ],
}, },
+4 -1
View File
@@ -116,7 +116,10 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
// Report // Report
'/report/logistic-stock/': ['lti.repport.purchasesupplier.list'], '/report/logistic-stock/': ['lti.repport.purchasesupplier.list'],
'/report/expense/': ['lti.repport.expense.list'], '/report/expense/': ['lti.repport.expense.list'],
'/report/marketing/': ['lti.repport.delivery.list'], '/report/marketing/': [
'lti.repport.delivery.list',
'lti.repport.gethppperkandang.list',
],
'/report/production-result/': ['lti.repport.production_result.list'], '/report/production-result/': ['lti.repport.production_result.list'],
'/report/finance/': [ '/report/finance/': [
'lti.repport.finance.list', 'lti.repport.finance.list',
@@ -127,6 +127,10 @@ export function DailyChecklistContent() {
{ id: number; name: string }[] { id: number; name: string }[]
>([]); >([]);
const sortedSelectedEmployees = selectedEmployees.toSorted((a, b) =>
a.name.localeCompare(b.name)
);
const [dailyChecklistId, setDailyChecklistId] = useState<string | null>(null); const [dailyChecklistId, setDailyChecklistId] = useState<string | null>(null);
const [checklistStatus, setChecklistStatus] = useState<string>('DRAFT'); const [checklistStatus, setChecklistStatus] = useState<string>('DRAFT');
// const [isEditMode, setIsEditMode] = useState(false); // const [isEditMode, setIsEditMode] = useState(false);
@@ -486,6 +490,11 @@ export function DailyChecklistContent() {
return; return;
} }
if (!tempSelectedPhaseIds.length) {
toast.error('Pilih minimal satu fase');
return;
}
try { try {
// Insert new phase links // Insert new phase links
const setDailyChecklistPhaseRes = const setDailyChecklistPhaseRes =
@@ -535,14 +544,6 @@ export function DailyChecklistContent() {
} }
}; };
const toggleSelectAllAbk = () => {
if (tempSelectedEmployees.length === employees.length) {
setTempSelectedEmployees([]);
} else {
setTempSelectedEmployees([...employees]);
}
};
const applyAbkSelection = async () => { const applyAbkSelection = async () => {
if (!dailyChecklistId) { if (!dailyChecklistId) {
toast.error('Checklist belum tersedia'); toast.error('Checklist belum tersedia');
@@ -853,10 +854,34 @@ export function DailyChecklistContent() {
); );
const isAllAbkSelected = const isAllAbkSelected =
tempSelectedEmployees.length === employees.length && employees.length > 0; tempSelectedEmployees.length === filteredEmployees.length &&
filteredEmployees.length > 0 &&
tempSelectedEmployees.every((tempSelectedEmployee) => {
return (
filteredEmployees.findIndex(
(filteredEmployee) => filteredEmployee.id === tempSelectedEmployee.id
) >= 0
);
});
const isAllPhasesSelected = const isAllPhasesSelected =
tempSelectedPhaseIds.length === availablePhases.length && tempSelectedPhaseIds.length === filteredPhases.length &&
availablePhases.length > 0; filteredPhases.length > 0 &&
tempSelectedPhaseIds.every((tempSelectedPhaseId) => {
return (
filteredPhases.findIndex(
(filteredPhase) =>
String(filteredPhase.id) === String(tempSelectedPhaseId)
) >= 0
);
});
const toggleSelectAllAbk = () => {
if (isAllAbkSelected) {
setTempSelectedEmployees([]);
} else {
setTempSelectedEmployees([...filteredEmployees]);
}
};
// Group activities by PHASE → TIME_TYPE → ACTIVITIES // Group activities by PHASE → TIME_TYPE → ACTIVITIES
const groupActivitiesByPhase = () => { const groupActivitiesByPhase = () => {
@@ -1130,7 +1155,7 @@ export function DailyChecklistContent() {
<th className='text-left py-3 px-4 text-sm font-semibold text-gray-700 border-r border-gray-200 min-w-[200px]'> <th className='text-left py-3 px-4 text-sm font-semibold text-gray-700 border-r border-gray-200 min-w-[200px]'>
Aktivitas Aktivitas
</th> </th>
{selectedEmployees.map((emp) => ( {sortedSelectedEmployees.map((emp) => (
<th <th
key={emp.id} key={emp.id}
className='text-center py-3 px-4 text-sm font-semibold text-gray-700 border-r border-gray-200 min-w-[100px]' className='text-center py-3 px-4 text-sm font-semibold text-gray-700 border-r border-gray-200 min-w-[100px]'
@@ -1216,6 +1241,14 @@ export function DailyChecklistContent() {
} }
// ACTIVITY rows (Child rows with checkboxes) // ACTIVITY rows (Child rows with checkboxes)
activities.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
})
);
console.log(activities);
activities.forEach((activity, index) => { activities.forEach((activity, index) => {
const taskId = const taskId =
taskIdsByPhaseActivityId[activity.id]; taskIdsByPhaseActivityId[activity.id];
@@ -1244,7 +1277,7 @@ export function DailyChecklistContent() {
</p> </p>
)} )}
</td> </td>
{selectedEmployees.map((emp) => ( {sortedSelectedEmployees.map((emp) => (
<td <td
key={emp.id} key={emp.id}
className='text-center py-3 px-4 border-r border-gray-200' className='text-center py-3 px-4 border-r border-gray-200'
@@ -1445,6 +1478,8 @@ export function DailyChecklistContent() {
inputWrapper: 'flex items-center', inputWrapper: 'flex items-center',
label: 'font-semibold text-gray-900', label: 'font-semibold text-gray-900',
}} }}
maxSize={5242880} // 5 MB
bottomLabel='Ukuran file maksimal 5MB'
/> />
)} )}
</> </>
@@ -1519,14 +1554,14 @@ export function DailyChecklistContent() {
setTempSelectedPhaseIds([]); setTempSelectedPhaseIds([]);
} else { } else {
setTempSelectedPhaseIds( setTempSelectedPhaseIds(
availablePhases.map((p) => String(p.id)) filteredPhases.map((p) => String(p.id))
); );
} }
}} }}
className='checkbox-clean' className='checkbox-clean'
/> />
<span className='text-sm font-medium text-gray-700 group-hover:text-gray-900'> <span className='text-sm font-medium text-gray-700 group-hover:text-gray-900'>
Pilih Semua ({availablePhases.length} Fase) Pilih Semua ({filteredPhases.length} Fase)
</span> </span>
</label> </label>
</div> </div>
@@ -1621,7 +1656,7 @@ export function DailyChecklistContent() {
/> />
</div> </div>
{employees.length > 0 && ( {filteredEmployees.length > 0 && (
<div className='flex items-center gap-3 px-1 py-2'> <div className='flex items-center gap-3 px-1 py-2'>
<label className='flex items-center gap-2 cursor-pointer group'> <label className='flex items-center gap-2 cursor-pointer group'>
<input <input
@@ -1631,7 +1666,7 @@ export function DailyChecklistContent() {
className='checkbox-clean' className='checkbox-clean'
/> />
<span className='text-sm font-medium text-gray-700 group-hover:text-gray-900'> <span className='text-sm font-medium text-gray-700 group-hover:text-gray-900'>
Pilih Semua ({employees.length} ABK) Pilih Semua ({filteredEmployees.length} ABK)
</span> </span>
</label> </label>
</div> </div>
@@ -275,6 +275,13 @@ export function DetailDailyChecklistContent() {
]) ])
).values() ).values()
); );
uniqueEmployees.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
})
);
setEmployees(uniqueEmployees); setEmployees(uniqueEmployees);
// Group data by Phase → Time Type → Activity // Group data by Phase → Time Type → Activity
@@ -779,11 +786,23 @@ export function DetailDailyChecklistContent() {
} }
// ACTIVITY rows // ACTIVITY rows
timeGroup.activities.forEach((activity, index) => { const activities = timeGroup.activities;
activities.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
})
);
activities.forEach((activity, index) => {
const indentClass = hasMultipleTimeTypes const indentClass = hasMultipleTimeTypes
? 'pl-12' ? 'pl-12'
: 'pl-8'; : 'pl-8';
console.log({
activity,
});
rows.push( rows.push(
<tr <tr
key={`activity-${activity.id}-${index}`} key={`activity-${activity.id}-${index}`}
@@ -823,9 +842,15 @@ export function DetailDailyChecklistContent() {
})} })}
<td className='py-3 px-4'> <td className='py-3 px-4'>
{activity.employees.length > 0 && {activity.employees.length > 0 &&
activity.employees[0].note ? ( activity.employees[
activity.employees.length - 1
].note ? (
<p className='text-sm text-gray-600'> <p className='text-sm text-gray-600'>
{activity.employees[0].note} {
activity.employees[
activity.employees.length - 1
].note
}
</p> </p>
) : ( ) : (
<p className='text-xs text-gray-400 italic'> <p className='text-xs text-gray-400 italic'>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Plus, MoreVertical, Pencil, Trash2 } from 'lucide-react'; import { Plus, MoreVertical, Pencil, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card'; import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button'; import { Button } from '@/figma-make/components/base/button';
@@ -404,7 +404,22 @@ export function MasterConfigurationContent() {
</div> </div>
{/* Add/Edit Modal */} {/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}> <Dialog
open={showModal}
onOpenChange={(open) => {
if (!open) {
setIsFormInvalid(false);
setConfigurationForm({
id: 0,
date: '',
percentage_threshold_bad: '',
percentage_threshold_enough: '',
});
}
setShowModal(open);
}}
>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'> <DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@@ -0,0 +1,51 @@
'use client';
import { ReactNode } from 'react';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export type FinanceTabActionsSlice = {
// State - actions per tab ID
tabActions: Record<string, ReactNode>;
// Actions
setTabActions: (tabId: string, actions: ReactNode) => void;
clearTabActions: (tabId: string) => void;
clearAllTabActions: () => void;
};
export const useFinanceTabStore = create<FinanceTabActionsSlice>()(
devtools(
(set) => ({
tabActions: {},
setTabActions: (tabId, actions) =>
set(
(state) => ({
tabActions: {
...state.tabActions,
[tabId]: actions,
},
}),
false,
'setTabActions'
),
clearTabActions: (tabId) =>
set(
(state) => {
const { [tabId]: _, ...rest } = state.tabActions;
return { tabActions: rest };
},
false,
'clearTabActions'
),
clearAllTabActions: () =>
set({ tabActions: {} }, false, 'clearAllTabActions'),
}),
{
name: 'FinanceTabStore',
}
)
);
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { ReactNode } from 'react';
import { StateCreator } from 'zustand';
import { UIStore } from '@/types/stores';
type NavbarActionsSlice = {
navbarActions: ReactNode | null;
setNavbarActions: (actions: ReactNode) => void;
clearNavbarActions: () => void;
};
export const createNavbarActionsSlice: StateCreator<
UIStore,
[],
[],
NavbarActionsSlice
> = (set) => ({
navbarActions: null,
setNavbarActions: (actions) => set({ navbarActions: actions }),
clearNavbarActions: () => set({ navbarActions: null }),
});
+2
View File
@@ -7,6 +7,7 @@ import { UIStore } from '@/types/stores';
import { createMainUiSlice } from '@/stores/ui/slices/main.slice'; import { createMainUiSlice } from '@/stores/ui/slices/main.slice';
import { createDrawerUISlice } from '@/stores/ui/slices/drawer.slice'; import { createDrawerUISlice } from '@/stores/ui/slices/drawer.slice';
import { createTableUISlice } from '@/stores/ui/slices/table.slice'; import { createTableUISlice } from '@/stores/ui/slices/table.slice';
import { createNavbarActionsSlice } from '@/stores/ui/slices/navbar.slice';
export const useUiStore = create<UIStore>()( export const useUiStore = create<UIStore>()(
devtools( devtools(
@@ -15,6 +16,7 @@ export const useUiStore = create<UIStore>()(
...createMainUiSlice(...args), ...createMainUiSlice(...args),
...createDrawerUISlice(...args), ...createDrawerUISlice(...args),
...createTableUISlice(...args), ...createTableUISlice(...args),
...createNavbarActionsSlice(...args),
}), }),
{ {
name: 'ui-cache', name: 'ui-cache',
+4 -4
View File
@@ -68,11 +68,11 @@ export type CreateMovementPayloadData = {
product_qty: number; product_qty: number;
}[]; }[];
deliveries: { deliveries: {
delivery_cost: number; delivery_cost?: number;
delivery_cost_per_item: number; delivery_cost_per_item?: number;
document_index?: number; document_index?: number;
driver_name: string; driver_name?: string;
vehicle_plate: string; vehicle_plate?: string;
supplier_id?: number | null; supplier_id?: number | null;
products: { products: {
product_id: number; product_id: number;
+11 -1
View File
@@ -32,7 +32,17 @@ type TableUISlice = {
resetSearchValue: () => void; resetSearchValue: () => void;
}; };
export type UIStore = MainUiSlice & DrawerUISlice & TableUISlice; // Navbar Actions Slice
type NavbarActionsSlice = {
navbarActions: ReactNode | null;
setNavbarActions: (actions: ReactNode) => void;
clearNavbarActions: () => void;
};
export type UIStore = MainUiSlice &
DrawerUISlice &
TableUISlice &
NavbarActionsSlice;
type ProductionStandardFormSlice = { type ProductionStandardFormSlice = {
formData: { formData: {