Merge branch 'feat/FE/US-280/project-flock-budget' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense

This commit is contained in:
rstubryan
2025-12-08 16:28:09 +07:00
55 changed files with 4116 additions and 1106 deletions
+33 -20
View File
@@ -7,26 +7,39 @@
default: false;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424);
--color-base-300: oklch(92% 0.003 48.717);
--color-base-content: oklch(22.389% 0.031 278.072);
--color-primary: oklch(60% 0.126 221.723);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(52% 0.105 223.128);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(45% 0.085 224.283);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(39% 0.07 227.392);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(62% 0.194 149.214);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(0% 0 0);
--color-error: oklch(57% 0.245 27.325);
--color-error-content: oklch(100% 0 0);
/* Primary Colors */
--color-primary: oklch(39.4% 0.177 301.9);
--color-primary-content: oklch(87.5% 0.038 274.5);
/* Secondary Colors */
--color-secondary: oklch(60.1% 0.258 335.7);
--color-secondary-content: oklch(99.4% 0.007 337.8);
/* Accent Colors */
--color-accent: oklch(76.2% 0.155 170.8);
--color-accent-content: oklch(7.2% 0.007 167.6);
/* Neutral Colors */
--color-neutral: oklch(22.4% 0.032 258.8);
--color-neutral-content: oklch(87.7% 0.016 257);
/* Base Colors */
--color-base-100: oklch(100% 0 0); /* #ffffff */
--color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */
--color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */
--color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */
/* Status/Utility Colors */
--color-info: oklch(67.4% 0.176 238.9);
--color-info-content: oklch(0% 0 0); /* #000000 */
--color-success: oklch(62.3% 0.147 149);
--color-success-content: oklch(100% 0 0); /* #ffffff */
--color-warning: oklch(82.2% 0.165 91.9);
--color-warning-content: oklch(0% 0 0); /* #000000 */
--color-error: oklch(61.8% 0.203 27.8);
--color-error-content: oklch(100% 0 0); /* #fffffff */
--radius-selector: 0rem;
--radius-field: 0.25rem;
--radius-box: 0.25rem;
@@ -12,8 +12,6 @@ const DetailInventoryAdjustment = () => {
// Ambil data dari router state
useEffect(() => {
console.log('Router State');
console.log(window.history.state);
const state = window.history.state?.usr as
| { inventoryAdjustment?: InventoryAdjustment }
| undefined;
@@ -26,9 +24,6 @@ const DetailInventoryAdjustment = () => {
const finalData = inventoryAdjustment;
console.log('Final Data');
console.log(finalData);
if (!finalData) {
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+50
View File
@@ -0,0 +1,50 @@
'use client';
import InventoryProductDetail from '@/components/pages/inventory/product/detail/InventoryProductDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const InventoryProductDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const inventoryProductId = searchParams.get('inventoryProductId');
const { data: inventoryProduct, isLoading: isLoadingInventoryProduct } =
useSWR(inventoryProductId, (id: number) =>
InventoryProductApi.getSingle(id)
);
if (!inventoryProductId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingInventoryProduct &&
(!inventoryProduct || isResponseError(inventoryProduct))
) {
router.replace('/404');
return;
}
return (
<div className='size-full'>
{isLoadingInventoryProduct && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingInventoryProduct && isResponseSuccess(inventoryProduct) && (
<InventoryProductDetail inventoryProduct={inventoryProduct.data} />
)}
</div>
);
};
export default InventoryProductDetailPage;
+11
View File
@@ -0,0 +1,11 @@
import InventoryProductTable from '@/components/pages/inventory/product/InventoryProductTable';
const InventoryProductPage = () => {
return (
<div className='size-full'>
<InventoryProductTable />
</div>
);
};
export default InventoryProductPage;
@@ -1,10 +1,18 @@
'use client';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import React, { useImperativeHandle } from 'react';
import toast from 'react-hot-toast';
const AddProjectFlock = () => {
// useImperativeHandle(ref, () => ({
// validate() {
// toast.success('Validating');
// return false;
// },
// }));
return (
<section className='w-full p-4 flex flex-row justify-center'>
<section className='w-full flex flex-row justify-center'>
<ProjectFlockForm formType='add' />
</section>
);
@@ -44,7 +44,7 @@ export default function AddChickinKandang() {
return (
<>
<section className='w-full p-4'>
<section className='size-full'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading &&
isResponseSuccess(projectFlockKandang) &&
@@ -10,7 +10,7 @@ const AddChickin = () => {
return (
<>
<section className='w-full p-4'>
<section className='w-full'>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section>
</>
@@ -2,7 +2,7 @@ import ChickinTable from '@/components/pages/production/chickin/ChickinTable';
const Chickin = () => {
return (
<section className='w-full p-4'>
<section className='w-full'>
<ChickinTable />
</section>
);
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,63 @@
'use client';
import ProjectFlockClosingForm from '@/components/pages/production/project-flock/closing/ProjectFlockClosingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const ProjectFlockClosingPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
const projectFlockKandangId = searchParams.get('projectFlockKandangId');
const { data: projectFlockKandang, isLoading: isLoadingProjectFlockKandang } =
useSWR(projectFlockKandangId, (id: number) =>
ProjectFlockKandangApi.getSingle(id)
);
const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR(
projectFlockId,
(id: number) => ProjectFlockApi.getSingle(id)
);
if (!projectFlockId || !projectFlockKandangId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock)) &&
!isLoadingProjectFlockKandang &&
(!projectFlockKandang || isResponseError(projectFlockKandang))
) {
router.replace('/404');
return;
}
return (
<div className='w-full h-full flex flex-col justify-center'>
{isLoadingProjectFlock ||
(isLoadingProjectFlockKandang && (
<span className='loading loading-spinner loading-xl' />
))}
{isResponseSuccess(projectFlock) &&
isResponseSuccess(projectFlockKandang) && (
<ProjectFlockClosingForm
projectFlock={projectFlock.data}
projectFlockKandang={projectFlockKandang.data}
/>
)}
</div>
);
};
export default ProjectFlockClosingPage;
@@ -37,7 +37,7 @@ const ProjectFlockEdit = () => {
}
return (
<div className='w-full p-4 flex flex-col justify-center'>
<div className='w-full flex flex-col justify-center'>
{isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' />
)}
@@ -1,12 +1,13 @@
'use client';
import ProjectFlockDetail from '@/components/pages/production/project-flock/detail/ProjectFlockDetail';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const ProjectFlockDetail = () => {
const ProjectFlockDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -37,19 +38,17 @@ const ProjectFlockDetail = () => {
}
return (
<div className='w-full p-4 flex flex-col justify-center'>
<div className='w-full h-full flex flex-col justify-center'>
{isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' />
)}
{isResponseSuccess(projectFlock) && (
<ProjectFlockForm
formType='detail'
initialValues={projectFlock.data}
refreshProjectFlocks={refreshProjectFlock}
/>
<ProjectFlockDetail projectFlock={projectFlock.data} />
)}
</div>
);
};
export default ProjectFlockDetail;
export default ProjectFlockDetailPage;
ProjectFlockDetail;
ProjectFlockDetail;
@@ -0,0 +1,59 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import Drawer from '@/components/Drawer';
import React, { ReactNode } from 'react';
import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable';
import { useUiStore } from '@/stores/ui/ui.store';
export default function ProjectFlockLayout({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const toggleValidate = useUiStore((s) => s.toggleValidate);
const isAdd = pathname.endsWith('/add');
const isEdit = pathname.includes('/detail/edit');
const isDetail = pathname.includes('/detail');
const isChickin = pathname.includes('/chickin/add/kandang');
const isClosing = pathname.includes('/closing');
const isOpen = isAdd || isEdit || isDetail || isChickin || isClosing;
const handleBackdropClick = () => {
const unsub = useUiStore.getState().subscribeIsValid((isValid) => {
if (isValid) {
unsub(); // berhenti listen
router.push('/production/project-flock');
}
});
toggleValidate();
};
return (
<>
{/* List page always rendered */}
<div className='min-h-sceen w-full relative'>
<ProjectFlockTable
refresh={() => !isOpen && router.push('/production/project-flock')}
/>
</div>
{/* Render Drawer only on /add */}
<Drawer
open={isOpen}
setOpen={(v) => {
if (!v) router.push('/production/project-flock');
}}
closeOnBackdropClick={isDetail ? true : false}
onBackdropClick={handleBackdropClick}
variant='right'
sidebarContent={isOpen && <div className=''>{children}</div>}
/>
</>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import ProjectFlockTable from '@/components/pages/production/project-flock/Proje
const ProjectFlock = () => {
return (
<section className='w-full p-4'>
<section className='size-full p-4'>
<ProjectFlockTable />
</section>
);
+101 -6
View File
@@ -10,28 +10,102 @@ interface DrawerProps {
open: boolean;
setOpen: (newOpenState: boolean) => void;
openOnLarge?: boolean;
variant?: 'sidebar' | 'left' | 'right';
zIndex?: string;
className?: DrawerClassName;
onBackdropClick?: () => void;
closeOnBackdropClick?: boolean;
}
type DrawerClassName = {
drawer?: string;
drawerContent?: string;
drawerSide?: string;
drawerOverlay?: string;
drawerSidebarContent?: string;
};
const Drawer = ({
children,
sidebarContent,
open,
setOpen,
openOnLarge,
variant = 'sidebar',
zIndex = '20',
className,
onBackdropClick,
closeOnBackdropClick = true,
}: DrawerProps) => {
const getDrawerClassNames = (): DrawerClassName => {
const baseClassNames = {
drawer: 'drawer',
drawerContent: 'drawer-content',
drawerSide: 'drawer-side',
drawerOverlay: 'drawer-overlay',
drawerSidebarContent: 'min-h-full bg-base-100',
};
if (variant === 'sidebar') {
return {
...baseClassNames,
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full max-w-[300px] lg:w-[300px]'
),
};
} else if (variant === 'right') {
return {
...baseClassNames,
drawer: cn(baseClassNames.drawer, 'drawer-end'),
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full min-w-120 sm:w-fit'
),
};
} else if (variant === 'left') {
return {
...baseClassNames,
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full min-w-120 sm:w-fit'
),
};
}
return baseClassNames; // Fallback for default or unknown variant
};
const varianClassName = getDrawerClassNames();
const toggleDrawer = () => {
setOpen(!open);
};
const closeDrawer = () => {
if (closeOnBackdropClick) {
setOpen(false);
}
onBackdropClick && onBackdropClick();
};
return (
<div
className={cn('drawer', {
className={cn(
'drawer',
{
'lg:drawer-open': openOnLarge,
})}
},
varianClassName?.drawer,
className?.drawer
)}
>
<input
type='checkbox'
@@ -40,16 +114,37 @@ const Drawer = ({
className='drawer-toggle'
/>
<div className='drawer-content'>{children}</div>
{/* Drawer Content */}
<div
className={cn(varianClassName?.drawerContent, className?.drawerContent)}
>
{children}
</div>
<div className='drawer-side border-r border-solid border-gray-200 z-20'>
{/* Drawer Side */}
<div
className={cn(
varianClassName?.drawerSide,
className?.drawerSide,
zIndex
)}
>
<label
aria-label='close sidebar'
className='drawer-overlay'
className={cn(
varianClassName?.drawerOverlay,
className?.drawerOverlay
)}
onClick={closeDrawer}
/>
<div className='min-h-full w-full max-w-[300px] lg:w-[300px] bg-base-100'>
{/* Sidebar Content */}
<div
className={cn(
varianClassName?.drawerSidebarContent,
className?.drawerContent
)}
>
{sidebarContent}
</div>
</div>
+141
View File
@@ -0,0 +1,141 @@
'use client';
import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
type FloatingActionsButtonProps = {
actions: {
action: 'DETAIL' | 'EDIT' | 'DELETE';
icon: string;
label?: string;
onClick?: () => void;
hidden?: boolean;
disabled?: boolean;
}[];
approvals: {
action: 'APPROVED' | 'REJECTED';
icon: string;
label?: string;
onClick?: () => void;
disabled?: boolean;
}[];
selectedRowIds: number[];
onClose: () => void;
};
const FloatingActionsButton = ({
actions,
approvals,
selectedRowIds,
onClose,
}: FloatingActionsButtonProps) => {
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB
const positionStyles =
selectedRowIds.length > 0 ? 'bottom-[10%]' : 'bottom-[-100%]';
// Helper untuk menentukan gaya warna tombol approval
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
if (action === 'APPROVED') return 'success';
if (action === 'REJECTED') return 'error';
return 'primary';
};
const getActionColor = (action: 'DETAIL' | 'EDIT' | 'DELETE') => {
if (action === 'DETAIL') return 'white';
if (action === 'EDIT') return 'warning';
if (action === 'DELETE') return 'error';
return 'primary';
};
return (
// Container utama FAB
<div
className={cn(
`absolute ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
'mx-auto w-full max-w-lg sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
'bg-slate-950 backdrop-blur-md'
)}
>
<div className='flex flex-col gap-3'>
{/* === BARIS ATAS: Status Seleksi dan Actions (Termasuk Close) === */}
<div className='flex justify-between items-center text-white'>
<h4 className='text-base font-semibold'>
{selectedRowIds.length} Selected
</h4>
<div className='flex flex-row gap-1 items-stretch'>
<div className='flex gap-4 items-center'>
{/* Render Aksi dari props.actions */}
{actions
.filter((action) => !action.hidden)
.map((action, index) => {
return (
<Button
key={index}
onClick={action.onClick}
className='text-white hover:text-gray-400 tooltip tooltip-bottom p-0'
variant='link'
disabled={action.disabled}
>
<Tooltip content={action.label || action.action}>
<Icon
icon={action.icon}
width={20}
height={20}
className={`text-${getActionColor(action.action)} font-thin`}
/>
</Tooltip>
</Button>
);
})}
<div className='border-[0.5px] border-white/30 h-full'></div>
{/* Tombol Close */}
<Button
onClick={onClose}
className='text-white hover:text-gray-400 p-0'
variant='link'
>
<Tooltip content='Close'>
<Icon icon='mdi:close' width={20} height={20} />
</Tooltip>
</Button>
</div>
</div>
</div>
{/* === BARIS BAWAH: Approval Buttons (Approve/Reject) === */}
<div className={`grid grid-cols-${approvals.length} gap-3`}>
{approvals.map((approval, index) => (
<Button
key={index}
onClick={approval.onClick}
className={cn(
'btn btn-lg w-full',
'bg-white/20 border-white/30',
'text-white/50 font-semibold flex items-center gap-2 rounded-lg transition-all duration-200',
approval.disabled
? 'cursor-not-allowed'
: 'hover:text-white/100 hover:bg-white/40 hover:border-white/50'
)}
disabled={approval.disabled}
>
<Icon
icon={approval.icon}
width={20}
height={20}
className={`text-${getApprovalColor(approval.action)}`}
/>
{approval.label || approval.action}
</Button>
))}
</div>
</div>
</div>
);
};
export default FloatingActionsButton;
@@ -0,0 +1,104 @@
'use client';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
export interface DrawerHeaderProps {
// Left side props
leftIcon?: string;
leftIconSize?: number;
leftIconHref?: string;
leftIconOnClick?: () => void;
leftIconClassName?: string;
// Subtitle/label props
subtitle?: string | ReactNode;
subtitleClassName?: string;
// Right side actions (children)
children?: ReactNode;
// Container props
className?: string;
showDivider?: boolean;
}
const DrawerHeader = ({
leftIcon = 'mdi:close',
leftIconSize = 24,
leftIconHref,
leftIconOnClick,
leftIconClassName,
subtitle,
subtitleClassName,
children,
className,
showDivider = true,
}: DrawerHeaderProps) => {
const renderLeftIcon = () => {
const iconElement = (
<Icon
icon={leftIcon}
width={leftIconSize}
height={leftIconSize}
className={cn('cursor-pointer', leftIconClassName)}
/>
);
if (leftIconHref) {
return (
<Link href={leftIconHref} className='hover:text-gray-400'>
{iconElement}
</Link>
);
}
if (leftIconOnClick) {
return (
<button
onClick={leftIconOnClick}
className='hover:text-gray-400 bg-transparent border-none p-0'
>
{iconElement}
</button>
);
}
return iconElement;
};
return (
<div
className={cn(
'flex flex-row justify-between items-center px-4 pt-4',
className
)}
>
{/* Left Side */}
<div className='flex flex-row h-full gap-2 items-center'>
{renderLeftIcon()}
{showDivider && subtitle && (
<div className='divider divider-horizontal p-0 m-0'></div>
)}
{subtitle && (
<div className={cn('text-sm text-neutral', subtitleClassName)}>
{subtitle}
</div>
)}
</div>
{/* Right Side Actions */}
{children && (
<div className='flex flex-row gap-3 justify-end items-center'>
{children}
</div>
)}
</div>
);
};
export default DrawerHeader;
+1 -1
View File
@@ -53,7 +53,7 @@ const CheckboxInput = ({
id={name}
name={name}
className={cn(
'checkbox cursor-pointer',
'checkbox rounded-md cursor-pointer',
{
'border-error': isError,
'border-success': isValid,
+2 -2
View File
@@ -7,11 +7,11 @@ import {
useState,
} from 'react';
import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import Button from '../Button';
import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
export interface DateInputProps {
label?: string;
+138 -39
View File
@@ -1,6 +1,11 @@
'use client';
import { ChangeEventHandler, ReactNode } from 'react';
import {
ChangeEventHandler,
ReactNode,
createContext,
useContext,
} from 'react';
import { cn } from '@/lib/helper';
export interface RadioOption {
@@ -8,37 +13,74 @@ export interface RadioOption {
value: string;
}
export interface RadioInputProps {
label?: string;
bottomLabel?: string;
// DaisyUI Radio Colors
export type RadioColor =
| 'neutral'
| 'primary'
| 'secondary'
| 'accent'
| 'success'
| 'warning'
| 'info'
| 'error';
// DaisyUI Radio Sizes
export type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// Context untuk RadioGroup
interface RadioGroupContextValue {
name: string;
value?: string;
options: RadioOption[];
variant?: string;
className?: {
wrapper?: string;
label?: string;
radioWrapper?: string;
radio?: string;
};
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
required?: boolean;
color?: RadioColor;
size?: RadioSize;
disabled?: boolean;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
}
const RadioInput = ({
const RadioGroupContext = createContext<RadioGroupContextValue | undefined>(
undefined
);
const useRadioGroup = () => {
const context = useContext(RadioGroupContext);
if (!context) {
throw new Error('RadioGroupItem must be used within RadioGroup');
}
return context;
};
// RadioGroup Component
export interface RadioGroupProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string;
options?: RadioOption[];
color?: RadioColor;
size?: RadioSize;
className?: {
wrapper?: string;
label?: string;
radioWrapper?: string;
};
isError?: boolean;
errorMessage?: string;
required?: boolean;
disabled?: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
children?: ReactNode;
}
export const RadioGroup = ({
label,
bottomLabel,
name,
value,
options,
variant = 'radio-primary',
color = 'primary',
size = 'md',
className,
isError,
errorMessage,
@@ -46,8 +88,20 @@ const RadioInput = ({
disabled = false,
onChange,
onBlur,
}: RadioInputProps) => {
children,
}: RadioGroupProps) => {
const contextValue: RadioGroupContextValue = {
name,
value,
color,
size,
disabled,
onChange,
onBlur,
};
return (
<RadioGroupContext.Provider value={contextValue}>
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
{/* Label atas */}
{label && (
@@ -74,27 +128,18 @@ const RadioInput = ({
className?.radioWrapper
)}
>
{options.map((option) => (
<label
{/* Jika options diberikan, render otomatis */}
{options &&
options.map((option) => (
<RadioGroupItem
key={option.value}
className={cn(
'flex flex-row items-center gap-2 cursor-pointer',
disabled && 'opacity-60 cursor-not-allowed'
)}
>
<input
type='radio'
name={name}
value={option.value}
checked={value === option.value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn('radio', variant, className?.radio)}
label={option.label}
/>
<span className='text-sm'>{option.label}</span>
</label>
))}
{/* Atau gunakan children untuk custom rendering */}
{children}
</div>
{/* Label bawah */}
@@ -107,7 +152,61 @@ const RadioInput = ({
<p className='text-sm text-error'>{errorMessage}</p>
)}
</div>
</RadioGroupContext.Provider>
);
};
export default RadioInput;
// RadioGroupItem Component
export interface RadioGroupItemProps {
value: string;
label?: string;
className?: string;
disabled?: boolean;
color?: RadioColor;
size?: RadioSize;
}
export const RadioGroupItem = ({
value,
label,
className,
disabled: itemDisabled,
color: itemColor,
size: itemSize,
}: RadioGroupItemProps) => {
const {
name,
value: groupValue,
color: groupColor,
size: groupSize,
disabled: groupDisabled,
onChange,
onBlur,
} = useRadioGroup();
const isDisabled = itemDisabled ?? groupDisabled;
const radioColor = itemColor ?? groupColor;
const radioSize = itemSize ?? groupSize;
return (
<label
className={cn(
'flex flex-row items-center gap-2 cursor-pointer',
isDisabled && 'opacity-60 cursor-not-allowed',
className
)}
>
<input
type='radio'
name={name}
value={value}
checked={groupValue === value}
onChange={onChange}
onBlur={onBlur}
disabled={isDisabled}
className={cn('radio', `radio-${radioColor}`, `radio-${radioSize}`)}
/>
{label && <span className='text-sm'>{label}</span>}
</label>
);
};
@@ -6,7 +6,7 @@ import Table from '@/components/Table';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { inventoryAdjustmentApi } from '@/services/api/inventory';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Icon } from '@iconify/react';
@@ -41,8 +41,8 @@ const InventoryAdjustmentTable = () => {
// Fetch Data
const { data: inventoryAdjustments, isLoading } = useSWR(
`${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
inventoryAdjustmentApi.getAllFetcher
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher
);
// State
@@ -1,7 +1,7 @@
'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { inventoryAdjustmentApi } from '@/services/api/inventory';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import {
CreateInventoryAdjustmentPayload,
InventoryAdjustment,
@@ -24,7 +24,7 @@ import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput';
import RadioInput from '@/components/input/RadioInput';
import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea';
interface InventoryAdjustmentFormProps {
@@ -52,7 +52,7 @@ const InventoryAdjustmentForm = ({
const createInventoryAdjustmentHandler = useCallback(
async (payload: CreateInventoryAdjustmentPayload) => {
const createInventoryAdjustmentRes =
await inventoryAdjustmentApi.create(payload);
await InventoryAdjustmentApi.create(payload);
if (isResponseError(createInventoryAdjustmentRes)) {
setInventoryAdjustmentFormErrorMessage(
@@ -347,7 +347,7 @@ const InventoryAdjustmentForm = ({
/>
{/* Radio Button Flag Stock */}
<RadioInput
<RadioGroup
name='transaction_type'
label='Tipe Transaksi'
options={[
@@ -367,7 +367,7 @@ const InventoryAdjustmentForm = ({
Boolean(formik.errors.transaction_type)
}
errorMessage={formik.errors.transaction_type as string}
variant='radio-primary'
color='primary'
required
bottomLabel={
formik.values.transaction_type == undefined
@@ -0,0 +1,233 @@
'use client';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product';
import { Icon } from '@iconify/react';
import {
CellContext,
ColumnDef,
Row,
SortingState,
} from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<InventoryProduct, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RowOptionsMenuWrapper>
);
const InventoryProductTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR(
`${InventoryProductApi.basePath}${getTableFilterQueryString()}`,
InventoryProductApi.getAllFetcher
);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
() => [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'product_price',
header: 'Harga Beli',
cell: (props) => {
return props.row.original.product_price
? formatCurrency(props.row.original.product_price)
: '-';
},
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
cell: (props) => {
return props.row.original.selling_price
? formatCurrency(props.row.original.selling_price)
: '-';
},
},
{
accessorFn: (row) => row.product_category.name,
header: 'Kategori',
},
{
accessorFn: (row) => row.total_stock,
header: 'Stok',
cell: (props) => {
return props.row.original.total_stock
? formatNumber(props.row.original.total_stock)
: '0';
},
},
{
accessorFn: (row) => row.uom.name,
header: 'Satuan',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
],
[]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'></div>
</div>
<div className='flex justify-between items-end gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
</div>
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(inventoryProducts) &&
inventoryProducts?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
</>
);
};
export default InventoryProductTable;
@@ -0,0 +1,118 @@
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react';
const InventoryProductDetail = ({
inventoryProduct,
}: {
inventoryProduct?: InventoryProduct;
}) => {
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap(
(warehouse) => warehouse.stock_logs || []
) || []
);
}, [inventoryProduct]);
return (
<div className='flex flex-col gap-4 p-4'>
<FormHeader
title='Detail Persediaan Produk'
backUrl='/inventory/product'
/>
<Card
title='Informasi Produk'
variant='bordered'
className={{
wrapper: 'w-full mt-4',
}}
>
<div className='grid grid-cols-2 gap-4'>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td className='font-semibold'>SKU</td>
<td>:</td>
<td>{inventoryProduct?.sku}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Produk</td>
<td>:</td>
<td>{inventoryProduct?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Kategory</td>
<td>:</td>
<td>{inventoryProduct?.product_category.name}</td>
</tr>
<tr>
<td className='font-semibold'>Satuan</td>
<td>:</td>
<td>{inventoryProduct?.uom.name}</td>
</tr>
</tbody>
</table>
</div>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td className='font-semibold'>Harga Jual</td>
<td>:</td>
<td>
{inventoryProduct?.selling_price
? formatCurrency(inventoryProduct.selling_price)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Harga Beli</td>
<td>:</td>
<td>
{inventoryProduct?.product_price
? formatCurrency(inventoryProduct?.product_price)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Pajak</td>
<td>:</td>
<td>
{inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Total Stok</td>
<td>:</td>
<td>
{inventoryProduct?.total_stock
? formatNumber(inventoryProduct?.total_stock)
: '0'}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</Card>
<StockProductWarehouseTable
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/>
<StockLogTable stockLogs={stockLogs} />
</div>
);
};
export default InventoryProductDetail;
@@ -0,0 +1,81 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLog } from '@/types/api/inventory/product';
const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
return (
<Card
title='Informasi Stock Produk'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<StockLog>
data={stockLogs}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
]}
className={{
containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</Card>
);
};
export default StockLogTable;
@@ -0,0 +1,65 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper';
import {
InventoryProduct,
ProductWarehouseStock,
} from '@/types/api/inventory/product';
const StockProductWarehouseTable = ({
productWarehouseStock,
}: {
productWarehouseStock?: ProductWarehouseStock[];
}) => {
return (
<Card
title='Informasi Gudang'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<ProductWarehouseStock>
data={productWarehouseStock ?? []}
columns={[
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
]}
className={{
containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</Card>
);
};
export default StockProductWarehouseTable;
@@ -6,7 +6,7 @@ import {
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './repeater/delivery-order/DeliverOrderProduct.schema';
} from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
type MarketingSchemaType = {
customer_id: number | undefined;
@@ -8,7 +8,6 @@ import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
@@ -31,23 +30,23 @@ import {
DeliveryOrderSchema,
SalesOrderFormValues,
SalesOrderSchema,
} from './MarketingForm.schema';
} from '@/components/pages/marketing/form/MarketingForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
DeliveryOrderApi,
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { SalesOrderProductFormValues } from './repeater/sales-order/SalesOrderProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import SalesOrderProductTable from './table-view/SalesOrderProductTable';
import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct';
import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema';
import DebouncedTextArea from '@/components/input/DebouncedTextArea';
import SalesOrderProductTable from '@/components/pages/marketing/form/table-view/SalesOrderProductTable';
import SalesOrderProductForm from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from '@/components/pages/marketing/form/table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -156,8 +155,6 @@ export const recalculate = (
field: string,
values: ProductCalculationFields
) => {
console.log('Values');
console.log(values);
const { qty, unit_price, total_price, avg_weight, total_weight } = values;
const result: Partial<ProductCalculationFields> = {};
if (field == 'unit_price' || field == 'total_price' || field == 'qty') {
@@ -174,8 +171,6 @@ export const recalculate = (
result.avg_weight = Number(total_weight) / Number(qty);
}
}
console.log('Result');
console.log(result);
return result;
};
export const getSubmitField = (values: ProductCalculationFields) => {
@@ -327,8 +322,6 @@ const MarketingForm = ({
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload);
console.log('PAYLOAD');
console.log(payload);
switch (formType) {
case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -352,7 +345,6 @@ const MarketingForm = ({
// ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
toast.success(createMarketingRes?.message as string);
@@ -365,7 +357,6 @@ const MarketingForm = ({
};
const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const updateMarketingRes = await SalesOrderApi.update(
initialValues?.id as number,
values
@@ -381,10 +372,8 @@ const MarketingForm = ({
};
const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const createDeliveryRes = await DeliveryOrderApi.create(values);
if (isResponseSuccess(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.success(createDeliveryRes?.message as string);
setDeliveryOrderValues(
createDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
@@ -397,20 +386,17 @@ const MarketingForm = ({
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.error(createDeliveryRes?.message as string);
}
setIsLoading(false);
};
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const updateDeliveryRes = await DeliveryOrderApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string);
setDeliveryOrderValues(
mergeSOwithDO(
@@ -426,7 +412,6 @@ const MarketingForm = ({
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.error(updateDeliveryRes?.message as string);
}
setIsLoading(false);
@@ -435,16 +420,13 @@ const MarketingForm = ({
// ================== MARKETING HANDLER ==================
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.success(deleteMarketingRes?.message as string);
}
if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.error(deleteMarketingRes?.message as string);
}
setIsLoading(false);
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './DeliverOrderProduct.schema';
} from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import { useFormik } from 'formik';
import Alert from '@/components/Alert';
import Button from '@/components/Button';
@@ -3,10 +3,10 @@ import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path';
import { date } from 'yup';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
interface DeliveryOrderExportProps {
data?: Marketing;
@@ -3,8 +3,8 @@ import { Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
interface SalesOrderExportProps {
data?: Marketing;
@@ -306,7 +306,6 @@ const SupplierForm = ({
label='Hatchery'
value={hatcheryOptionsValues}
onChange={(val) => {
console.log(val); // pastikan val = array of { value, label }
setHatcheryOptionValues(val as OptionType[]);
}}
isError={
@@ -7,13 +7,16 @@ import { formatNumber } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import Tabs from '@/components/Tabs';
import ChickinFormView from './tabs/ChickinFormView';
import ChickinLogsView from './tabs/ChickLogsView';
import { useState } from 'react';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_KANDANG_APPROVAL_LINE } from '@/config/approval-line';
import ChickinFormView from '@/components/pages/production/chickin/form/tabs/ChickinFormView';
import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/ChickLogsView';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { Icon } from '@iconify/react';
import Badge from '@/components/Badge';
const ChickinFormKandang = ({
formType = 'add',
initialValues,
@@ -23,7 +26,7 @@ const ChickinFormKandang = ({
initialValues: ProjectFlockKandang;
afterSubmit?: () => void;
}) => {
const [activeTabId, setActiveTabId] = useState<string>('formChickIn');
const [openChickin, setOpenChickin] = useState<boolean>(false);
const {
approvals,
@@ -37,108 +40,148 @@ const ChickinFormKandang = ({
});
const afterSubmitFormChickin = () => {
setActiveTabId('logsChickIn');
setOpenChickin(true);
afterSubmit && afterSubmit();
refreshApprovals();
};
return (
<div className='flex flex-col gap-4'>
<FormHeader
title={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`}
<>
<DrawerHeader
subtitle={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
leftIcon='mdi:arrow-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${initialValues?.project_flock?.id}`}
/>
{/* Informasi Kandang */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Kandang</h2>
{approvals && !approvalsLoading && (
<div className='mb-3 text-sm'>
<ApprovalSteps approvals={approvals} />
</div>
)}
<Card
title='Informasi Kandang'
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
className={{
wrapper: 'w-full bg-white mt-4',
badge: 'rounded-lg px-2',
}}
>
<Table<Kandang>
emptyContent={
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Informasi Kandang belum tersedia...
</span>
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
</Badge>
</div>
}
data={[initialValues?.kandang]}
columns={[
{
header: 'Area',
accessorFn: () => initialValues?.project_flock?.area.name || '-',
},
{
header: 'Lokasi',
accessorFn: () =>
initialValues?.project_flock?.location.name || '-',
},
{
header: 'Flock',
accessorFn: () => initialValues?.project_flock?.flock_name || '-',
},
{
header: 'Kandang',
accessorFn: (row) => row?.name || '-',
},
{
header: 'Kapasitas',
accessorFn: (row) =>
(row?.capacity && formatNumber(row?.capacity)) || '-',
},
{
header: 'Penanggung Jawab',
accessorFn: (row) => row?.pic?.name || '-',
},
]}
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
{/* Area */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>
{initialValues.project_flock.area.name}
</div>
{/* Lokasi */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>
{initialValues.project_flock?.location.name}
</div>
{/* Kandang */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
</div>
<div className='col-span-2'>{initialValues.kandang.name}</div>
{/* Jumlah DOC */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
</div>
<div className='col-span-2'>
{formatNumber(
initialValues.chickins?.reduce(
(total, chickin) => total + chickin.usage_qty,
0
) ?? 0
)}{' '}
Ekor
</div>
</div>
</div>
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Chick In</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color={'success'} />{' '}
Perlu Chick In ({initialValues.available_qtys?.length ?? 0})
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => setOpenChickin(!openChickin)}
>
{`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`}
<Icon
icon={`mdi:${openChickin ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/>
</Card>
<Tabs
className='bg-white p-2'
onTabChange={setActiveTabId}
activeTabId={activeTabId}
tabs={[
{
id: 'formChickIn',
label: 'Tambah Chick In',
content: (
</Badge>
</div>
</div>
{openChickin && (
<ChickinLogsView
initialValues={initialValues}
afterSubmit={afterSubmitFormChickin}
/>
)}
<ChickinFormView
initialValues={initialValues}
formType={formType}
afterSubmit={afterSubmitFormChickin}
/>
),
},
{
content: (
<ChickinLogsView
initialValues={initialValues}
afterSubmit={afterSubmitFormChickin}
/>
),
id: 'logsChickIn',
label: 'Riwayat Chick In',
},
]}
variant='lifted'
/>
</div>
</>
);
};
@@ -2,17 +2,12 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin';
import {
Chickin,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
@@ -54,105 +49,120 @@ const ChickinLogsView = ({
return (
<>
<div className='px-4 pb-4 flex flex-col gap-4'>
{/* Card List Chickin Logs */}
{(initialValues?.chickins || []).length === 0 ? (
<div className='w-full p-8 text-center'>
<span className='text-lg opacity-50'>
Belum ada riwayat Chick In...
</span>
</div>
) : (
(initialValues?.chickins || []).map((chickin, index) => {
const isApproved = chickin.usage_qty !== 0;
const isPending = chickin.pending_usage_qty !== 0;
const quantity = isApproved
? chickin.usage_qty
: isPending
? chickin.pending_usage_qty
: 0;
return (
<Card
title='Riwayat Chick In'
key={chickin.id || index}
variant='bordered'
className={{
wrapper: 'w-full bg-white',
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-row justify-start gap-3 mt-3'>
<div className='flex flex-col gap-4'>
{/* Header with Status Badge */}
<div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'>
Chick In #{index + 1}
</div>
<PillBadge
content={
isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
}
color={
isApproved ? 'green' : isPending ? 'yellow' : 'gray'
}
/>
</div>
{/* Tanggal Chick In */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
<span>Tanggal Chick In</span>
</div>
<div className='text-end text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</div>
</div>
{/* Kandang */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:home'} width={14} height={14} />{' '}
<span>Kandang</span>
</div>
<div className='text-end text-gray-500'>
{chickin.product_warehouse?.warehouse?.name || '-'}
</div>
</div>
{/* Produk */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon
icon={'mdi:package-variant'}
width={14}
height={14}
/>{' '}
<span>Produk</span>
</div>
<div className='text-end text-gray-500'>
{chickin.product_warehouse?.product?.name || '-'}
</div>
</div>
{/* Jumlah Chick In */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:counter'} width={14} height={14} />{' '}
<span>Jumlah Chick In</span>
</div>
<div className='text-end text-gray-500 font-semibold'>
{quantity > 0 ? `${formatNumber(quantity)} Ekor` : '-'}
</div>
</div>
</div>
</Card>
);
})
)}
{initialValues?.approval?.step_number == 1 && (
<Button
color='success'
variant='outline'
onClick={handleClickApprove}
className='w-full'
>
<Icon width={24} height={24} icon='material-symbols:check' />
Approve
Approve Semua Chick In
</Button>
)}
</div>
<Table<Chickin>
data={initialValues?.chickins || []}
columns={[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorFn: (row) => row.chick_in_date,
header: 'Tanggal Chick In',
cell: (props) => {
return formatDate(props.getValue() as string, 'DD MMM YYYY');
},
},
{
accessorFn: (row) => row.product_warehouse?.warehouse?.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.product_warehouse?.product?.name,
header: 'Produk',
},
{
accessorFn: (row) => row.usage_qty ?? row.pending_usage_qty,
header: 'Jumlah Chick In',
cell: (props) => {
if (props.row.original.usage_qty != 0) {
return formatNumber(props.row.original.usage_qty);
} else if (props.row.original.pending_usage_qty != 0) {
return formatNumber(props.row.original.pending_usage_qty);
} else {
return '-';
}
},
},
{
accessorFn: (row) => row.pending_usage_qty,
header: 'Status',
cell: (props) => {
return (
<PillBadge
content={
props.row.original.usage_qty !== 0
? 'Disetujui'
: props.row.original.pending_usage_qty !== 0
? 'Pending'
: '-'
}
color={
props.row.original.usage_qty !== 0
? 'green'
: props.row.original.pending_usage_qty !== 0
? 'yellow'
: 'gray'
}
/>
);
},
},
]}
className={{
containerClassName: cn({
'mb-20': initialValues?.chickins?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert>
</div>
)}
</Card>
</div>
<ConfirmationModalWithNotes
ref={confirmModal.ref}
type='success'
@@ -20,6 +20,7 @@ import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandan
import { useRouter } from 'next/navigation';
import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper';
import { Icon } from '@iconify/react';
const ChickinFormView = ({
initialValues,
@@ -118,19 +119,58 @@ const ChickinFormView = ({
return (
<form
className='flex flex-col gap-4'
className='flex flex-col gap-4 p-4'
onReset={() => {
handleReset();
}}
onSubmit={formik.handleSubmit}
>
{(formik.values.chickin_requests || []).map((chickinRequest, index) => {
const availableQty = initialValues?.available_qtys?.find(
(availableQty) =>
availableQty.product_warehouse.id ===
chickinRequest.product_warehouse_id
);
return (
<Card
title='Informasi Chick In DOC'
key={index}
// title={`${formatNumber(availableQty?.available_qty ?? 0)} Ekor - ${availableQty?.product_warehouse?.product?.name}`}
variant='bordered'
size='sm'
className={{
wrapper: 'w-full bg-white',
wrapper: 'w-full',
body: 'p-3',
}}
>
<Table<ChickinRequestFormValues>
<div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'>
{formatNumber(availableQty?.available_qty ?? 0)} Ekor -{' '}
{availableQty?.product_warehouse?.product?.name}
</div>
{chickinRequest.chick_in_date && (
<Icon
icon='mdi:check-circle-outline'
color='success'
className='text-success'
width={20}
height={20}
/>
)}
</div>
<DateInput
className={{
wrapper: 'w-full',
inputWrapper: 'bg-white',
}}
label='Tanggal Chick In'
name={`chickin_requests[${index}].chick_in_date`}
value={chickinRequest.chick_in_date}
onChange={formik.handleChange}
/>
</Card>
);
})}
{/* <Table<ChickinRequestFormValues>
data={formik.values.chickin_requests || []}
columns={[
{
@@ -204,20 +244,17 @@ const ChickinFormView = ({
</span>
</div>
}
/>
</Card>
<div className='flex flex-row justify-center gap-3'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
/> */}
{formik.values.chickin_requests?.length > 0 && (
<Button
type='submit'
color='primary'
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
<Icon icon='mdi:checkbox-marked-outline' width={24} height={24} />
Chick In
</Button>
</div>
)}
{chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert>
@@ -1,6 +1,8 @@
'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
@@ -8,23 +10,18 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import { Kandang } from '@/types/api/master-data/kandang';
import {
ProjectFlockApprovalPayload,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import { CellContext, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
@@ -98,7 +95,7 @@ const RowOptionsMenu = ({
);
};
const ProjectFlockTable = () => {
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const {
state: tableFilterState,
updateFilter,
@@ -123,8 +120,9 @@ const ProjectFlockTable = () => {
periodFilter: 'period',
},
});
const router = useRouter();
// State
// ===== State =====
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id])
@@ -151,14 +149,15 @@ const ProjectFlockTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
// Fetch Data
// ===== Fetch Data =====
const {
data: projectFlocks,
isLoading,
mutate: refreshProjectFlocks,
} = useSWR(
`${ProjectFlockApi.basePath}${getTableFilterQueryString()}`,
ProjectFlockApi.getAllFetcher
ProjectFlockApi.getAllFetcher,
{ revalidateOnMount: true }
);
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
@@ -191,7 +190,7 @@ const ProjectFlockTable = () => {
KandangApi.getAllFetcher
);
// Data to Options Mapping
// ===== Data to Options Mapping ======
const optionsArea = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
@@ -211,7 +210,7 @@ const ProjectFlockTable = () => {
}))
: [];
// Handler
// ====== HANDLER ======
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
@@ -219,17 +218,17 @@ const ProjectFlockTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProjectFlockApi.delete(selectedProjectFlock?.id as number);
await ProjectFlockApi.delete(selectedSingleRow?.id as number);
refreshProjectFlocks();
deleteModal.closeModal();
toast.success('Successfully delete Project Flock!');
setIsDeleteLoading(false);
setRowSelection({});
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'APPROVED' | 'REJECTED'
@@ -259,22 +258,44 @@ const ProjectFlockTable = () => {
setIsApproveLoading(false);
};
// ====== EFFECT ======
useEffect(() => {
refreshProjectFlocks();
}, [refresh]);
// ====== MEMO ======
const selectedSingleRow: ProjectFlock | null | undefined = useMemo(() => {
return selectedRowIds.length === 1
? isResponseSuccess(projectFlocks)
? projectFlocks?.data.find((row) => row.id === selectedRowIds[0])
: null
: null;
}, [rowSelection]);
const canApprove = useMemo(() => {
if (!selectedSingleRow || isApproveLoading) return false;
const isPengajuan = selectedSingleRow.approval.step_number == 1;
const isNotRejected = selectedSingleRow.approval.action != 'REJECTED';
return isPengajuan && isNotRejected;
}, [selectedSingleRow, isApproveLoading]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='min-h-screen w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col justify-between items-end gap-2'>
<div className='flex flex-col sm:flex-row gap-3 w-full'>
<Button
href='/production/project-flock/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
href='/production/project-flock/add'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
<Button
{/* <Button
variant='outline'
color='success'
onClick={() => {
@@ -299,7 +320,7 @@ const ProjectFlockTable = () => {
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</Button> */}
<div className='ms-auto w-full sm:w-auto'>
<DebouncedTextInput
name='search'
@@ -391,9 +412,7 @@ const ProjectFlockTable = () => {
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(
(row) => row.original?.approval?.step_number == 1
);
const selectableRows = allRows;
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
@@ -417,12 +436,6 @@ const ProjectFlockTable = () => {
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={
isResponseSuccess(projectFlocks) &&
projectFlocks?.data?.filter(
(flock) => flock.approval.step_number == 1
).length == 0
}
/>
</div>
);
@@ -431,14 +444,8 @@ const ProjectFlockTable = () => {
return (
<CheckboxInput
name='row'
checked={
row.getIsSelected() &&
row.original.approval.step_number == 1
}
disabled={
!row.getCanSelect() ||
row.original.approval.step_number != 1
}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
@@ -469,6 +476,40 @@ const ProjectFlockTable = () => {
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
/>
{approval.step_name}
</Badge>
);
},
},
{
header: 'Kandang',
@@ -496,51 +537,51 @@ const ProjectFlockTable = () => {
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString(),
formatDate(props.row.original.created_at, 'MMM DD, YYYY'),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows =
props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
// {
// header: 'Aksi',
// cell: (props) => {
// const currentPageSize =
// props.table.getPaginationRowModel().rows.length;
// const currentPageRows =
// props.table.getPaginationRowModel().flatRows;
// const currentRowRelativeIndex =
// currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2;
// const isLast2Rows =
// currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProjectFlock(props.row.original);
deleteModal.openModal();
};
// const deleteClickHandler = () => {
// setSelectedProjectFlock(props.row.original);
// deleteModal.openModal();
// };
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
// return (
// <>
// {currentPageSize > 2 && (
// <RowDropdownOptions isLast2Rows={isLast2Rows}>
// <RowOptionsMenu
// type='dropdown'
// props={props}
// deleteClickHandler={deleteClickHandler}
// />
// </RowDropdownOptions>
// )}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
// {currentPageSize <= 2 && (
// <RowCollapseOptions>
// <RowOptionsMenu
// type='collapse'
// props={props}
// deleteClickHandler={deleteClickHandler}
// />
// </RowCollapseOptions>
// )}
// </>
// );
// },
// },
]}
pageSize={tableFilterState.pageSize}
page={
@@ -576,6 +617,57 @@ const ProjectFlockTable = () => {
</div>
</div>
<FloatingActionsButton
actions={[
{
action: 'DETAIL',
icon: 'mdi:eye-outline',
label: 'Lihat Detail',
hidden: selectedRowIds.length !== 1,
onClick() {
router.push(
`/production/project-flock/detail?projectFlockId=${selectedRowIds[0]}`
);
setRowSelection({});
},
},
{
action: 'DELETE',
icon: 'material-symbols:delete-outline-rounded',
label: `Hapus data`,
hidden: selectedRowIds.length !== 1,
onClick: () => {
deleteModal.openModal();
},
},
]}
approvals={[
{
icon: 'material-symbols:check',
label: 'Approve',
action: 'APPROVED',
onClick: () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
},
disabled: !canApprove,
},
{
icon: 'mdi:times',
label: 'Reject',
action: 'REJECTED',
onClick: () => {
setApprovalAction('REJECTED');
confirmModal.openModal();
},
},
]}
selectedRowIds={selectedRowIds}
onClose={() => {
setRowSelection({});
}}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
@@ -10,7 +10,7 @@ import SelectInput, {
import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { cn, formatDate, formatTitleCase } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -21,6 +21,7 @@ import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { FormHeader } from '@/components/helper/form/FormHeader';
import Link from 'next/link';
const ProjectFlockChickinDetail = ({
projectFlockId,
@@ -101,11 +102,26 @@ const ProjectFlockChickinDetail = ({
}, [projectFlockId, listProjectFlock]);
return (
<>
<FormHeader
{/* Header */}
<div className='flex flex-row justify-between items-center px-4 py-4'>
<div className='flex flex-row items-center h-full gap-2'>
<Link
href={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`}
className='hover:text-gray-400'
>
<Icon icon='mdi:arrow-left' width={24} height={24} />
</Link>
<div className='divider divider-horizontal p-0 m-0'></div>
<div className='text-sm text-neutral'>
Chick In {projectFlock?.flock_name}
</div>
</div>
</div>
{/* <FormHeader
title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`}
backUrl='/production/project-flock'
/>
<div className='flex flex-col gap-4 w-full my-4'>
backUrl={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`}
/> */}
{/* <div className='flex flex-col gap-4 w-full my-4'>
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput
required
@@ -145,8 +161,129 @@ const ProjectFlockChickinDetail = ({
}
/>
</div>
</div> */}
{/* Informasi Umum */}
{projectFlock && (
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={
projectFlock.approval.step_number == 1
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
? 'error'
: undefined
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
projectFlock.approval.step_number == 1
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
? 'error'
: undefined
}
/>{' '}
{projectFlock.approval.step_name}
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:bookmark' width={12} height={12} />
{` ${formatTitleCase(projectFlock.category)}`}
</Badge>
</div>
<Card
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:account' /> Submitted
</div>
<div className='col-span-2'>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:account-circle' width={14} height={14} />{' '}
{projectFlock.created_user.name}
</Badge>
</div>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div>
{/* BARIS 1 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area.name}</div>
{/* BARIS 2 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> FCR
</div>
<div className='col-span-2'>{projectFlock.fcr.name}</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Kategori
</div>
<div className='col-span-2'>
{formatTitleCase(projectFlock.category)}
</div>
</div>
</div>
</div>
)}
{/* <Card
title='Informasi Flock'
className={{
wrapper: 'w-full bg-white mb-3',
@@ -231,8 +368,152 @@ const ProjectFlockChickinDetail = ({
paginationClassName: 'hidden',
}}
/>
</Card>
</Card> */}
{/* Card Kandangs */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Daftar Kandang</h2>
{isResponseSuccess(listProjectFlock) ? (
<>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Disetujui (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval?.step_number == 1
).length}
)
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
variant='soft'
color={'neutral'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'neutral'}
/>{' '}
Pengajuan (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval?.step_number == 2
).length}
)
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='error'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon
icon={`mdi:circle`}
width={12}
height={12}
color='error'
/>
Belum Chickin (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval == null
).length}
)
</Badge>
</div>
{/* Card Kandang */}
<Card
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-6'>
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.map((kandang) => (
<div
key={kandang.id}
className='flex flex-row justify-between items-center'
>
<div className='flex flex-row gap-2 items-center cursor-pointer text-gray-400'>
<Badge
variant='soft'
color={
kandang.approval
? kandang.approval.step_number == 1
? 'success'
: 'neutral'
: 'error'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
kandang.approval
? kandang.approval.step_number == 1
? 'success'
: 'neutral'
: 'gray'
}
/>
</Badge>
<span className='font-semibold'>
{kandang.kandang.name}
</span>
</div>
<Button
variant='outline'
className='py-1 text-sm'
onClick={() => {
handleChickinClick(kandang);
}}
disabled={projectFlock?.approval?.step_number === 1}
>
Chick In{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div>
))}
</div>
</Card>
</>
) : (
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Pilih project flock terlebih dahulu...
</span>
</div>
)}
</div>
</div>
{/* <Card
title='Daftar Kandang'
className={{
wrapper: 'w-full bg-white',
@@ -351,7 +632,7 @@ const ProjectFlockChickinDetail = ({
paginationClassName: 'hidden',
}}
/>
</Card>
</Card> */}
</>
);
};
@@ -0,0 +1,304 @@
'use client';
import Button from '@/components/Button';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import Table from '@/components/Table';
import Badge from '@/components/Badge';
import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import {
ClosingExpense,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import useSWR from 'swr';
import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
const ProjectFlockClosingForm = ({
projectFlock,
projectFlockKandang,
}: {
projectFlock: ProjectFlock;
projectFlockKandang: ProjectFlockKandang;
}) => {
const router = useRouter();
const closeModal = useModal();
const isCanClose = projectFlock.approval?.step_number <= 2;
const [isClosingLoading, setIsClosingLoading] = useState(false);
const { data: closingData, isLoading } = useSWR(
`${ProjectFlockKandangApi.basePath}/${projectFlockKandang.id}/closing`,
() => ProjectFlockKandangApi.checkClosing(projectFlockKandang.id)
);
const confirmationModalCloseClickHandler = async () => {
setIsClosingLoading(true);
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
projectFlock?.id as number,
{
closed_date: formatDate(new Date(), 'yyyy-MM-dd'),
action: isCanClose ? 'close' : 'unclose',
}
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push(`/production/project-flock`);
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsClosingLoading(false);
closeModal.closeModal();
};
const errorStock = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
: false;
}, [closingData]);
const errorExpense = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.expenses.every((expense) => expense.step < 5)
: false;
}, [closingData]);
const isCanCloseValid = !errorStock && !errorExpense;
return (
<>
<DrawerHeader
leftIcon='mdi:arrow-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${projectFlock.id}`}
subtitle={`Close ${projectFlock.flock_name}`}
></DrawerHeader>
{/* Informasi Kandang */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Kandang</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
{/* Area */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area?.name}</div>
{/* Lokasi */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location?.name}</div>
{/* Kandang */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
</div>
<div className='col-span-2'>{projectFlockKandang.kandang?.name}</div>
{/* Jumlah DOC */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
</div>
<div className='col-span-2'>
{formatNumber(
projectFlockKandang.chickins?.reduce(
(total, chickin) => total + chickin.usage_qty,
0
) ?? 0
)}{' '}
Ekor
</div>
</div>
</div>
{/* Table Biaya */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Biaya</h2>
<Table<ClosingExpense>
data={
isResponseSuccess(closingData) ? closingData.data?.expenses : []
}
columns={[
{
header: 'PO Number',
accessorKey: 'po_number',
},
{
header: 'Total',
accessorKey: 'total',
},
{
header: 'Status',
accessorKey: 'status',
cell(props) {
return (
<Badge
className={{
badge: 'rounded-lg',
}}
variant='soft'
color={
props.row.original.step < 5
? props.row.original.step == 1
? 'neutral'
: 'success'
: 'error'
}
>
{formatTitleCase(props.row.original.status)}
</Badge>
);
},
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{errorExpense && (
<div className='text-center text-error'>
*Pastikan semua biaya sudah selesai sebelum melakukan closing.
</div>
)}
</div>
{/* Table Persediaan Gudang */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Persediaan Gudang</h2>
<Table<ProductWarehouse>
data={
isResponseSuccess(closingData)
? closingData.data?.stock_remaining
: []
}
columns={[
{
header: 'Product',
accessorKey: 'product.name',
},
{
header: 'Kategori',
accessorKey: 'product.product_category.name',
},
{
header: 'Quantity',
accessorKey: 'quantity',
},
{
header: 'UOM',
accessorKey: 'product.uom.name',
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{errorStock && (
<div className='text-center text-error'>
*Masih ada sisa stock yang belum dihabiskan.
</div>
)}
</div>
<div className='p-4 mt-6'>
<Button
className='w-full'
color='error'
isLoading={isLoading}
disabled={!isCanCloseValid}
onClick={() => closeModal.openModal()}
>
<Icon icon='mdi:checkbox-marked-circle-outline' />{' '}
{isCanClose ? 'Close' : 'Unclose'}
</Button>
</div>
<ConfirmationModal
ref={closeModal.ref}
type='error'
text={
isCanClose
? 'Apakah kamu yakin ingin mengakhiri project ini ? *Pastikan persediaan produk di gudang terkait sudah kosong, dan BOP sudah selesai'
: 'Apakah kamu yakin ingin membuka kembali project ini ? *Project ini akan kembali ke status aktif'
}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isClosingLoading,
onClick: confirmationModalCloseClickHandler,
}}
/>
</>
);
};
export default ProjectFlockClosingForm;
@@ -0,0 +1,439 @@
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import {
formatCurrency,
formatDate,
formatNumber,
formatTitleCase,
} from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import toast from 'react-hot-toast';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
const ProjectFlockDetail = ({
projectFlock,
}: {
projectFlock: ProjectFlock;
}) => {
const router = useRouter();
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [openBudgets, setOpenBudget] = useState(false);
const [selectedKandangId, setSelectedKamdangId] = useState<string | null>(
null
);
const selectedKandang = projectFlock.kandangs.find(
(kandang) => kandang.id === Number(selectedKandangId)
);
const {
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: projectFlock?.approval,
approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCKS',
moduleId: projectFlock?.id.toString() ?? '',
});
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ProjectFlockApi.delete(
projectFlock?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/project-flock');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsDeleteLoading(false);
};
return (
<>
<div className='h-full w-full flex flex-col gap-4'>
{/* Header */}
<DrawerHeader
leftIcon='mdi:close'
leftIconHref='/production/project-flock'
subtitle={`Created On ${formatDate(projectFlock.created_at, 'MMM DD, YYYY')}`}
>
<Link
href={`/production/project-flock/detail/edit?projectFlockId=${projectFlock.id}`}
className='p-0'
>
<Tooltip content='Edit' position='bottom'>
<Button variant='link' className='p-0 text-neutral'>
<Icon icon='mdi:square-edit-outline' width={20} height={20} />
</Button>
</Tooltip>
</Link>
<Button
variant='link'
className='p-0 text-error'
onClick={() => {
deleteModal.openModal();
}}
>
<Tooltip content='Hapus' position='bottom'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
</Tooltip>
</Button>
</DrawerHeader>
{/* Informasi Umum */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
{/* Status Approval */}
{approvals && !approvalsLoading && (
<div className='text-sm my-3'>
<ApprovalSteps approvals={approvals} />
</div>
)}
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
: undefined
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
: undefined
}
/>{' '}
{projectFlock.approval?.step_name}
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:bookmark' width={12} height={12} />
{` ${formatTitleCase(projectFlock.category)}`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:account' /> Submitted
</div>
<div className='col-span-2'>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:account-circle' width={14} height={14} />{' '}
{projectFlock.created_user.name}
</Badge>
</div>
{/* <div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div> */}
{/* BARIS 1 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area.name}</div>
{/* BARIS 2 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> FCR
</div>
<div className='col-span-2'>{projectFlock.fcr.name}</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Kategori
</div>
<div className='col-span-2'>
{formatTitleCase(projectFlock.category)}
</div>
</div>
</div>
</div>
{/* Kandang Aktif */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Kandang Aktif</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Kandang Aktif ({projectFlock.kandangs.length})
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => {
setOpenBudget(!openBudgets);
}}
>
{` ${formatCurrency(
(projectFlock.project_budgets ?? []).reduce(
(acc, curr) => acc + curr.price * curr.qty,
0
)
)}`}
<Icon
icon={`mdi:${openBudgets ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/>
</Badge>
</div>
{/* Card List Project Budgets */}
{openBudgets &&
(projectFlock.project_budgets ?? []).map((budget) => (
<Card
key={budget.id}
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-6'>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:tag'} width={14} height={14} />{' '}
<span>Jenis Produk</span>
</div>
<div className='text-end text-gray-500'>
{budget.nonstock?.name}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:tag'} width={14} height={14} />{' '}
<span>Nama Satuan</span>
</div>
<div className='text-end text-gray-500'>
{budget.nonstock?.uom.name}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon
icon={'mdi:file-multiple'}
width={14}
height={14}
/>{' '}
<span>Jumlah Pembelian</span>
</div>
<div className='text-end text-gray-500'>
{formatNumber(budget.qty)}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:file'} width={14} height={14} />{' '}
<span>Harga Satuan</span>
</div>
<div className='text-end text-gray-500'>
{formatCurrency(budget.price)}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:calculator'} width={14} height={14} />{' '}
<span>Total Harga</span>
</div>
<div className='text-end text-gray-500'>
{formatCurrency(budget.price * budget.qty)}
</div>
</div>
</div>
</Card>
))}
{/* Card Kandangs */}
<Card
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<RadioGroup
name='gender'
className={{
radioWrapper: 'grid grid-cols-1 gap-6',
}}
onChange={(e) => setSelectedKamdangId(e.target.value)}
value={selectedKandangId?.toString()}
size='md'
color='neutral'
disabled={projectFlock.approval.step_number == 1}
>
{projectFlock.kandangs.map((kandang) => (
<div
key={kandang.id}
className={`grid grid-cols-2 gap-6 cursor-pointer hover:text-gray-800`}
onClick={() =>
projectFlock.approval.step_number > 1 &&
setSelectedKamdangId(kandang.id.toString())
}
>
<RadioGroupItem
value={kandang.id.toString()}
label={kandang.name}
disabled={projectFlock.approval.step_number == 1}
/>
<div className='text-end'>
<Badge
className={{
badge: 'rounded-lg',
}}
>
Kapasitas {kandang.capacity} Ekor
</Badge>
</div>
</div>
))}
</RadioGroup>
</Card>
<div className='grid grid-cols-4 gap-3'>
<Link
href={`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}&projectFlockId=${projectFlock.id}`}
className='m-0 p-0'
>
<Button
className='w-full px-2 py-1 text-sm'
variant='outline'
color='success'
disabled={
!selectedKandangId || projectFlock.approval.step_number == 1
}
>
Chickin <Icon icon='mdi:checkbox-marked-outline' />
</Button>
</Link>
<Link
href={`/production/project-flock/closing?projectFlockId=${projectFlock.id}&projectFlockKandangId=${selectedKandangId}`}
className='m-0 p-0'
>
<Button
className='w-full px-2 py-1 text-sm'
variant='outline'
color='error'
disabled={
!selectedKandangId || projectFlock.approval.step_number == 1
}
>
Close <Icon icon='mdi:checkbox-marked-circle-outline' />
</Button>
</Link>
</div>
</div>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${projectFlock?.flock_name} - ${projectFlock?.area?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default ProjectFlockDetail;
@@ -1,6 +1,71 @@
import * as Yup from 'yup';
export const ProjectFlockFormSchema = Yup.object({
type ProjectFlockFormSchemaType = {
flock: {
value: number | string;
label: string;
} | null;
flock_name: string;
area: {
value: number | string;
label: string;
} | null;
area_id: number;
category_option: {
value: string;
label: string;
} | null;
category: string;
fcr: {
value: number | string;
label: string;
} | null;
fcr_id: number;
location: {
value: number | string;
label: string;
} | null;
location_id: number;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
export type ProjectFlockBudgetsSchemaType = {
nonstock: {
value: number | string;
label: string;
} | null;
nonstock_id: number | string;
qty: number | string;
price: number | string;
total_price: number | string;
};
export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSchemaType> =
Yup.object({
nonstock: Yup.object({
value: Yup.number().required('ID Nonstock wajib diisi!'),
label: Yup.string().required('Nama Nonstock wajib diisi!'),
}).required('Nonstock wajib diisi!'),
nonstock_id: Yup.number()
.min(1, 'Nonstock wajib diisi!')
.required('Nonstock wajib diisi!'),
qty: Yup.number()
.typeError('Jumlah harus berupa angka!')
.min(1, 'Jumlah minimal 1!')
.required('Jumlah wajib diisi!'),
price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
total_price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
});
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
Yup.object({
// Flock
flock: Yup.object({
value: Yup.number().required('ID Flock wajib diisi!'),
@@ -31,7 +96,9 @@ export const ProjectFlockFormSchema = Yup.object({
value: Yup.number().required('ID FCR wajib diisi!'),
label: Yup.string().required('Nama FCR wajib diisi!'),
}).nullable(),
fcr_id: Yup.number().min(1, 'FCR wajib diisi!').required('FCR wajib diisi!'),
fcr_id: Yup.number()
.min(1, 'FCR wajib diisi!')
.required('FCR wajib diisi!'),
// Location
location: Yup.object({
@@ -43,9 +110,14 @@ export const ProjectFlockFormSchema = Yup.object({
.required('Lokasi wajib diisi!'),
kandang_ids: Yup.array()
.of(Yup.number().typeError('Kandang tidak valid!'))
.of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
.required('Kandang wajib diisi!'),
project_budgets: Yup.array()
.of(ProjectFlockBudgetsSchema)
.min(1, 'Minimal harus ada 1 data budget!')
.required('Data budget wajib diisi!'),
});
export type ProjectFlockFormValues = Yup.InferType<
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
'use client';
import Badge from '@/components/Badge';
import Card from '@/components/Card';
import CheckboxInput from '@/components/input/CheckboxInput';
import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
@@ -9,6 +11,7 @@ import {
ProjectFlock,
ProjectFlockPeriods,
} from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react';
@@ -29,163 +32,119 @@ const ProjectFlockKandangTable = ({
initialValues?: ProjectFlock;
formType: 'add' | 'edit' | 'detail';
}) => {
const initialKandangIdSet = useMemo(() => {
return initialValues?.kandangs.map((k) => k.id) ?? [];
}, [initialValues]);
const isRowEnabled = (row: Row<Kandang>) => {
const isDisabled =
!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN' ||
formType == 'detail');
return !isDisabled;
// Fungsi untuk menangani perubahan checkbox
const handleCheckboxChange = (kandang: Kandang, isChecked: boolean) => {
// Hanya izinkan perubahan jika tidak dalam mode 'detail'
if (formType === 'detail') return;
// Pastikan kandang.id ada dan tidak null/undefined
if (kandang.id === undefined) return;
const kandangIdString = kandang.id.toString();
setRowSelection((prev) => {
const newSelection = { ...prev };
if (isChecked) {
newSelection[kandangIdString] = true;
} else {
delete newSelection[kandangIdString];
}
return newSelection;
});
};
return (
<>
<Table<Kandang>
data={listKandang}
columns={[
{listKandang.length > 0 ? (
<>
{/* ... Bagian Badge Status ... */}
<div className='flex flex-row mb-4'>
<Badge
variant='soft'
color='primary'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tersedia (
{
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
// 1. Filter semua baris dengan logika yang sama persis seperti di cell
const selectableRows = allRows.filter(isRowEnabled);
listKandang.filter((kandang) => kandang.status == 'NON_ACTIVE')
.length
}
)
</Badge>
<div className='divider divider-horizontal mx-1'></div>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tidak Tersedia (
{
listKandang.filter((kandang) => kandang.status != 'NON_ACTIVE')
.length
}
)
</Badge>
</div>
{/* --- */}
<Card
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-4',
}}
>
<div className='flex flex-col gap-4 w-full'>
{listKandang.map((kandang, index) => {
const kandangIdString =
kandang.id?.toString() ?? `temp-${index}`;
// 2. Cek apakah SEMUA baris yang BISA DIPILIH sudah terpilih
const allSelected =
selectableRows.length > 0 &&
selectableRows.every((row) => row.getIsSelected());
const isSelected =
!!rowSelection[kandangIdString] ||
(kandang.id !== undefined &&
selectedIds.includes(kandang.id));
// 3. Cek apakah BEBERAPA baris yang BISA DIPILIH sudah terpilih
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
// 4. Fungsi toggle HANYA akan mentoggle baris yang BISA DIPILIH
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
};
const isDisabled =
formType == 'detail' || kandang.status != 'NON_ACTIVE';
return (
<div className='w-full flex flex-row justify-center'>
<div key={index} className='flex flex-row justify-between'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={
selectableRows.length === 0 || formType == 'detail'
name={`kandang-${kandang.id}`} // Nama unik untuk setiap checkbox
label={kandang.name}
checked={isSelected}
disabled={isDisabled}
onChange={(e) =>
handleCheckboxChange(kandang, e.currentTarget.checked)
}
/>
<Badge
variant='soft'
color={
kandang.status == 'NON_ACTIVE' ? 'primary' : 'neutral'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
{kandang.status != 'NON_ACTIVE' && 'Tidak'} Tersedia
</Badge>
</div>
);
},
cell: ({ row }) => {
return (
<CheckboxInput
name='row'
checked={
(row.getIsSelected() &&
(row.original.status == 'NON_ACTIVE' ||
row.original.status == 'PENGAJUAN')) ||
(selectedIds && selectedIds.includes(row.original.id))
}
disabled={
formType == 'detail' ||
(!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN'))
}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
},
},
{
accessorFn: (row) => row.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.status,
header: 'Status',
cell: (props) => {
return (
<PillBadge
color={(() => {
switch (props.row.original.status) {
case 'ACTIVE':
return 'red';
case 'PENGAJUAN':
return 'green';
case 'NON_ACTIVE':
return 'blue';
default:
return 'gray';
}
})()}
content={props.row.original.status
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
/>
);
},
},
{
accessorFn: (row) => row.capacity,
header: 'Kapasitas',
},
{
accessorFn: (row) => row.location?.name,
header: 'Periode',
cell: (props) => {
console.log('listPeriods');
console.log(listPeriods);
const period =
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
const calcPeriod = period?.period == 0 ? 1 : period?.period;
const selected = props.row.getIsSelected();
const initPeriod = initialValues?.period;
return formType == 'detail'
? selected
? initPeriod
: '-'
: formType == 'add'
? (calcPeriod ?? '-')
: selected
? (initPeriod ?? '-')
: (calcPeriod ?? '-');
},
},
{
accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab',
},
]}
className={{
containerClassName: cn({
'mb-20': listKandang?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
/>
})}
</div>
</Card>
</>
) : (
<div className='text-center py-4 text-gray-400'>
Pilih lokasi terlebih dahulu
</div>
)}
</>
);
};
+11 -5
View File
@@ -58,16 +58,22 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
icon: 'uil:wallet',
},
{
title: 'Perhitungan Sapronak',
link: '/us-284',
icon: 'uil:calculator',
},
{
title: 'Persediaan',
link: '/inventory',
icon: 'mdi:warehouse',
submenu: [
// {
// title: 'Product',
// link: '/inventory/product',
// icon: 'mdi:package-variant-closed',
// },
{
title: 'Produk',
link: '/inventory/product',
icon: 'mdi:package-variant-closed',
},
{
title: 'Penyesuaian Stok',
link: '/inventory/adjustment',
+8
View File
@@ -29,6 +29,14 @@ export const formatNumber = (
}).format(value);
};
export const formatTitleCase = (value: string) => {
return value
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
export function formatVechicleNumber(value: string): string {
let result = '';
for (let i = 0; i < (value?.length ?? 0); i++) {
+8 -1
View File
@@ -12,6 +12,7 @@ import {
CreateInventoryAdjustmentPayload,
InventoryAdjustment,
} from '@/types/api/inventory/adjustment';
import { InventoryProduct } from '@/types/api/inventory/product';
export const ProductWarehouseApi = new BaseApiService<
ProductWarehouse,
@@ -25,8 +26,14 @@ export const MovementApi = new BaseApiService<
unknown
>('/inventory/transfers');
export const inventoryAdjustmentApi = new BaseApiService<
export const InventoryAdjustmentApi = new BaseApiService<
InventoryAdjustment,
CreateInventoryAdjustmentPayload,
unknown
>('/inventory/adjustments');
export const InventoryProductApi = new BaseApiService<
InventoryProduct,
unknown,
unknown
>('/inventory/product-stocks');
+1 -1
View File
@@ -1,4 +1,4 @@
import { BaseApiService } from './base';
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import {
CreateProjectFlockPayload,
@@ -2,10 +2,189 @@ import { BaseApiService } from '@/services/api/base';
import {
BaseProjectFlockKandang,
ProjectFlockKandang,
ClosingProjectFlockKandangPayload,
CheckClosingResponse,
} from '@/types/api/production/project-flock-kandang';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import axios from 'axios';
export const ProjectFlockKandangApi = new BaseApiService<
export class ProjectFlockKandangService extends BaseApiService<
BaseProjectFlockKandang,
ProjectFlockKandang,
unknown
>('project-flock-kandang');
> {
constructor(basePath: string = '') {
super(basePath);
}
/**
* Close or Unclose Project Flock Kandang
*/
async closing(
id: number,
payload: ClosingProjectFlockKandangPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> {
try {
const path = `${this.basePath}/${id}/closing`;
const headers = {
'Content-Type': 'application/json',
...(this.header ?? {}),
};
return await httpClient<BaseApiResponse<{ message: string }>>(path, {
method: 'POST',
body: payload,
headers,
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Check Closing Requirements for Project Flock Kandang
* TODO: Replace with actual API call when backend is ready
*/
async checkClosing(
id: number
): Promise<BaseApiResponse<CheckClosingResponse> | undefined> {
// Dummy data - replace with actual API call when backend is ready
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 200,
status: 'success',
message: 'Cek persyaratan closing kandang',
data: {
unfinished_expenses: 2,
stock_remaining: [
{
id: 1,
product_id: 1,
warehouse_id: 1,
quantity: 0,
product: {
id: 1,
name: 'Pakan Starter',
brand: 'Brand A',
sku: 'PKN-STR-001',
product_price: 15000,
selling_price: 17000,
tax: 0,
expiry_period: 365,
flags: ['active'],
uom: {
id: 1,
name: 'Kg',
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin User',
},
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
product_category: {
id: 1,
name: 'Pakan',
code: 'PKN',
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin User',
},
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
suppliers: [],
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin User',
},
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
warehouse: {
id: 1,
name: 'Gudang Utama',
type: 'AREA',
area: {
id: 1,
name: 'Area 1',
},
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin User',
},
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin User',
},
created_at: '2025-01-01',
updated_at: '2025-01-01',
},
],
expenses: [
{
id: 1,
po_number: 'PO-BOP-LTI-00001',
category: 'NON-BOP',
total: 110000,
status: 'SELESAI',
step_name: 'Approval Finance',
step: 5,
reference_number: 'BOP-LTI-00001',
},
{
id: 3,
po_number: 'PO-BOP-LTI-00003',
category: 'BOP',
total: 110000,
status: 'SELESAI',
step_name: 'Approval Finance',
step: 5,
reference_number: 'BOP-LTI-00003',
},
],
},
});
}, 500); // Simulate network delay
});
/*
// Original API call - uncomment when backend is ready
try {
const path = `${this.basePath}/${id}/closing/check`;
return await httpClient<BaseApiResponse<CheckClosingResponse>>(path, {
method: 'GET',
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<CheckClosingResponse>>(error)) {
return error.response?.data;
}
return undefined;
}
*/
}
}
export const ProjectFlockKandangApi = new ProjectFlockKandangService(
'/production/project-flock-kandangs'
);
@@ -141,6 +141,38 @@ export class ProjectFlockService extends BaseApiService<
}
}
/**
* Resubmit Project Flock
*/
async resubmit(
id: number,
payload: UpdateProjectFlockPayload
): Promise<BaseApiResponse<ProjectFlock> | undefined> {
try {
const updatePath = `${this.basePath}/${id}/resubmit`;
const headers = {
'Content-Type': 'application/json',
...(this.header ?? {}),
};
const updateRes = await httpClient<BaseApiResponse<ProjectFlock>>(
updatePath,
{
method: 'PUT',
body: payload,
headers,
}
);
return updateRes;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<ProjectFlock>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Approve single Project Flock
*/
+40
View File
@@ -0,0 +1,40 @@
import { DrawerUISlice } from '@/types/stores';
import { StateCreator } from 'zustand';
export const createDrawerUISlice: StateCreator<
DrawerUISlice,
[],
[],
DrawerUISlice
> = (set, get, api) => ({
// event flag untuk memicu formik validate
triggerValidate: false,
// dibalik untuk memicu event
toggleValidate: () => {
const current = get().triggerValidate;
set({ triggerValidate: !current });
},
// sistem subscriber sederhana agar form bisa listen perubahan flag
subscribeValidate: (callback: () => void) => {
let prev = get().triggerValidate;
const unsub = api.subscribe((state) => {
if (state.triggerValidate !== prev) {
prev = state.triggerValidate;
callback();
}
});
return unsub;
},
isValid: false,
setIsValid: (isValid: boolean) => set({ isValid }),
subscribeIsValid: (callback: (isValid: boolean) => void) => {
return api.subscribe((state) => {
callback(Boolean(state.isValid));
});
},
});
+2
View File
@@ -5,11 +5,13 @@ import { devtools } from 'zustand/middleware';
import { UIStore } from '@/types/stores';
import { createMainUiSlice } from '@/stores/ui/slices/main.slice';
import { createDrawerUISlice } from '@/stores/ui/slices/drawer.slice';
export const useUiStore = create<UIStore>()(
devtools(
(...args) => ({
...createMainUiSlice(...args),
...createDrawerUISlice(...args),
}),
{
name: 'UIStore',
+48
View File
@@ -0,0 +1,48 @@
import { BaseMetadata, CreatedUser } from '@/types/api/api-general';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Supplier } from '@/types/api/master-data/supplier';
import { Uom } from '@/types/api/master-data/uom';
import { Location } from '@/types/api/master-data/location';
export type BaseInventoryProduct = {
id: number;
name: string;
brand: string;
sku: string;
product_price: number;
selling_price?: number;
tax?: number;
expiry_period?: number;
uom: Uom;
product_category: ProductCategory;
suppliers: Supplier[];
flags: string[];
product_warehouses?: ProductWarehouseStock[];
total_stock?: number;
};
export type ProductWarehouseStock = {
id: number;
product_id: number;
warehouse_id: number;
warehouse_name: string;
location: Location | null;
current_stock: number;
stock_logs: StockLog[];
};
export type StockLog = {
id: number;
increase: number;
decrease: number;
loggable_type: string;
loggable_id: number;
notes: string;
product_warehouse_id: number;
created_by: number;
created_user: CreatedUser;
created_at: string;
};
export type InventoryProduct = BaseInventoryProduct & BaseMetadata;
+22
View File
@@ -39,3 +39,25 @@ export type LookupProjectFlockKandangPayload = {
project_flock_id: number;
kandang_id: number;
};
export type ClosingProjectFlockKandangPayload = {
action: 'close' | 'unclose';
closed_date?: string; // YYYY-MM-DD, DD-MM-YYYY, or RFC3339
};
export type ClosingExpense = {
id: number;
po_number: string;
category: string;
total: number;
status: string;
step_name: string;
step: number;
reference_number: string;
};
export type CheckClosingResponse = {
unfinished_expenses: number;
stock_remaining: ProductWarehouse[];
expenses: ClosingExpense[];
};
+12
View File
@@ -4,6 +4,7 @@ import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang';
import { Location } from '@/types/api/master-data/location';
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
import { Nonstock } from '@/types/api/master-data/nonstock';
export type BaseProjectFlock = {
id: number;
@@ -22,6 +23,7 @@ export type BaseProjectFlock = {
kandangs: (Kandang & {
project_flock_kandang_id: number;
})[];
project_budgets?: ProjectFlockBudget[];
approval: BaseApproval;
};
@@ -30,6 +32,15 @@ export type PeriodFlock = {
next_period: number;
};
export type ProjectFlockBudget = {
id?: number;
project_flock_id?: number;
nonstock_id: number;
nonstock?: Nonstock;
qty: number;
price: number;
};
export type ProjectFlock = BaseMetadata & BaseProjectFlock;
export type CreateProjectFlockPayload = {
@@ -39,6 +50,7 @@ export type CreateProjectFlockPayload = {
fcr_id: number;
location_id: number;
kandang_ids: number[];
project_budgets?: ProjectFlockBudget[];
};
export type UpdateProjectFlockPayload = CreateProjectFlockPayload;
+10 -1
View File
@@ -3,4 +3,13 @@ type MainUiSlice = {
setMainDrawerOpen: (open: boolean) => void;
};
export type UIStore = MainUiSlice;
type DrawerUISlice = {
triggerValidate: boolean;
toggleValidate: () => void;
subscribeValidate: (callback: () => void) => void;
isValid: boolean;
setIsValid: (v: boolean) => void;
subscribeIsValid: (callback: (isValid: boolean) => void) => () => void;
};
export type UIStore = MainUiSlice & DrawerUISlice;