Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu

This commit is contained in:
rstubryan
2026-01-25 17:46:05 +07:00
25 changed files with 2029 additions and 418 deletions
+7 -3
View File
@@ -1,5 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin "daisyui"; @plugin "daisyui";
@import '../styles/tailwind.css';
@import '../styles/daisyui.css'; @import '../styles/daisyui.css';
@import '../figma-make/styles/theme.css'; @import '../figma-make/styles/theme.css';
@@ -34,11 +35,11 @@
/* Status/Utility Colors */ /* Status/Utility Colors */
--color-info: oklch(67.4% 0.176 238.9); --color-info: oklch(67.4% 0.176 238.9);
--color-info-content: oklch(0% 0 0); /* #000000 */ --color-info-content: oklch(0% 0 0); /* #000000 */
--color-success: oklch(62.3% 0.147 149); --color-success: #00d390;
--color-success-content: oklch(100% 0 0); /* #ffffff */ --color-success-content: oklch(100% 0 0); /* #ffffff */
--color-warning: oklch(82.2% 0.165 91.9); --color-warning: oklch(82.2% 0.165 91.9);
--color-warning-content: oklch(0% 0 0); /* #000000 */ --color-warning-content: oklch(0% 0 0); /* #000000 */
--color-error: oklch(61.8% 0.203 27.8); --color-error: #ff3a3a;
--color-error-content: oklch(100% 0 0); /* #fffffff */ --color-error-content: oklch(100% 0 0); /* #fffffff */
--radius-selector: 0rem; --radius-selector: 0rem;
@@ -52,7 +53,7 @@
} }
:root { :root {
--color-primary: #1f74bf; --color-primary: #0069e0;
} }
@theme { @theme {
@@ -64,6 +65,9 @@
--container-lg: 64rem; --container-lg: 64rem;
--container-xl: 80rem; --container-xl: 80rem;
--container-2xl: 96rem; --container-2xl: 96rem;
--shadow-button-soft:
0 3px 2px -2px var(--color-base-200), 0 4px 3px -2px var(--color-base-200);
} }
html { html {
@@ -1,9 +1,12 @@
import TransferToLayingsTable from '@/components/pages/production/transfer-to-laying/TransferToLayingsTable'; import TransferToLayingsTable from '@/components/pages/production/transfer-to-laying/TransferToLayingsTable';
import TransferToLayingFormModal from '@/components/pages/production/transfer-to-laying/TransferToLayingFormModal';
const TransferToLaying = () => { const TransferToLaying = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full'>
<TransferToLayingsTable /> <TransferToLayingsTable />
<TransferToLayingFormModal />
</section> </section>
); );
}; };
+263
View File
@@ -0,0 +1,263 @@
import React, { useId } from 'react';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import { cn, findMenuPath } from '@/lib/helper';
import { Size } from '@/types/theme';
import Button from '@/components/Button';
import { MAIN_DRAWER_LINKS } from '@/config/constant';
interface BreadcrumbItem {
label: string;
href?: string;
icon?: React.ReactNode;
isActive?: boolean;
isDisabled?: boolean;
}
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
size?: Size;
maxVisibleItems?: number;
showEllipsisDropdown?: boolean;
}
export function buildBreadcrumbs(pathname: string): BreadcrumbItem[] {
const menuPath = findMenuPath(MAIN_DRAWER_LINKS, pathname);
if (!menuPath) return [];
return menuPath.map((menu, index) => {
const isLast = index === menuPath.length - 1;
return {
label: menu.text,
href: isLast ? menu.link : undefined,
isActive: isLast,
icon: menu.icon ? (
<Icon icon={menu.icon} width={16} height={16} />
) : undefined,
};
});
}
const EllipsisDropdown = ({
hiddenItems,
}: {
hiddenItems: BreadcrumbItem[];
}) => {
const dropdownId = useId();
const anchorId = useId();
return (
<li>
{/* Ellipsis Button */}
<Button
popoverTarget={dropdownId}
variant='ghost'
color='none'
style={
{
anchorName: `--breadcrumb-ellipsis-anchor-${anchorId}`,
} as React.CSSProperties
}
>
<Icon icon='material-symbols:more-horiz' width={16} height={16} />
</Button>
{/* Dropdown Menu using popover API */}
<ul
className='dropdown menu rounded-box bg-base-100 border border-base-300 shadow-lg z-[9999] [&_a:hover]:no-underline [&_a:focus]:no-underline [&&]:no-underline [&&_a]:no-underline [&&]:hover:no-underline [&&]:flex [&&]:items-start [&&]:justify-start w-max'
popover='auto'
id={dropdownId}
style={
{
positionAnchor: `--breadcrumb-ellipsis-anchor-${anchorId}`,
} as React.CSSProperties
}
>
{hiddenItems.map((item, index) => {
const itemStyles = cn(
'[&]:flex [&]:items-center [&]:justify-start py-1 text-sm',
// Disabled state
item.isDisabled && 'text-base-content/40 opacity-50',
// Active/Last state
(item.isActive || item.isDisabled) && 'text-primary',
// Regular clickable state
!item.isDisabled && 'text-base-content/50'
);
const itemContent = (
<div className={itemStyles}>
{item.icon && (
<span className='inline-flex mr-2'>{item.icon}</span>
)}
{item.label}
</div>
);
return (
<li
key={`ellipsis-${index}`}
className='[&&]:text-left [&&]:block w-full'
>
{item.href && !item.isDisabled ? (
<Link
href={item.href}
className='block !no-underline [&&]:text-left w-full'
onClick={(e) => e.stopPropagation()}
>
{itemContent}
</Link>
) : (
<div className='block !no-underline [&&]:cursor-default [&&]:hover:cursor-default [&&]:hover:bg-base-100 [&&]:text-left'>
{itemContent}
</div>
)}
</li>
);
})}
</ul>
</li>
);
};
const Breadcrumb = ({
items,
size = 'md',
maxVisibleItems = 3,
showEllipsisDropdown = true,
className,
...props
}: BreadcrumbsProps) => {
const sizeClasses = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
};
const getItemStyles = (
item: BreadcrumbItem,
position: 'first' | 'middle' | 'last' = 'middle'
) => {
const baseClasses = 'inline-flex items-center gap-2';
// Disabled state
if (item.isDisabled) {
return `${baseClasses} text-base-content/40 !cursor-default opacity-50 hover:!no-underline`;
}
// Active/Last state (no underline)
if (item.isActive || position === 'last') {
return `${baseClasses} text-primary !cursor-pointer hover:!no-underline`;
}
// Regular clickable state
return `${baseClasses} text-base-content/60`;
};
const renderItem = (
item: BreadcrumbItem,
position: 'first' | 'middle' | 'last' = 'middle'
) => {
const styles = getItemStyles(item, position);
// Disabled items
if (item.isDisabled) {
return (
<span className={styles}>
{item.icon && item.icon}
{item.label}
</span>
);
}
// Active/Last items
if (item.isActive || position === 'last') {
if (item.href) {
return (
<Link href={item.href} className={styles}>
{item.icon && (
<span className='inline-flex gap-2'>{item.icon}</span>
)}
{item.label}
</Link>
);
}
return (
<span className={styles}>
{item.icon && item.icon}
{item.label}
</span>
);
}
// Regular items
if (item.href) {
return (
<Link href={item.href} className={styles}>
{item.icon && <span className='inline-flex gap-2'>{item.icon}</span>}
{item.label}
</Link>
);
}
return (
<span className={styles}>
{item.icon && item.icon}
{item.label}
</span>
);
};
const renderBreadcrumbList = () => {
// Show all items if within limit
if (items.length <= maxVisibleItems) {
return items.map((item, index) => {
const position =
index === 0
? 'first'
: index === items.length - 1
? 'last'
: 'middle';
return <li key={index}>{renderItem(item, position)}</li>;
});
}
// Collapsed items indexing when exceeding limit
const firstItem = items[0];
const lastItem = items[items.length - 1];
const visibleMiddleItems = items.slice(1, -1).slice(-(maxVisibleItems - 2));
const hiddenItems = items.slice(1, -1).slice(0, -(maxVisibleItems - 2));
const showEllipsis = showEllipsisDropdown && hiddenItems.length > 0;
return (
<>
<li>{renderItem(firstItem, 'first')}</li>
{/* Ellipsis for hidden items with dropdown */}
{showEllipsis && <EllipsisDropdown hiddenItems={hiddenItems} />}
{/* Middle items */}
{visibleMiddleItems.map((item, index) => (
<li key={`middle-${index}`}>{renderItem(item, 'middle')}</li>
))}
<li>{renderItem(lastItem, 'last')}</li>
</>
);
};
return (
<nav
aria-label='Breadcrumb'
className={cn('breadcrumbs', sizeClasses[size], className)}
{...props}
>
<ul className='text-sm'>{renderBreadcrumbList()}</ul>
</nav>
);
};
export default Breadcrumb;
+2 -1
View File
@@ -2,11 +2,12 @@ import react from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { UrlObject } from 'url';
export interface ButtonProps extends react.ComponentProps<'button'> { export interface ButtonProps extends react.ComponentProps<'button'> {
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
color?: Color; color?: Color;
href?: string; href?: string | UrlObject;
isLoading?: boolean; isLoading?: boolean;
target?: string; target?: string;
rel?: string; rel?: string;
+1 -35
View File
@@ -78,40 +78,6 @@ const MainDrawer = ({
permissionCheck(permission) permissionCheck(permission)
); );
const getPageTitle = useCallback(() => {
let title = '';
const activeMenu = MAIN_DRAWER_LINKS.find((item) =>
isPathActive(pathname, item.link)
);
const traverseMenuTitle = (menu: typeof activeMenu) => {
if (!menu) return;
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) {
title += menu?.text;
} else {
title += ' - ' + menu?.text;
}
if (!hasSubmenu || !menu.submenu) return;
const activeSubmenu = menu.submenu?.find((item) =>
isPathActive(pathname, item.link)
);
traverseMenuTitle(activeSubmenu);
};
traverseMenuTitle(activeMenu);
return title;
}, [pathname]);
const pageTitle = getPageTitle();
const toggleSidebar = () => { const toggleSidebar = () => {
setMainDrawerOpen(!mainDrawerOpen); setMainDrawerOpen(!mainDrawerOpen);
}; };
@@ -132,7 +98,7 @@ const MainDrawer = ({
}} }}
> >
<main className='w-full h-full flex flex-col'> <main className='w-full h-full flex flex-col'>
<Navbar title={pageTitle as string} toggleSidebar={toggleSidebar} /> <Navbar toggleSidebar={toggleSidebar} />
{children} {children}
</main> </main>
+22 -2
View File
@@ -53,15 +53,25 @@ interface ModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
children?: ReactNode; children?: ReactNode;
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
onBackdropClick?: () => void;
position?: 'top' | 'middle' | 'bottom' | 'start' | 'end';
className?: { className?: {
modal?: string; modal?: string;
modalBox?: string; modalBox?: string;
}; };
} }
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { const Modal = ({
ref,
children,
closeOnBackdrop,
onBackdropClick,
position = 'middle',
className,
}: ModalProps) => {
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => { const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (closeOnBackdrop && e.target === ref.current) { if (closeOnBackdrop && e.target === ref.current) {
onBackdropClick?.();
ref.current?.close(); ref.current?.close();
} }
}; };
@@ -69,7 +79,17 @@ const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
return ( return (
<dialog <dialog
ref={ref} ref={ref}
className={cn('modal', className?.modal)} className={cn(
'modal',
{
'modal-top': position === 'top',
'modal-middle': position === 'middle',
'modal-bottom': position === 'bottom',
'modal-start': position === 'start',
'modal-end': position === 'end',
},
className?.modal
)}
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className={cn('modal-box', className?.modalBox)}>{children}</div> <div className={cn('modal-box', className?.modalBox)}>{children}</div>
+41 -31
View File
@@ -1,26 +1,26 @@
'use client'; 'use client';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown'; import Breadcrumb, { buildBreadcrumbs } from '@/components/Breadcrumb';
import PopoverButton from '@/components/popover/PopoverButton';
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';
interface NavbarProps { interface NavbarProps {
title: string;
toggleSidebar?: () => void; toggleSidebar?: () => void;
} }
const Navbar = ({ title, toggleSidebar }: NavbarProps) => { const Navbar = ({ toggleSidebar }: NavbarProps) => {
const { setUser } = useAuth(); const { setUser } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const logoutClickHandler = async () => { const logoutClickHandler = async () => {
const logoutRes = await AuthApi.logout(); const logoutRes = await AuthApi.logout();
@@ -35,42 +35,52 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
}; };
return ( return (
<div className='navbar px-4 bg-base-100 shadow-sm'> <div className='navbar p-3 bg-base-100 border-b border-base-content/10'>
<div className='flex-1'> <div className='flex-1'>
<div className='flex flex-row items-center gap-4'> <div className='flex flex-row items-center gap-4'>
{toggleSidebar && ( {toggleSidebar && (
<Button onClick={toggleSidebar} className='block lg:hidden'> <Button
<Icon variant='ghost'
icon='material-symbols:menu-rounded' color='none'
width={24} onClick={toggleSidebar}
height={24} className='block lg:hidden p-[9px] text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
/> >
<Icon icon='heroicons:bars-3' width={20} height={20} />
</Button> </Button>
)} )}
<span className='font-bold text-xl text-primary'>{title}</span> <Breadcrumb items={buildBreadcrumbs(pathname)} />
</div> </div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<Dropdown <PopoverButton
align='end' tabIndex={0}
direction='bottom' variant='ghost'
trigger={ color='none'
<div className='btn btn-ghost btn-circle avatar'> popoverTarget='accountNavbar'
<div className='w-10 rounded-full border flex justify-center items-center'> anchorName='--account-navbar'
<Icon icon='uil:user' width={40} height={40} /> className='p-[9px] text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
</div>
</div>
}
className={{
content: 'w-52 mt-3',
}}
> >
<Menu> <Icon icon='heroicons:user' width={20} height={20} />
<MenuItem title='Logout' onClick={logoutClickHandler} /> </PopoverButton>
</Menu>
</Dropdown> <PopoverContent
id='accountNavbar'
anchorName='--account-navbar'
position='bottom-start'
className='rounded-xl border border-base-content/5 shadow-sm'
>
<Button
onClick={logoutClickHandler}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon icon='heroicons-outline:logout' width={20} height={20} />
Logout
</Button>
</PopoverContent>
</div> </div>
</div> </div>
); );
+10 -3
View File
@@ -31,6 +31,7 @@ interface TableClassNames {
headerColumnClassName?: string; headerColumnClassName?: string;
tableBodyClassName?: string; tableBodyClassName?: string;
bodyRowClassName?: string; bodyRowClassName?: string;
selectedBodyRowClassName?: string;
bodyColumnClassName?: string; bodyColumnClassName?: string;
tableFooterClassName?: string; tableFooterClassName?: string;
footerRowClassName?: string; footerRowClassName?: string;
@@ -88,9 +89,11 @@ export const TABLE_DEFAULT_STYLING = {
headerColumnClassName: headerColumnClassName:
'px-4 py-3 border-base-content/10 text-base-content/50 text-sm font-medium', 'px-4 py-3 border-base-content/10 text-base-content/50 text-sm font-medium',
tableBodyClassName: '', tableBodyClassName: '',
bodyRowClassName: 'border-t border-base-content/10', bodyRowClassName:
'transition-all duration-200 border-t border-base-content/10 bg-transparent',
selectedBodyRowClassName: 'bg-primary/5',
bodyColumnClassName: 'px-4 py-3 text-base-content', bodyColumnClassName: 'px-4 py-3 text-base-content',
paginationClassName: '', paginationClassName: 'px-3',
tableFooterClassName: 'font-semibold border-base-content/10', tableFooterClassName: 'font-semibold border-base-content/10',
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10', footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap', footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
@@ -353,7 +356,11 @@ const Table = <TData extends object>({
key={row.id} key={row.id}
className={cn( className={cn(
TABLE_DEFAULT_STYLING.bodyRowClassName, TABLE_DEFAULT_STYLING.bodyRowClassName,
tableClassNames.bodyRowClassName tableClassNames.bodyRowClassName,
{
[tableClassNames.selectedBodyRowClassName]:
row.getIsSelected(),
}
)} )}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
+56
View File
@@ -0,0 +1,56 @@
import Badge from '@/components/Badge';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface StatusBadgeProps {
color: Color;
text: string;
className?: {
badge?: string;
status?: string;
};
}
const StatusBadge = ({
color = 'neutral',
text,
className,
}: StatusBadgeProps) => {
return (
<Badge
variant='soft'
className={{
badge: cn(
'px-2 py-1 w-full flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content',
{
'bg-base-content/5': color === 'neutral',
'bg-success/30': color === 'success',
'bg-error/20': color === 'error',
'bg-primary/20': color === 'info',
},
className?.badge
),
status: cn(className?.status),
}}
color={color}
>
<svg
height='12'
width='12'
xmlns='http://www.w3.org/2000/svg'
className={cn({
'text-base-content/10': color === 'neutral',
'text-success': color === 'success',
'text-error': color === 'error',
'text-primary': color === 'info',
})}
>
<circle r='6' cx='6' cy='6' fill='currentColor' />
</svg>
{text}
</Badge>
);
};
export default StatusBadge;
+9 -14
View File
@@ -204,17 +204,12 @@ const DateInput = ({
const finalErrorMessage = internalError || externalErrorMessage; const finalErrorMessage = internalError || externalErrorMessage;
return ( return (
<div <div className={cn('w-full flex flex-col text-start', className?.wrapper)}>
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && ( {label && (
<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': finalIsError }, { 'text-error': finalIsError },
className?.label className?.label
)} )}
@@ -231,7 +226,7 @@ const DateInput = ({
<div <div
className={cn( className={cn(
'input h-12 bg-inherit px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border', 'input h-12 bg-inherit px-3 py-2.5 text-base font-normal leading-6 w-full rounded-lg transition-all duration-200 flex items-center border border-base-content/10',
{ {
'border-error': finalIsError, 'border-error': finalIsError,
'border-success': externalValid && !finalIsError, 'border-success': externalValid && !finalIsError,
@@ -261,10 +256,10 @@ const DateInput = ({
</div> </div>
)} )}
<Icon <Icon
icon='uil:calendar' icon='heroicons:calendar-date-range'
width={24} width={14}
height={24} height={14}
className='cursor-pointer text-dark' className='cursor-pointer text-base-content/20'
onClick={(e) => onClick={(e) =>
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>) handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
} }
@@ -272,10 +267,10 @@ const DateInput = ({
</div> </div>
{!finalIsError && bottomLabel && ( {!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)} )}
{finalIsError && finalErrorMessage && ( {finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{finalErrorMessage}</p> <p className='w-full mt-1.5 text-xs text-error'>{finalErrorMessage}</p>
)} )}
<Modal <Modal
+21 -19
View File
@@ -95,7 +95,7 @@ const CustomControl = <
return ( return (
<ReactSelectComponents.Control {...props}> <ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'> <div className='flex-1 p-3! py-1.5 gap-1 flex items-center'>
{shouldShowAdornment && startAdornment} {shouldShowAdornment && startAdornment}
{children} {children}
</div> </div>
@@ -118,7 +118,7 @@ const CustomMenuList = <
{children} {children}
{options.length > 0 && isLoading && ( {options.length > 0 && isLoading && (
<div className='mt-1 px-3 py-2 rounded-md text-center text-gray-400'> <div className='px-3 py-2 rounded-md text-center text-gray-400'>
<span className='loading loading-spinner loading-md' /> <span className='loading loading-spinner loading-md' />
</div> </div>
)} )}
@@ -204,16 +204,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}; };
return ( return (
<div <div className={cn('w-full flex flex-col text-start', className?.wrapper)}>
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && ( {label && (
<span <span
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 },
className?.label className?.label
)} )}
@@ -253,33 +248,36 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
...(!startAdornment && { ...(!startAdornment && {
control: ({ isFocused, isDisabled }) => control: ({ isFocused, isDisabled }) =>
cn( cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!', 'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!',
{ {
'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,
'border-gray-300': !isError && !isFocused, 'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled, 'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
} }
), ),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'), valueContainer: () => cn('flex-1 p-3! py-2! gap-1'),
}), }),
placeholder: () => placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }), cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () => singleValue: () =>
cn({ 'text-gray-900': !isError, 'text-error!': isError }), cn({ 'text-gray-900': !isError, 'text-error!': isError }),
input: () => cn('text-gray-900'), input: () => cn('text-gray-900 m-0! p-0!'),
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'), indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
dropdownIndicator: ({ isFocused }) => dropdownIndicator: ({ isFocused }) =>
cn('p-1 rounded hover:bg-gray-100', { cn('p-1! rounded hover:bg-gray-100', {
'text-gray-900': isFocused, 'text-gray-900': isFocused,
'text-gray-500': !isFocused, 'text-gray-500': !isFocused,
'text-error!': isError, 'text-error!': isError,
}), }),
clearIndicator: () => cn('p-1! rounded hover:bg-gray-100'),
menu: () => menu: () =>
cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'), cn(
menuList: () => cn('p-2! max-h-60 overflow-auto'), '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 }) => option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', { cn('px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused, 'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected, 'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected, 'text-gray-700': !isFocused && !isSelected,
@@ -287,13 +285,17 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
multiValue: ({ getValue, index }) => { multiValue: ({ getValue, index }) => {
const selectedValues = getValue() as T[]; const selectedValues = getValue() as T[];
return cn( return cn(
'bg-indigo-50 rounded py-0.5 pl-2 pr-1 flex items-center gap-1!', 'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
selectedValues[index]?.className selectedValues[index]?.className
); );
}, },
multiValueRemove: () => cn('p-0! w-3 h-3'),
multiValueLabel: ({ getValue, index }) => { multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue() as T[]; const selectedValues = getValue() as T[];
return cn('text-indigo-700', selectedValues[index]?.labelClassName); return cn(
'p-0! text-base-content! text-xs!',
selectedValues[index]?.labelClassName
);
}, },
}} }}
components={{ components={{
+11 -4
View File
@@ -25,14 +25,18 @@ const CheckboxOption = <
>( >(
props: OptionProps<T, IsMulti, Group> props: OptionProps<T, IsMulti, Group>
) => { ) => {
const { isSelected, label, innerRef, innerProps, className } = props; const { isSelected, label, innerRef, innerProps, className, isFocused } =
props;
return ( return (
<div <div
ref={innerRef} ref={innerRef}
{...innerProps} {...innerProps}
className={cn( className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer', 'flex items-center gap-3 p-3 cursor-pointer transition-all hover:bg-primary/5',
{
'bg-primary/5': isFocused,
},
className className
)} )}
> >
@@ -40,9 +44,12 @@ const CheckboxOption = <
type='checkbox' type='checkbox'
checked={isSelected} checked={isSelected}
onChange={() => null} onChange={() => null}
className='checkbox checkbox-sm rounded checkbox-primary pointer-events-none' className='checkbox checkbox-sm rounded-md checkbox-primary pointer-events-none border-base-content/10'
/> />
<label className='cursor-pointer flex-1 select-none'>{label}</label>
<label className='cursor-pointer flex-1 select-none text-sm text-base-content/50 font-medium'>
{label}
</label>
</div> </div>
); );
}; };
+11 -4
View File
@@ -21,14 +21,18 @@ const RadioOption = <
>( >(
props: OptionProps<T, IsMulti, Group> props: OptionProps<T, IsMulti, Group>
) => { ) => {
const { isSelected, label, innerRef, innerProps, className } = props; const { isSelected, label, innerRef, innerProps, className, isFocused } =
props;
return ( return (
<div <div
ref={innerRef} ref={innerRef}
{...innerProps} {...innerProps}
className={cn( className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer', 'flex items-center gap-3 p-3 cursor-pointer transition-all hover:bg-primary/5',
{
'bg-primary/5': isFocused,
},
className className
)} )}
> >
@@ -36,9 +40,12 @@ const RadioOption = <
type='radio' type='radio'
checked={isSelected} checked={isSelected}
onChange={() => null} onChange={() => null}
className='radio radio-sm radio-primary pointer-events-none' className='radio radio-md radio-primary pointer-events-none'
/> />
<label className='cursor-pointer flex-1 select-none'>{label}</label>
<label className='cursor-pointer flex-1 select-none text-sm text-base-content/50 font-medium'>
{label}
</label>
</div> </div>
); );
}; };
+7 -5
View File
@@ -53,7 +53,7 @@ const TextArea = ({
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',
className?.wrapper className?.wrapper
)} )}
> >
@@ -61,7 +61,7 @@ const TextArea = ({
<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,
}, },
@@ -83,7 +83,7 @@ const TextArea = ({
<textarea <textarea
className={cn( className={cn(
'textarea h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white', 'textarea h-auto px-3 py-2.5 text-base font-normal leading-6 w-full rounded-lg outline-none! transition-all bg-white border-base-content/10',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -110,9 +110,11 @@ const TextArea = ({
)} )}
{!isError && 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 && (
<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>
); );
}; };
+25 -11
View File
@@ -21,6 +21,9 @@ export interface TextInputProps {
label?: string; label?: string;
inputWrapper?: string; inputWrapper?: string;
input?: string; input?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
}; };
isError?: boolean; isError?: boolean;
isValid?: boolean; isValid?: boolean;
@@ -62,7 +65,7 @@ const TextInput = ({
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
)} )}
> >
@@ -70,7 +73,7 @@ const TextInput = ({
<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,
}, },
@@ -90,15 +93,23 @@ const TextInput = ({
)} )}
{inputPrefix || inputSuffix ? ( {inputPrefix || inputSuffix ? (
<div className='relative flex'> <div
className={cn(
'relative flex text-sm',
className?.inputPrefixSuffixWrapper
)}
>
{inputPrefix && ( {inputPrefix && (
<div <div
className={cn( className={cn(
'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200', 'inline-flex items-center px-3 py-2.5 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{ {
'bg-gray-100 border-gray-300': !disabled, 'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled, 'bg-gray-50 border-gray-200': disabled,
} 'border-error': isError,
'border-success!': isValid,
},
className?.inputPrefix
)} )}
> >
{inputPrefix} {inputPrefix}
@@ -107,7 +118,7 @@ const TextInput = ({
<div <div
className={cn( className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white', 'input h-12 px-3 py-2.5 text-sm font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white border-base-content/10',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -154,11 +165,14 @@ const TextInput = ({
{inputSuffix && ( {inputSuffix && (
<div <div
className={cn( className={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200', 'inline-flex items-center px-3 py-2.5 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-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled, 'bg-gray-50 border-gray-200': disabled,
} 'border-error': isError,
'border-success!': isValid,
},
className?.inputSuffix
)} )}
> >
{inputSuffix} {inputSuffix}
@@ -168,7 +182,7 @@ const TextInput = ({
) : ( ) : (
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white', 'input h-12 px-3 py-2.5 text-sm font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white border-base-content/10',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -202,10 +216,10 @@ const TextInput = ({
)} )}
{!isError && 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 && ( {isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p> <p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)} )}
</div> </div>
); );
+45 -41
View File
@@ -35,29 +35,29 @@ const iconConfig = {
info: { info: {
icon: 'material-symbols:info-outline-rounded', icon: 'material-symbols:info-outline-rounded',
iconClassName: 'text-info-content', iconClassName: 'text-info-content',
bgClassName: 'bg-info', innerRingClassName: 'bg-info',
outerRingClassName: 'bg-info/20', middleRingClassName: 'bg-info/12',
borderClassName: 'border-info', outerRingClassName: 'border-info/12 bg-info/8',
}, },
success: { success: {
icon: 'heroicons:check', icon: 'heroicons:check',
iconClassName: 'text-white', iconClassName: 'text-white',
bgClassName: 'bg-[#00D390]', innerRingClassName: 'bg-success',
outerRingClassName: 'bg-[#00D3901F]', middleRingClassName: 'bg-success/12',
borderClassName: 'border-[#CCF7EB]', outerRingClassName: 'border-success/12 bg-success/8',
}, },
error: { error: {
icon: 'solar:danger-triangle-linear', icon: 'heroicons:exclamation-triangle',
iconClassName: 'text-error-content', iconClassName: 'text-error-content',
bgClassName: 'bg-[#f03338]', innerRingClassName: 'bg-error',
outerRingClassName: 'bg-[#f3cdcd]', middleRingClassName: 'bg-error/12',
borderClassName: 'border-[#fff0ef]', outerRingClassName: 'border-error/12 bg-error/8',
}, },
} as const; } as const;
const ConfirmationModalIcon = ({ const ConfirmationModalIcon = ({
type, type,
size = 24, size = 16,
}: { }: {
type: 'info' | 'success' | 'error'; type: 'info' | 'success' | 'error';
size?: number; size?: number;
@@ -65,28 +65,22 @@ const ConfirmationModalIcon = ({
const config = iconConfig[type]; const config = iconConfig[type];
return ( return (
<div className='flex items-center justify-center p-2'> <div
<div className={cn('rounded-full border p-[5px]', config.outerRingClassName)}
className={cn( >
'rounded-full border-4 p-1', <div className={cn('rounded-full p-2', config.middleRingClassName)}>
config.outerRingClassName, <div
config.borderClassName className={cn(
)} 'rounded-full p-1 flex items-center justify-center',
> config.innerRingClassName
<div className={cn('rounded-full p-1', config.outerRingClassName)}> )}
<div >
className={cn( <Icon
'rounded-full p-3 flex items-center justify-center', icon={config.icon}
config.bgClassName width={size}
)} height={size}
> className={config.iconClassName}
<Icon />
icon={config.icon}
width={size}
height={size}
className={config.iconClassName}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +97,7 @@ const ConfirmationModal = ({
secondaryButton, secondaryButton,
className, className,
children, children,
iconSize = 32, iconSize = 16,
iconPosition = 'center', iconPosition = 'center',
}: ConfirmationModalProps) => { }: ConfirmationModalProps) => {
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false); const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
@@ -123,7 +117,14 @@ const ConfirmationModal = ({
}; };
return ( return (
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}> <Modal
ref={ref}
closeOnBackdrop={closeOnBackdrop}
className={{
...className,
modalBox: cn('rounded-xl p-4', className?.modalBox),
}}
>
<div className='w-full flex flex-col gap-4'> <div className='w-full flex flex-col gap-4'>
{iconPosition === 'center' ? ( {iconPosition === 'center' ? (
<> <>
@@ -143,7 +144,7 @@ const ConfirmationModal = ({
</> </>
) : ( ) : (
<div <div
className={cn('flex flex-row items-center gap-4', { className={cn('flex flex-row items-center gap-3', {
'flex-row': iconPosition === 'left', 'flex-row': iconPosition === 'left',
'flex-row-reverse': iconPosition === 'right', 'flex-row-reverse': iconPosition === 'right',
})} })}
@@ -153,12 +154,12 @@ const ConfirmationModal = ({
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<p className='font-medium'> <p className='text-sm font-semibold'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'} {text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p> </p>
{subtitleText && ( {subtitleText && (
<p className='text-sm text-gray-400'>{subtitleText}</p> <p className='text-xs text-base-content/50'>{subtitleText}</p>
)} )}
</div> </div>
</div> </div>
@@ -166,7 +167,7 @@ const ConfirmationModal = ({
{children && <div className='w-full'>{children}</div>} {children && <div className='w-full'>{children}</div>}
<div className='w-full flex flex-row gap-2'> <div className='w-full grid grid-cols-2 gap-3'>
{secondaryButton && secondaryButton.text && ( {secondaryButton && secondaryButton.text && (
<Button <Button
{...secondaryButton} {...secondaryButton}
@@ -179,7 +180,10 @@ const ConfirmationModal = ({
: isPrimaryButtonLoading : isPrimaryButtonLoading
} }
onClick={closeModalHandler} onClick={closeModalHandler}
className='grow' className={cn(
'p-2 rounded-xl text-sm',
secondaryButton?.className
)}
> >
{secondaryButton?.text ?? 'Tidak'} {secondaryButton?.text ?? 'Tidak'}
</Button> </Button>
@@ -200,7 +204,7 @@ const ConfirmationModal = ({
? primaryButton?.isLoading ? primaryButton?.isLoading
: isPrimaryButtonLoading : isPrimaryButtonLoading
} }
className='grow' className={cn('p-2 rounded-xl text-sm', primaryButton?.className)}
> >
{primaryButton?.text ?? 'Ya'} {primaryButton?.text ?? 'Ya'}
</Button> </Button>
@@ -32,6 +32,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
className, className,
rows = 3, rows = 3,
placeholder = 'Catatan...', placeholder = 'Catatan...',
...props
}) => { }) => {
const randomId = useId(); const randomId = useId();
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
@@ -55,6 +56,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
}} }}
secondaryButton={secondaryButton} secondaryButton={secondaryButton}
className={className} className={className}
{...props}
> >
<TextArea <TextArea
name={randomId} name={randomId}
@@ -405,7 +405,9 @@ const FinanceTable = () => {
{ {
header: 'Bank', header: 'Bank',
accessorFn: (finance: Finance) => accessorFn: (finance: Finance) =>
`${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`, finance.bank
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
: '-',
}, },
{ {
header: 'Pengeluaran (Rp)', header: 'Pengeluaran (Rp)',
@@ -0,0 +1,208 @@
'use client';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: TransferToLayingFilter) => void;
onReset?: () => void;
}
const TransferToLayingFilterModal = ({
ref,
onSubmit,
onReset,
}: TransferToLayingFilterModal) => {
const closeModalHandler = () => {
ref.current?.close();
};
// Flock Source
const {
setInputValue: setFlockSourceInputValue,
options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions,
loadMore: loadMoreFlockSource,
} = useSelect<Flock>(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
category: 'GROWING',
});
// Flock Destination
const {
setInputValue: setFlockDestinationInputValue,
options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions,
loadMore: loadMoreFlockDestination,
} = useSelect<Flock>(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
category: 'LAYING',
});
const formik = useFormik<{
startDate: string;
endDate: string;
flockSource: { value: number; label: string }[];
flockDestination: { value: number; label: string }[];
status: { value: number; label: string }[];
}>({
initialValues: {
startDate: '',
endDate: '',
flockSource: [],
flockDestination: [],
status: [],
},
onSubmit: async (values) => {
const formattedValues = {
...values,
flockSource: values.flockSource.map((item) => item.value),
flockDestination: values.flockDestination.map((item) => item.value),
status: values.status.map((item) => item.value),
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('flockSource', val as OptionType[]);
};
const flockDestinationChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('flockDestination', val as OptionType[]);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val as OptionType[]);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
{/* Modal Header */}
<div className='p-4 flex items-center justify-between gap-2 border-b border-gray-300'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<span className='py-2 text-xs font-semibold'>Tanggal</span>
<div className='flex flex-row items-center gap-1.5'>
<DateInput
name='startDate'
placeholder='Tanggal Awal'
value={formik.values.startDate}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='endDate'
placeholder='Tanggal Akhir'
value={formik.values.endDate}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
</div>
<SelectInputCheckbox
label='Flock Asal'
placeholder='Flock Asal'
value={formik.values.flockSource}
onChange={flockSourceChangeHandler}
options={flockSourceOptions}
isLoading={isLoadingFlockSourceOptions}
onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
/>
<SelectInputCheckbox
label='Flock Tujuan'
placeholder='Flock Tujuan'
value={formik.values.flockDestination}
onChange={flockDestinationChangeHandler}
options={flockDestinationOptions}
isLoading={isLoadingFlockDestinationOptions}
onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
/>
<SelectInputCheckbox
label='Status'
placeholder='Pilih Status'
options={[
{ value: 'PENDING', label: 'Pengajuan' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
]}
value={formik.values.status}
onChange={statusChangeHandler}
/>
</div>
</div>
{/* Modal Footer */}
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='ghost'
color='none'
className='p-3 rounded-lg text-base-content/65'
>
Reset Filter
</Button>
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default TransferToLayingFilterModal;
@@ -0,0 +1,975 @@
'use client';
import {
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import useSWR, { useSWRConfig } from 'swr';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import NumberInput from '@/components/input/NumberInput';
import TextArea from '@/components/input/TextArea';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useRouter, useSearchParams } from 'next/navigation';
import { ProjectFlockApi } from '@/services/api/production';
import { getIn, useFormik } from 'formik';
import {
getFilledTransferToLayingFormInitialValues,
getTransferToLayingFormInitialValues,
TransferToLayingFormSchema,
TransferToLayingFormValues,
} from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm.schema';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { cn, formatNumber } from '@/lib/helper';
import {
CreateTransferToLayingPayload,
UpdateTransferToLayingPayload,
} from '@/types/api/production/transfer-to-laying';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
const TransferToLayingFormModal = () => {
const router = useRouter();
const searchParams = useSearchParams();
const modalAction = searchParams.get('action');
const transferToLayingId = searchParams.get('id');
const { mutate } = useSWRConfig();
const refreshTransferToLayings = () => {
mutate(
(key) =>
typeof key === 'string' && key.includes(TransferToLayingApi.basePath)
);
};
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR(transferToLayingId ? transferToLayingId : undefined, (id: number) =>
TransferToLayingApi.getSingle(id)
);
/**
* Step 1: General Information
* Step 2: Select source and destination kandang
* Step 3: Enter transfered quantity
* Step 4: Submit
*/
const [step, setStep] = useState(1);
const formModal = useModal();
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
// Flock Source
const {
setInputValue: setFlockSourceInputValue,
options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions,
loadMore: loadMoreFlockSource,
rawData: flockSourceRawData,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
category: 'GROWING',
}
);
// Flock Destination
const {
setInputValue: setFlockDestinationInputValue,
options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions,
loadMore: loadMoreFlockDestination,
rawData: flockDestinationRawData,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
category: 'LAYING',
}
);
const closeModalHandler = (shouldPushToRoute: boolean = true) => {
if (shouldPushToRoute) {
router.push('/production/transfer-to-laying');
}
formik.resetForm();
setStep(1);
setFormErrorMessage('');
formModal.closeModal();
};
const createTransferToLayingHandler = useCallback(
async (payload: CreateTransferToLayingPayload) => {
const createTransferToLayingRes =
await TransferToLayingApi.create(payload);
if (isResponseError(createTransferToLayingRes)) {
setFormErrorMessage(createTransferToLayingRes.message);
return;
}
refreshTransferToLayings();
toast.success(createTransferToLayingRes?.message as string);
router.push('/production/transfer-to-laying');
closeModalHandler(false);
},
[router]
);
const updateTransferToLayingHandler = useCallback(
async (
transferToLayingId: number,
payload: UpdateTransferToLayingPayload
) => {
const updateKandangRes = await TransferToLayingApi.update(
transferToLayingId,
payload
);
if (updateKandangRes?.status === 'error') {
setFormErrorMessage(updateKandangRes.message);
return;
}
refreshTransferToLayings();
toast.success(updateKandangRes?.message as string);
router.push('/production/transfer-to-laying');
closeModalHandler(false);
},
[router]
);
const [formikInitialValues, setFormikInitialValues] = useState(
getTransferToLayingFormInitialValues()
);
const formik = useFormik<TransferToLayingFormValues>({
initialValues: formikInitialValues,
validationSchema: TransferToLayingFormSchema,
onSubmit: async (values) => {
const transferToLayingPayload: CreateTransferToLayingPayload = {
transfer_date: values.transfer_date as string,
source_project_flock_id: values.flockSource?.value as number,
target_project_flock_id: values.flockDestination?.value as number,
totalQuantity: values.totalQuantity as number,
source_kandangs: values.flockSourceKandangs?.map((kandang) => ({
project_flock_kandang_id: kandang.kandang.value,
quantity: parseFloat(kandang.quantity as string),
})) as CreateTransferToLayingPayload['source_kandangs'],
target_kandangs: values.flockDestinationKandangs?.map((kandang) => ({
project_flock_kandang_id: kandang.kandang.value,
quantity: parseFloat(kandang.quantity as string),
})) as CreateTransferToLayingPayload['target_kandangs'],
reason: values.reason as string,
};
switch (modalAction) {
case 'add':
await createTransferToLayingHandler(transferToLayingPayload);
break;
case 'edit':
await updateTransferToLayingHandler(
Number(transferToLayingId),
transferToLayingPayload
);
break;
}
},
});
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
ProjectFlock | undefined
>(undefined);
const selectedFlockDestinationRawData = isResponseSuccess(
flockDestinationRawData
)
? flockDestinationRawData.data.find(
(item) => item.id === formik.values.flockDestination?.value
)
: undefined;
const {
data: flockSourceKandangsAvailability,
isLoading: isLoadingFlockSourceKandangsAvailability,
} = useSWR(
formik.values.flockSource
? [
'transfer-to-laying',
'available-qty',
String(formik.values.flockSource.value),
]
: undefined,
([, , id]: string[]) =>
TransferToLayingApi.getMappedFlockKandangsAvailability(Number(id))
);
const mappedFlockSourceKandangsAvailability: {
kandang_name: string;
available_qty: number;
project_flock_kandang_id: number;
}[] = useMemo(() => {
if (!flockSourceKandangsAvailability || !selectedFlockSourceRawData)
return [];
return selectedFlockSourceRawData
? selectedFlockSourceRawData.kandangs.map((kandang) => {
const availability =
flockSourceKandangsAvailability[kandang.project_flock_kandang_id]
.available_qty;
return {
kandang_name: kandang.name,
available_qty: availability,
project_flock_kandang_id: kandang.project_flock_kandang_id,
};
})
: [];
}, [flockSourceKandangsAvailability, selectedFlockSourceRawData]);
const mappedFlockSourceKandangsAvailabilityInfo: {
available: number;
unavailable: number;
} = useMemo(() => {
if (!mappedFlockSourceKandangsAvailability)
return { available: 0, unavailable: 0 };
let countAvailable = 0;
let countUnavailable = 0;
mappedFlockSourceKandangsAvailability.forEach((item) => {
if (item.available_qty > 0) {
countAvailable += 1;
} else {
countUnavailable += 1;
}
});
return { available: countAvailable, unavailable: countUnavailable };
}, [mappedFlockSourceKandangsAvailability]);
const mappedFlockDestinationKandangsAvailabilityInfo: {
available: number;
unavailable: number;
} = useMemo(() => {
if (!selectedFlockDestinationRawData)
return { available: 0, unavailable: 0 };
let countAvailable = 0;
let countUnavailable = 0;
selectedFlockDestinationRawData?.kandangs.forEach((item) => {
// TODO: change this to real available quota later
if (item.capacity > 0) {
countAvailable += 1;
} else {
countUnavailable += 1;
}
});
return { available: countAvailable, unavailable: countUnavailable };
}, [selectedFlockDestinationRawData]);
const totalEnteredChickenForTransfer =
formik.values.flockSourceKandangs.reduce(
(acc, item) => acc + Number(item.quantity),
0
);
const totalTransferedChicken = formik.values.flockDestinationKandangs.reduce(
(acc, item) => acc + Number(item.quantity),
0
);
const totalAvailableChickenForTransfer =
totalEnteredChickenForTransfer - totalTransferedChicken;
const isNextButtonDisabled = useMemo(() => {
if (step === 1) {
return Boolean(
!formik.values.transfer_date ||
!formik.values.flockSource ||
!formik.values.flockDestination
);
}
if (step === 2) {
return Boolean(
!formik.values.flockSourceKandangs.length ||
!formik.values.flockDestinationKandangs.length
);
}
return true;
}, [step, formik.values]);
const nextButtonHandler = () => {
setStep(step + 1);
};
const deleteEnteredKandangHandler = () => {
formik.setFieldValue('flockSourceKandangs', []);
formik.setFieldValue('flockDestinationKandangs', []);
formik.setFieldValue('totalQuantity', '');
formik.setFieldValue('maxTotalQuantity', '');
formik.setFieldValue('reason', '');
formik.setFieldTouched('reason', false);
setStep(2);
};
const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('flockSource', val);
formik.setFieldValue('flockSourceKandangs', []);
};
const flockDestinationChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('flockDestination', val);
formik.setFieldValue('flockDestinationKandangs', []);
};
useEffect(() => {
if (modalAction === 'add' || modalAction === 'edit') {
formModal.openModal();
}
}, [modalAction]);
useEffect(() => {
const getFilledInitialValues = async () => {
if (transferToLayingId && isResponseSuccess(transferToLaying)) {
const filledInitialValues =
await getFilledTransferToLayingFormInitialValues(
transferToLaying.data
);
formik.setValues(filledInitialValues);
setStep(3);
}
};
const getFlockSourceData = async () => {
if (transferToLayingId && isResponseSuccess(transferToLaying)) {
const singleFlockSourceRawData = await ProjectFlockApi.getSingle(
transferToLaying.data.from_project_flock.id
);
if (isResponseSuccess(singleFlockSourceRawData)) {
setSelectedFlockSourceRawData(singleFlockSourceRawData.data);
}
}
};
getFlockSourceData();
getFilledInitialValues();
}, [transferToLayingId, transferToLaying]);
useEffect(() => {
if (isResponseSuccess(flockSourceRawData)) {
const selectedFlockSourceRawData = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value
);
setSelectedFlockSourceRawData(selectedFlockSourceRawData);
}
}, [flockSourceRawData]);
useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken);
formik.setFieldValue('maxTotalQuantity', totalTransferedChicken);
}, [totalTransferedChicken]);
return (
<>
<Modal
ref={formModal.ref}
position='end'
className={{
modalBox: 'w-full sm:w-fit p-3 rounded-xl bg-transparent shadow-none',
}}
>
<form
onSubmit={handleFormSubmit}
className='w-full min-h-full flex flex-col sm:flex-row items-stretch bg-base-100 rounded-xl overflow-y-auto'
>
{/* 1st Section */}
<div className='w-full sm:w-[446px]'>
<div className='w-full p-4 flex flex-row items-stretch gap-3 border-b border-base-content/10'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => closeModalHandler()}
className='p-0 text-black hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
<div className='w-px border-none bg-base-content/10' />
<h4 className='text-sm font-medium text-base-content/50'>
Add Transfer to Laying
</h4>
</div>
<div className='w-full p-4 flex flex-col'>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Informasi Umum
</h4>
<DateInput
name='transfer_date'
label='Tanggal'
placeholder='Tanggal'
value={formik.values.transfer_date ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transfer_date &&
Boolean(formik.errors.transfer_date)
}
errorMessage={formik.errors.transfer_date}
disabled={step > 2}
/>
<SelectInputRadio
label='Flock Asal'
placeholder='Pilih Flock Asal'
value={formik.values.flockSource}
onChange={flockSourceChangeHandler}
options={flockSourceOptions}
isLoading={isLoadingFlockSourceOptions}
onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isDisabled={step > 2}
/>
<SelectInputRadio
label='Flock Tujuan'
placeholder='Pilih Flock Tujuan'
value={formik.values.flockDestination}
onChange={flockDestinationChangeHandler}
options={flockDestinationOptions}
isLoading={isLoadingFlockDestinationOptions}
onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isDisabled={step > 2}
/>
</div>
{step >= 2 && (
<>
<div className='w-full p-4 flex flex-col gap-3 border-y border-base-content/10'>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Pilih Kandang Asal
</h4>
<div className='w-fit flex flex-row items-stretch gap-3'>
<StatusBadge
color='info'
text={`Tersedia (${mappedFlockSourceKandangsAvailabilityInfo.available})`}
className={{ badge: 'text-nowrap' }}
/>
<div className='w-px border-none bg-base-content/10' />
<StatusBadge
color='neutral'
text={`Tidak Tersedia (${mappedFlockSourceKandangsAvailabilityInfo.unavailable})`}
className={{ badge: 'text-nowrap' }}
/>
</div>
<div className='w-full rounded-xl border border-base-content/10'>
{mappedFlockSourceKandangsAvailability.map(
(item, itemIdx) => {
const isAvailable = item.available_qty > 0;
const isChecked =
formik.values.flockSourceKandangs.some(
(k) =>
k.kandang.value === item.project_flock_kandang_id
);
const flockSourceKandangCheckboxChangeHandler: FormEventHandler<
HTMLInputElement
> = (e) => {
const checked = (e.target as HTMLInputElement)
.checked;
if (checked) {
formik.setFieldValue('flockSourceKandangs', [
...formik.values.flockSourceKandangs,
{
kandang: {
value: item.project_flock_kandang_id,
label: item.kandang_name,
},
quantity: '',
maxQuantity: item.available_qty,
},
]);
} else {
formik.setFieldValue(
'flockSourceKandangs',
formik.values.flockSourceKandangs.filter(
(k) =>
k.kandang.value !==
item.project_flock_kandang_id
)
);
}
};
return (
<div
key={itemIdx}
className='w-full p-3 flex flex-row items-center justify-between'
>
<div className='flex flex-row items-center gap-3'>
<CheckboxInput
name={`flockSourceKandang.${itemIdx}.value`}
value={item.project_flock_kandang_id}
checked={isChecked}
onChange={
flockSourceKandangCheckboxChangeHandler
}
size='md'
disabled={!isAvailable}
classNames={{
checkbox: cn({
'bg-base-200 border border-base-content/10 opacity-100':
!isAvailable,
}),
}}
/>
<label
htmlFor={`flockSourceKandang.${itemIdx}.value`}
className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable,
'cursor-not-allowed': !isAvailable,
})}
>
{item.kandang_name}{' '}
<span className='text-base-content/20'>{`(Max: ${item.available_qty})`}</span>
</label>
</div>
<StatusBadge
color={isAvailable ? 'info' : 'neutral'}
text={isAvailable ? 'Tersedia' : 'Tidak Tersedia'}
className={{ badge: 'w-fit' }}
/>
</div>
);
}
)}
</div>
</div>
<div className='w-full p-4 flex flex-col gap-3'>
<div className='flex flex-row items-center justify-between'>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Pilih Kandang Tujuan
</h4>
{formik.touched.flockDestinationKandangs &&
formik.errors.flockDestinationKandangs &&
typeof formik.errors.flockDestinationKandangs ===
'string' && (
<span className='text-xs text-error'>
{formik.errors.flockDestinationKandangs}
</span>
)}
</div>
<div className='w-fit flex flex-row items-stretch gap-3'>
<StatusBadge
color='info'
text={`Tersedia (${mappedFlockDestinationKandangsAvailabilityInfo.available})`}
className={{ badge: 'text-nowrap' }}
/>
<div className='w-px border-none bg-base-content/10' />
<StatusBadge
color='neutral'
text={`Tidak Tersedia (${mappedFlockDestinationKandangsAvailabilityInfo.unavailable})`}
className={{ badge: 'text-nowrap' }}
/>
</div>
<div className='w-full rounded-xl border border-base-content/10'>
{selectedFlockDestinationRawData?.kandangs.map(
(item, itemIdx) => {
// TODO: change this to real available quota later
const isAvailable = item.capacity > 0;
const isChecked =
formik.values.flockDestinationKandangs.some(
(k) =>
k.kandang.value === item.project_flock_kandang_id
);
const flockDestinationKandangCheckboxChangeHandler: FormEventHandler<
HTMLInputElement
> = (e) => {
const checked = (e.target as HTMLInputElement)
.checked;
if (checked) {
formik.setFieldValue('flockDestinationKandangs', [
...formik.values.flockDestinationKandangs,
{
kandang: {
value: item.project_flock_kandang_id,
label: item.name,
},
quantity: '',
// TODO: change this to real available quota later
maxQuantity: item.capacity,
},
]);
} else {
formik.setFieldValue(
'flockDestinationKandangs',
formik.values.flockDestinationKandangs.filter(
(k) =>
k.kandang.value !==
item.project_flock_kandang_id
)
);
}
};
return (
<div
key={itemIdx}
className='w-full p-3 flex flex-row items-center justify-between'
>
<div className='flex flex-row items-center gap-3'>
<CheckboxInput
name={`flockDestinationKandang.${itemIdx}.value`}
value={item.project_flock_kandang_id}
checked={isChecked}
onChange={
flockDestinationKandangCheckboxChangeHandler
}
size='md'
disabled={!isAvailable}
classNames={{
checkbox: cn({
'bg-base-200 border border-base-content/10 opacity-100':
!isAvailable,
}),
}}
/>
<label
htmlFor={`flockDestinationKandang.${itemIdx}.value`}
className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable,
'cursor-not-allowed': !isAvailable,
})}
>
{item.name}{' '}
{/* TODO: change this to real available quota later */}
<span className='text-base-content/20'>{`(Max: ${item.capacity})`}</span>
</label>
</div>
<StatusBadge
color={isAvailable ? 'info' : 'neutral'}
text={isAvailable ? 'Tersedia' : 'Tidak Tersedia'}
className={{ badge: 'w-fit' }}
/>
</div>
);
}
)}
</div>
</div>
</>
)}
<div className='w-full p-4 border-t border-base-content/10'>
{step < 3 && (
<Button
type='button'
onClick={nextButtonHandler}
disabled={isNextButtonDisabled}
className='w-full p-3 rounded-lg text-sm text-base-100'
>
Next
</Button>
)}
</div>
</div>
{/* 2nd Section */}
{step === 3 && (
<div className='w-full sm:w-[446px] border-l border-base-content/10'>
<div className='w-full p-4 flex flex-row items-center justify-between gap-3 border-b border-base-content/10'>
<h4 className='text-sm font-medium text-base-content/50'>
Tambah Kuantitas
</h4>
<Button
type='button'
variant='ghost'
color='none'
onClick={deleteEnteredKandangHandler}
className='p-0 text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
</div>
<div className='w-full p-4 flex flex-col'>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Informasi Kandang
</h4>
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal
</span>
{formik.values.flockSourceKandangs.length === 0 && (
<span className='text-sm text-base-content/50 italic'>
Belum ada kandang asal yang dipilih
</span>
)}
{formik.values.flockSourceKandangs.length > 0 && (
<div className='flex flex-col gap-3'>
{formik.values.flockSourceKandangs.map((item, index) => {
const isInvalid =
item.quantity === ''
? false
: Boolean(
getIn(
formik.errors,
`flockSourceKandangs[${index}].quantity`
)
);
const errorMessage = getIn(
formik.errors,
`flockSourceKandangs[${index}].quantity`
);
return (
<NumberInput
key={`flockSourceKandangs-${item.kandang.value}-${index}`}
name={`flockSourceKandangs.${index}.quantity`}
placeholder='Masukkan Kuantitas'
value={item.quantity}
onChange={formik.handleChange}
isError={isInvalid}
errorMessage={errorMessage}
inputPrefix={
<div className='w-full h-full py-1 flex flex-row items-stretch justify-between gap-5'>
<span
title={item.kandang.label}
className='text-sm text-base-content self-center text-nowrap truncate'
>
{item.kandang.label}
</span>
<div className='w-px bg-base-content/10' />
</div>
}
className={{
inputPrefix:
'py-0 px-0 pl-3 text-base-content/50 bg-transparent border-r-0',
inputPrefixSuffixWrapper: 'grid grid-cols-2',
inputWrapper: 'border-l-0 pl-5',
}}
/>
);
})}
</div>
)}
</div>
{/* Destination Kandang */}
<div className='mt-3 flex flex-col'>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>Kandang Tujuan</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockDestinationKandangs.length === 0 && (
<span className='text-sm text-base-content/50 italic'>
Belum ada kandang tujuan yang dipilih
</span>
)}
{formik.values.flockDestinationKandangs.length > 0 && (
<div className='flex flex-col gap-3'>
{formik.values.flockDestinationKandangs.map(
(item, index) => {
const isInvalid =
item.quantity === ''
? false
: Boolean(
getIn(
formik.errors,
`flockDestinationKandangs[${index}].quantity`
)
);
const errorMessage = getIn(
formik.errors,
`flockDestinationKandangs[${index}].quantity`
);
return (
<NumberInput
key={`flockDestinationKandangs-${item.kandang.value}-${index}`}
name={`flockDestinationKandangs.${index}.quantity`}
placeholder='Masukkan Kuantitas'
value={item.quantity}
onChange={formik.handleChange}
isError={isInvalid}
errorMessage={errorMessage}
inputPrefix={
<div className='w-full h-full py-1 flex flex-row items-stretch justify-between gap-5'>
<span
title={item.kandang.label}
className='text-sm text-base-content self-center text-nowrap truncate'
>
{item.kandang.label}
</span>
<div className='w-px bg-base-content/10' />
</div>
}
className={{
inputPrefix:
'py-0 px-0 pl-3 text-base-content/50 bg-transparent border-r-0',
inputPrefixSuffixWrapper: 'grid grid-cols-2',
inputWrapper: 'border-l-0 pl-5',
}}
/>
);
}
)}
</div>
)}
</div>
</div>
<div className='w-full p-4 flex flex-col border-y border-base-content/10'>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Informasi Umum
</h4>
<NumberInput
name='totalQuantity'
label='Jumlah Transfer'
placeholder='Total Kuantitas Transfer'
value={formik.values.totalQuantity}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={totalAvailableChickenForTransfer < 0}
errorMessage={
totalAvailableChickenForTransfer < 0
? 'Jumlah transfer melebihi ketersediaan'
: ''
}
disabled
/>
<TextArea
name='reason'
label='Catatan'
placeholder='Alasan Transfer'
rows={4}
value={formik.values.reason}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
Boolean(formik.touched.reason) &&
Boolean(formik.errors.reason)
}
errorMessage={formik.errors.reason}
/>
</div>
<div className='w-full p-4 self-end flex flex-col gap-3'>
{formErrorMessage && (
<div role='alert' className='alert alert-error w-full'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<Button
type='submit'
disabled={
formik.isSubmitting || totalAvailableChickenForTransfer < 0
}
isLoading={formik.isSubmitting}
className='w-full p-3 rounded-lg text-sm text-base-100'
>
Submit
</Button>
</div>
</div>
)}
</form>
</Modal>
</>
);
};
export default TransferToLayingFormModal;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
CellContext, CellContext,
@@ -12,30 +12,28 @@ import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import DateInput from '@/components/input/DateInput';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import Badge from '@/components/Badge';
import PopoverContent from '@/components/popover/PopoverContent';
import Dropdown from '@/components/Dropdown';
import StatusBadge from '@/components/helper/StatusBadge';
import TransferToLayingFilterModal from '@/components/pages/production/transfer-to-laying/TransferToLayingFilterModal';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; import {
TransferToLaying,
TransferToLayingFilter,
} from '@/types/api/production/transfer-to-laying';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Flock } from '@/types/api/master-data/flock';
import { ProjectFlockApi } from '@/services/api/production';
import Badge from '@/components/Badge';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import PopoverContent from '@/components/popover/PopoverContent';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
props, props,
@@ -56,8 +54,8 @@ const RowOptionsMenu = ({
const showDeleteButton = showEditButton; const showDeleteButton = showEditButton;
const showApproveButton = showEditButton; // const showApproveButton = showEditButton;
const showRejectButton = showEditButton; // const showRejectButton = showEditButton;
const popoverId = `transferToLaying#${props.row.original.id}`; const popoverId = `transferToLaying#${props.row.original.id}`;
const popoverAnchorName = `--anchor-transferToLaying#${props.row.original.id}`; const popoverAnchorName = `--anchor-transferToLaying#${props.row.original.id}`;
@@ -78,7 +76,7 @@ const RowOptionsMenu = ({
id={popoverId} id={popoverId}
anchorName={popoverAnchorName} anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'} position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='rounded-xl border border-base-content/5 shadow-sm' className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
> >
<div className='flex flex-col bg-base-100 rounded-xl'> <div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.production.transfer_to_laying.detail'> <RequirePermission permissions='lti.production.transfer_to_laying.detail'>
@@ -96,7 +94,7 @@ const RowOptionsMenu = ({
{showEditButton && ( {showEditButton && (
<RequirePermission permissions='lti.production.transfer_to_laying.update'> <RequirePermission permissions='lti.production.transfer_to_laying.update'>
<Button <Button
href={`/production/transfer-to-laying/detail/edit/?transferToLayingId=${props.row.original.id}`} href={`/production/transfer-to-laying/?action=edit&id=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='none' color='none'
className='p-3 justify-start text-sm font-semibold w-full' className='p-3 justify-start text-sm font-semibold w-full'
@@ -107,34 +105,6 @@ const RowOptionsMenu = ({
</RequirePermission> </RequirePermission>
)} )}
{showApproveButton && (
<RequirePermission permissions='lti.production.transfer_to_laying.approve'>
<Button
variant='ghost'
color='success'
onClick={approveClickHandler}
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon icon='heroicons:check' width={20} height={20} />
Approve
</Button>
</RequirePermission>
)}
{showRejectButton && (
<RequirePermission permissions='lti.production.transfer_to_laying.approve'>
<Button
variant='ghost'
color='error'
onClick={rejectClickHandler}
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
Reject
</Button>
</RequirePermission>
)}
{showDeleteButton && ( {showDeleteButton && (
<RequirePermission permissions='lti.production.transfer_to_laying.delete'> <RequirePermission permissions='lti.production.transfer_to_laying.delete'>
<hr className='mx-3 border-base-content/10 h-px' /> <hr className='mx-3 border-base-content/10 h-px' />
@@ -165,18 +135,22 @@ const TransferToLayingsTable = () => {
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
transferDate: '', startDate: '',
endDate: '',
flockSource: '', flockSource: '',
flockDestination: '', flockDestination: '',
status: '',
filter_by: '', filter_by: '',
sort_by: '', sort_by: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
transferDate: 'transfer_date', startDate: 'start_date',
endDate: 'end_date',
flockSource: 'flock_source', flockSource: 'flock_source',
flockDestination: 'flock_destination', flockDestination: 'flock_destination',
status: 'status',
filter_by: 'filter_by', filter_by: 'filter_by',
sort_by: 'sort_by', sort_by: 'sort_by',
}, },
@@ -191,34 +165,36 @@ const TransferToLayingsTable = () => {
TransferToLayingApi.getAllFetcher TransferToLayingApi.getAllFetcher
); );
const filterCount = useMemo(() => {
let count = 0;
if (tableFilterState.startDate && tableFilterState.endDate) {
count += 1;
}
if (tableFilterState.flockSource.length > 0) {
count += 1;
}
if (tableFilterState.flockDestination.length > 0) {
count += 1;
}
if (tableFilterState.status.length > 0) {
count += 1;
}
return count;
}, [tableFilterState]);
const isFilterActive = filterCount > 0;
// Modal hooks // Modal hooks
const filterModal = useModal();
const deleteModal = useModal(); const deleteModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
// Flocks data
const {
setInputValue: setFlockSourceInputValue,
options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<Flock>(ProjectFlockApi.basePath, 'id', 'flock_name');
const {
setInputValue: setFlockDestinationInputValue,
options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<Flock>(ProjectFlockApi.basePath, 'id', 'flock_name');
// Flocks value
const [selectedFlockSource, setSelectedFlockSource] =
useState<OptionType | null>(null);
const [selectedFlockDestination, setSelectedFlockDestination] =
useState<OptionType | null>(null);
const [selectedTransferToLaying, setSelectedTransferToLaying] = useState< const [selectedTransferToLaying, setSelectedTransferToLaying] = useState<
TransferToLaying | undefined TransferToLaying | undefined
>(undefined); >(undefined);
@@ -315,18 +291,7 @@ const TransferToLayingsTable = () => {
latestApprovalStepName = 'Ditolak'; latestApprovalStepName = 'Ditolak';
} }
return ( return <StatusBadge color={badgeColor} text={latestApprovalStepName} />;
<Badge
variant='soft'
className={{
badge: 'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={badgeColor}
>
<Icon icon='mdi:circle' width={12} height={12} color={badgeColor} />
{latestApprovalStepName}
</Badge>
);
}, },
}, },
{ {
@@ -337,7 +302,7 @@ const TransferToLayingsTable = () => {
const currentRowRelativeIndex = const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1; currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const approveClickHandler = () => { const approveClickHandler = () => {
setSelectedTransferToLaying(props.row.original); setSelectedTransferToLaying(props.row.original);
@@ -472,38 +437,25 @@ const TransferToLayingsTable = () => {
setIsRejectLoading(false); setIsRejectLoading(false);
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const filterSubmitHandler = (values: TransferToLayingFilter) => {
updateFilter('search', e.target.value); updateFilter('startDate', values.startDate);
updateFilter('endDate', values.endDate);
updateFilter('flockSource', values.flockSource.join(','));
updateFilter('flockDestination', values.flockDestination.join(','));
updateFilter('status', values.status.join(','));
}; };
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const filterResetHandler = () => {
const newVal = val as OptionType; updateFilter('startDate', '');
updateFilter('endDate', '');
setPageSize(newVal.value as number); updateFilter('flockSource', '');
updateFilter('flockDestination', '');
updateFilter('status', '');
}; };
const transferDateChangeHandler: ChangeEventHandler<HTMLInputElement> = ( // TODO: add export to excel functionality
e const exportToExcelHandler = () => {
) => { toast.error('Not implemented yet');
updateFilter('transferDate', e.target.value);
};
const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedFlockSource(val as OptionType);
updateFilter(
'flockSource',
val ? ((val as OptionType).value as string) : ''
);
};
const flockDestinationChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedFlockDestination(val as OptionType);
updateFilter(
'flockDestination',
val ? ((val as OptionType).value as string) : ''
);
}; };
useEffect(() => { useEffect(() => {
@@ -518,111 +470,137 @@ const TransferToLayingsTable = () => {
return ( return (
<> <>
<div className='w-full p-0'> <div className='@container w-full'>
<div className='flex flex-col gap-2 mb-4'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full flex flex-col xl:flex-row justify-between items-end xl:items-center gap-2'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'> <RequirePermission permissions='lti.production.transfer_to_laying.create'>
<RequirePermission permissions='lti.production.transfer_to_laying.create'> <Button
<Button href={{
href='/production/transfer-to-laying/add' pathname: '/production/transfer-to-laying',
variant='outline' query: {
color='primary' action: 'add',
className='w-full sm:w-fit' },
> }}
<Icon icon='ic:round-plus' width={24} height={24} /> color='primary'
Tambah className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
</Button> >
</RequirePermission> <Icon icon='heroicons:plus' width={20} height={20} />
Add Transfer to Laying
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && ( {selectedRowIds.length > 0 && (
<> <>
<RequirePermission permissions='lti.production.transfer_to_laying.approve'> <hr className='w-px h-full border-none bg-base-content/10 hidden @sm:block' />
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check'
width={24}
height={24}
/>
Approve
</Button>
</RequirePermission>
<RequirePermission permissions='lti.production.transfer_to_laying.approve'> <RequirePermission permissions='lti.production.transfer_to_laying.approve'>
<Button <Button
variant='outline' variant='outline'
color='error' color='none'
onClick={bulkRejectClickHandler} onClick={bulkRejectClickHandler}
disabled={selectedRowIds.length === 0} disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit' className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
> >
<Icon <Icon
icon='material-symbols:close' icon='heroicons:x-mark'
width={24} width={20}
height={24} height={20}
/> className='text-error'
Reject />
</Button> Reject
</RequirePermission> </Button>
</> </RequirePermission>
)}
</div>
<DebouncedTextInput <RequirePermission permissions='lti.production.transfer_to_laying.approve'>
name='search' <Button
placeholder='Cari TransferToLaying' variant='outline'
value={tableFilterState.search} color='none'
onChange={searchChangeHandler} onClick={bulkApproveClickHandler}
className={{ disabled={selectedRowIds.length === 0}
wrapper: 'sm:max-w-3xs', className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
}} >
/> <Icon
icon='heroicons:check'
width={20}
height={20}
className='text-success'
/>
Approve
</Button>
</RequirePermission>
</>
)}
</div> </div>
<div className='grid grid-cols-12 justify-end gap-4'> <div className='flex flex-row justify-center items-center gap-3'>
<DateInput <Button
name='transfer_date' variant='outline'
label='Tanggal Transfer' color='none'
placeholder='Tanggal Transfer' onClick={filterModal.openModal}
value={tableFilterState.transferDate} className={cn(
onChange={transferDateChangeHandler} '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',
className={{ {
wrapper: 'col-span-12 sm:col-span-3', 'border-primary-gradient text-primary': isFilterActive,
}} }
/> )}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{isFilterActive && (
<Badge
className={{
badge:
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
}}
>
{filterCount}
</Badge>
)}
</Button>
<SelectInput <Dropdown
label='Flock Asal' align='end'
options={flockSourceOptions} direction='bottom'
isLoading={isLoadingFlockSourceOptions}
value={selectedFlockSource}
onChange={flockSourceChangeHandler}
onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-3', content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}} }}
/> trigger={
<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='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<SelectInput <span>Export</span>
label='Flock Tujuan'
options={flockDestinationOptions} <div className='w-px self-stretch bg-base-content/10' />
isLoading={isLoadingFlockDestinationOptions}
value={selectedFlockDestination} <Icon
onChange={flockDestinationChangeHandler} icon='heroicons:chevron-down'
onInputChange={setFlockDestinationInputValue} width={14}
onMenuScrollToBottom={loadMoreFlockDestination} height={14}
isClearable />
className={{ </div>
wrapper: 'col-span-12 sm:col-span-3', </Button>
}} }
/> >
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
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>
</Dropdown>
</div> </div>
</div> </div>
@@ -652,7 +630,7 @@ const TransferToLayingsTable = () => {
enableRowSelection={tableEnableRowSelectionHandler} enableRowSelection={tableEnableRowSelectionHandler}
withCheckbox withCheckbox
className={{ className={{
containerClassName: cn({ containerClassName: cn('p-3', {
'w-full mb-20': 'w-full mb-20':
isResponseSuccess(transferToLayings) && isResponseSuccess(transferToLayings) &&
transferToLayings?.data?.length === 0, transferToLayings?.data?.length === 0,
@@ -662,15 +640,23 @@ const TransferToLayingsTable = () => {
/> />
</div> </div>
<TransferToLayingFilterModal
ref={filterModal.ref}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
iconPosition='left'
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data transfer ke laying ini?`} text='Delete This Data?'
subtitleText='Are you sure you want to delete this data? '
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Cancel',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Delete',
color: 'error', color: 'error',
isLoading: isDeleteLoading, isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
@@ -680,12 +666,14 @@ const TransferToLayingsTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' type='success'
text={`Apakah anda yakin ingin approve data transfer ke laying ini (${selectedRowIds.length} data)?`} iconPosition='left'
text='Approve This Submission?'
subtitleText='Are you sure you want to approve this submission?'
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Cancel',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Approve',
color: 'success', color: 'success',
isLoading: isApproveLoading, isLoading: isApproveLoading,
onClick: confirmationModalApproveClickHandler, onClick: confirmationModalApproveClickHandler,
@@ -695,12 +683,14 @@ const TransferToLayingsTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={rejectModal.ref} ref={rejectModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin reject data transfer ke laying ini (${selectedRowIds.length} data)?`} iconPosition='left'
text='Reject This Submission?'
subtitleText='Are you sure you want to reject this submission?'
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Cancel',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Reject',
color: 'error', color: 'error',
isLoading: isRejectLoading, isLoading: isRejectLoading,
onClick: confirmationModalRejectClickHandler, onClick: confirmationModalRejectClickHandler,
@@ -1,7 +1,10 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { formatDate } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
type TransferToLayingFormSchemaType = { type TransferToLayingFormSchemaType = {
transfer_date?: string; transfer_date?: string;
@@ -14,7 +17,7 @@ type TransferToLayingFormSchemaType = {
label: string; label: string;
}; };
totalQuantity?: number; totalQuantity?: number | string;
maxTotalQuantity?: number; // original cap (hidden), helper maxTotalQuantity?: number; // original cap (hidden), helper
flockSourceKandangs: { flockSourceKandangs: {
@@ -53,15 +56,15 @@ export const TransferToLayingFormSchema: Yup.ObjectSchema<TransferToLayingFormSc
}).required('Flock tujuan wajib diisi!'), }).required('Flock tujuan wajib diisi!'),
totalQuantity: Yup.number() totalQuantity: Yup.number()
.min(1, 'Jumlah transfer minimal 1') .min(0, 'Jumlah transfer minimal 0')
.max( .max(
Yup.ref('maxTotalQuantity'), Yup.ref('maxTotalQuantity'),
({ max }) => `Kuantitas maksimal ${max}!` ({ max }) => `Kuantitas maksimal ${formatNumber(max)}!`
) )
.required('Jumlah transfer wajib diisi!'), .required('Jumlah transfer wajib diisi!'),
maxTotalQuantity: Yup.number() maxTotalQuantity: Yup.number()
.min(1, 'Jumlah transfer minimal 1') .min(0, 'Jumlah transfer minimal 0')
.required('Jumlah transfer wajib diisi!'), .required('Jumlah transfer wajib diisi!'),
flockSourceKandangs: Yup.array() flockSourceKandangs: Yup.array()
@@ -76,7 +79,7 @@ export const TransferToLayingFormSchema: Yup.ObjectSchema<TransferToLayingFormSc
.min(0, 'Kuantitas minimal 0!') .min(0, 'Kuantitas minimal 0!')
.max( .max(
Yup.ref('maxQuantity'), Yup.ref('maxQuantity'),
({ max }) => `Kuantitas maksimal ${max}!` ({ max }) => `Kuantitas maksimal ${formatNumber(max)}!`
) )
.required('Kuantitas wajib diisi!'), .required('Kuantitas wajib diisi!'),
@@ -98,7 +101,7 @@ export const TransferToLayingFormSchema: Yup.ObjectSchema<TransferToLayingFormSc
.min(0, 'Kuantitas minimal 0!') .min(0, 'Kuantitas minimal 0!')
.max( .max(
Yup.ref('maxQuantity'), Yup.ref('maxQuantity'),
({ max }) => `Kuantitas maksimal ${max}!` ({ max }) => `Kuantitas maksimal ${formatNumber(max)}!`
) )
.required('Kuantitas wajib diisi!'), .required('Kuantitas wajib diisi!'),
@@ -137,12 +140,12 @@ export const getTransferToLayingFormInitialValues = (
} }
: undefined, : undefined,
totalQuantity: totalQuantity:
initialValues?.usage_qty ?? initialValues?.pending_usage_qty ?? undefined, initialValues?.usage_qty ?? initialValues?.pending_usage_qty ?? '',
flockSourceKandangs: initialValues?.sources flockSourceKandangs: initialValues?.sources
? initialValues.sources.map((sourceKandang) => ({ ? initialValues.sources.map((sourceKandang) => ({
kandang: { kandang: {
value: sourceKandang.source_project_flock_kandang.kandang.id, value: sourceKandang.source_project_flock_kandang.id,
label: sourceKandang.source_project_flock_kandang.kandang.name, label: sourceKandang.source_project_flock_kandang.kandang.name,
}, },
quantity: sourceKandang.qty, quantity: sourceKandang.qty,
@@ -152,7 +155,7 @@ export const getTransferToLayingFormInitialValues = (
flockDestinationKandangs: initialValues?.targets flockDestinationKandangs: initialValues?.targets
? initialValues.targets.map((targetKandang) => ({ ? initialValues.targets.map((targetKandang) => ({
kandang: { kandang: {
value: targetKandang.target_project_flock_kandang.kandang.id, value: targetKandang.target_project_flock_kandang.id,
label: targetKandang.target_project_flock_kandang.kandang.name, label: targetKandang.target_project_flock_kandang.kandang.name,
}, },
quantity: targetKandang.qty, quantity: targetKandang.qty,
@@ -174,7 +177,7 @@ export const getFilledTransferToLayingFormInitialValues = async (
const formattedFlockSourceKandangs = initialValues?.sources const formattedFlockSourceKandangs = initialValues?.sources
? initialValues.sources.map((sourceKandang) => ({ ? initialValues.sources.map((sourceKandang) => ({
kandang: { kandang: {
value: sourceKandang.source_project_flock_kandang.kandang.id, value: sourceKandang.source_project_flock_kandang.id,
label: sourceKandang.source_project_flock_kandang.kandang.name, label: sourceKandang.source_project_flock_kandang.kandang.name,
}, },
quantity: sourceKandang.qty, quantity: sourceKandang.qty,
@@ -189,9 +192,35 @@ export const getFilledTransferToLayingFormInitialValues = async (
let maxTotalQuantity = 0; let maxTotalQuantity = 0;
formattedFlockSourceKandangs.forEach((item) => { formattedFlockSourceKandangs.forEach((item) => {
maxTotalQuantity += item.maxQuantity; maxTotalQuantity += item.quantity;
}); });
const flockDestination = await ProjectFlockApi.getSingle(
initialValues?.to_project_flock.id as number
);
const formattedFlockDestinationKandangs = initialValues?.targets
? initialValues.targets.map((targetKandang) => {
const kandang = isResponseSuccess(flockDestination)
? flockDestination?.data?.kandangs.find(
(kandang) =>
String(kandang.project_flock_kandang_id) ===
String(targetKandang.target_project_flock_kandang.id)
)
: undefined;
return {
kandang: {
value: targetKandang.target_project_flock_kandang.id,
label: targetKandang.target_project_flock_kandang.kandang.name,
},
quantity: targetKandang.qty,
maxQuantity: kandang?.capacity ?? 0,
};
})
: [];
return { return {
transfer_date: initialValues?.transfer_date transfer_date: initialValues?.transfer_date
? formatDate(initialValues.transfer_date, 'YYYY-MM-DD') ? formatDate(initialValues.transfer_date, 'YYYY-MM-DD')
@@ -214,21 +243,7 @@ export const getFilledTransferToLayingFormInitialValues = async (
flockSourceKandangs: formattedFlockSourceKandangs, flockSourceKandangs: formattedFlockSourceKandangs,
flockDestinationKandangs: initialValues?.targets flockDestinationKandangs: formattedFlockDestinationKandangs,
? initialValues.targets.map((targetKandang) => ({
kandang: {
value: targetKandang.target_project_flock_kandang.kandang.id,
label: targetKandang.target_project_flock_kandang.kandang.name,
},
quantity: targetKandang.qty,
// maxQuantity:
// targetKandang.target_project_flock_kandang.kandang.capacity,
// TODO: integrate this to real API kandang capacity
maxQuantity: Infinity,
}))
: [],
reason: initialValues?.notes ?? undefined, reason: initialValues?.notes ?? undefined,
}; };
+31
View File
@@ -2,6 +2,7 @@ import moment from 'moment';
import 'moment/locale/id'; import 'moment/locale/id';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import clsx, { ClassValue } from 'clsx'; import clsx, { ClassValue } from 'clsx';
import { SidebarMenuItem } from '@/components/molecules/SidebarMenu';
// set locale globally // set locale globally
moment.locale('id'); moment.locale('id');
@@ -147,3 +148,33 @@ export const isPathActive = (pathname: string, link?: string) => {
return pathname.startsWith(link) && isActiveLinkValid; return pathname.startsWith(link) && isActiveLinkValid;
}; };
export function findMenuPath(
menus: readonly SidebarMenuItem[],
pathname: string,
parents: SidebarMenuItem[] = []
): SidebarMenuItem[] | null {
for (const menu of menus) {
const currentPath = [...parents, menu];
// Exact match
if (menu.link === pathname) {
return currentPath;
}
// Prefix match (useful for pages like /add, /edit, etc.)
if (pathname.startsWith(menu.link + '/')) {
if (!menu.submenu) {
return currentPath;
}
}
// Search children
if (menu.submenu) {
const found = findMenuPath(menu.submenu, pathname, currentPath);
if (found) return found;
}
}
return null;
}
+19
View File
@@ -0,0 +1,19 @@
@layer utilities {
.border-primary-gradient {
border: 1px solid transparent;
border-radius: 12px;
background:
linear-gradient(
180deg,
var(--color-primary) -250%,
var(--color-base-100) 100%
),
linear-gradient(
to bottom,
var(--color-primary),
color-mix(in srgb, var(--color-primary) 40%, transparent)
);
background-clip: padding-box, border-box;
background-origin: padding-box, border-box;
}
}
+8
View File
@@ -89,3 +89,11 @@ export type CreateTransferToLayingPayload = {
}; };
export type UpdateTransferToLayingPayload = CreateTransferToLayingPayload; export type UpdateTransferToLayingPayload = CreateTransferToLayingPayload;
export type TransferToLayingFilter = {
flockSource: number[];
flockDestination: number[];
status: number[];
startDate: string;
endDate: string;
};