Merge branch 'feat/FE/US-161/TASK-208-212-slicing-ui-and-validation-create-purchase-request-form' into 'feat/FE/US-162/purchase-order'

[FEAT/FE][US#161|US#162] Add Feature Purchase Request and Purchase Order

See merge request mbugroup/lti-web-client!61
This commit is contained in:
Rivaldi A N S
2025-11-26 03:10:06 +00:00
27 changed files with 6150 additions and 121 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
npm run format
npm run lint
npm run build
npm run build
+11
View File
@@ -0,0 +1,11 @@
import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm';
const AddPurchaseRequest = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<PurchaseRequestForm />
</div>
);
};
export default AddPurchaseRequest;
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm';
import { PurchaseApi } from '@/services/api/purchase';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
const PurchaseEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const purchaseId = searchParams.get('purchaseId');
const { data: purchase, isLoading: isLoadingPurchase } = useSWR(
purchaseId,
(id: number) => PurchaseApi.getSingle(id)
);
if (!purchaseId) {
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 (!isLoadingPurchase && (!purchase || isResponseError(purchase))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingPurchase && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingPurchase && isResponseSuccess(purchase) && (
<PurchaseRequestForm type='edit' initialValues={purchase.data} />
)}
</div>
);
};
export default PurchaseEdit;
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import PurchaseOrderDetail from '@/components/pages/purchase/order/PurchaseOrderDetail';
import { PurchaseApi } from '@/services/api/purchase';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
const PurchaseDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const purchaseId = searchParams.get('purchaseId');
const {
data: purchase,
isLoading: isLoadingPurchase,
mutate: mutatePurchase,
} = useSWR(purchaseId, (id: number) => PurchaseApi.getSingle(id));
if (!purchaseId) {
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 (!isLoadingPurchase && (!purchase || isResponseError(purchase))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoadingPurchase && (
<div className='w-full flex flex-row justify-center items-center'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoadingPurchase && isResponseSuccess(purchase) && (
<PurchaseOrderDetail
type='detail'
initialValues={purchase.data}
refetchData={mutatePurchase}
/>
)}
</div>
);
};
export default PurchaseDetail;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+11
View File
@@ -0,0 +1,11 @@
import PurchaseTable from '@/components/pages/purchase/PurchaseTable';
const Purchase = () => {
return (
<section className='w-full p-4'>
<PurchaseTable />
</section>
);
};
export default Purchase;
+127 -33
View File
@@ -1,9 +1,11 @@
'use client';
import { HTMLAttributes, ReactNode } from 'react';
import { HTMLAttributes, ReactNode, useState } from 'react';
import { cn } from '@/lib/helper';
import Image from 'next/image';
import Collapse from './Collapse';
import { Icon } from '@iconify/react';
export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
@@ -11,8 +13,13 @@ export interface CardProps
subtitle?: string;
image?: string;
imageAlt?: string;
imageWidth?: number;
imageHeight?: number;
actions?: ReactNode;
footer?: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
className?: {
wrapper?: string;
image?: string;
@@ -21,6 +28,7 @@ export interface CardProps
subtitle?: string;
actions?: string;
footer?: string;
collapsible?: string;
};
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
size?: 'sm' | 'md' | 'lg';
@@ -31,14 +39,27 @@ const Card = ({
subtitle,
image,
imageAlt,
imageWidth,
imageHeight,
actions,
footer,
collapsible,
defaultCollapsed = false,
onCollapsedChange,
className,
variant = 'default',
size = 'md',
children,
...props
}: CardProps) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const handleCollapsedChange = (open: boolean) => {
const collapsed = !open;
setIsCollapsed(collapsed);
onCollapsedChange?.(collapsed);
};
const getCardClasses = () => {
const baseClasses = 'card bg-base-100';
@@ -64,11 +85,31 @@ const Card = ({
);
};
const getImageDimensions = () => {
if (variant === 'image-full') {
return {
width: imageWidth || 128,
height: imageHeight || 128,
};
}
const cardWidths = {
sm: 256, // w-64
md: 384, // w-96
lg: 448, // w-[28rem]
};
return {
width: imageWidth || cardWidths[size],
height: imageHeight || 192,
};
};
const getImageClasses = () => {
if (variant === 'image-full') {
return cn('w-32 h-32 object-cover', className?.image);
return cn('object-cover', className?.image);
}
return cn('h-48 object-cover', className?.image);
return cn('w-full object-cover', className?.image);
};
const getBodyClasses = () => {
@@ -103,45 +144,98 @@ const Card = ({
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
};
const renderCardContent = () => {
const hasContent = children || actions || footer;
const titleContent = (
<div className='group flex items-center !justify-between w-full'>
<div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
</div>
{collapsible && (
<button
onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
>
<Icon
icon={
isCollapsed
? 'material-symbols:expand-more'
: 'material-symbols:expand-less'
}
width={20}
/>
</button>
)}
</div>
);
const cardContent = (
<div className='space-y-4'>
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
return (
<>
{image && (
<figure>
<Image
src={image}
alt={imageAlt || title || 'Card image'}
width={getImageDimensions().width}
height={getImageDimensions().height}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{collapsible && hasContent ? (
<Collapse
variant='default'
bordered={false}
open={!isCollapsed}
onOpenChange={handleCollapsedChange}
title={titleContent}
titleClassName='w-full cursor-pointer'
contentClassName='p-0'
fullWidth={true}
>
{cardContent}
</Collapse>
) : (
<>
{(title || subtitle) && (
<div className='mb-4'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && (
<p className={getSubtitleClasses()}>{subtitle}</p>
)}
</div>
)}
{hasContent && cardContent}
</>
)}
</div>
</>
);
};
if (variant === 'image-full' && image) {
return (
<div className={getCardClasses()} {...props}>
<figure>
<Image
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
{renderCardContent()}
</div>
);
}
return (
<div className={getCardClasses()} {...props}>
{image && (
<figure>
<Image
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
{renderCardContent()}
</div>
);
};
+6 -2
View File
@@ -26,6 +26,9 @@ export type CollapseProps = {
disabled?: boolean;
/** Allow only one open at a time by switching to radio input */
asRadio?: boolean;
/** Force full width instead of auto-fit when collapsed
* (Khusus justify-between dan justify-end) */
fullWidth?: boolean;
/** Extra classnames */
className?: string;
titleClassName?: string;
@@ -44,6 +47,7 @@ export const Collapse = ({
bordered,
disabled,
asRadio = false,
fullWidth,
className,
titleClassName,
contentClassName,
@@ -68,9 +72,9 @@ export const Collapse = ({
'collapse',
variant === 'arrow' && 'collapse-arrow',
variant === 'plus' && 'collapse-plus',
bordered && 'border base-content/20 border-opacity-20 rounded',
bordered && 'border base-content/20 border-opacity-20 rounded-box',
disabled && 'opacity-60 pointer-events-none',
!open && 'w-fit',
!fullWidth && !open && 'w-fit',
className
);
+7 -3
View File
@@ -10,15 +10,19 @@ import {
} from 'react';
import { cn } from '@/lib/helper';
export const useModal = () => {
export const useModal = (isNestingModal = false) => {
const ref = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
const openModal = useCallback(() => {
if (!ref.current) return;
ref.current.show();
if (isNestingModal) {
ref.current.showModal();
} else {
ref.current.show();
}
setOpen(true);
}, []);
}, [isNestingModal]);
const closeModal = useCallback(() => {
if (!ref.current) return;
+6 -4
View File
@@ -7,10 +7,10 @@ import {
useState,
} from 'react';
import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '@/components/Modal';
import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import Button from '@/components/Button';
import Button from '../Button';
import { Icon } from '@iconify/react';
export interface DateInputProps {
@@ -34,6 +34,7 @@ export interface DateInputProps {
required?: boolean;
isLoading?: boolean;
isRange?: boolean;
isNestedModal?: boolean; // New prop to indicate if used inside another modal
errorMessage?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
@@ -58,6 +59,7 @@ const DateInput = ({
readOnly = false,
isLoading = false,
isRange = false,
isNestedModal = false,
}: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const [selected, setSelected] = useState<Date | undefined>();
@@ -74,7 +76,7 @@ const DateInput = ({
? new Date(max.split('/').reverse().join('-'))
: undefined;
const calendarModal = useModal();
const calendarModal = useModal(isNestedModal);
// --- Sync value props ---
useEffect(() => {
@@ -264,7 +266,7 @@ const DateInput = ({
ref={calendarModal.ref}
className={{
modal: 'rounded',
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
modalBox: `!max-w-max min-h-${isRange ? '124' : '110'} flex flex-col`,
}}
closeOnBackdrop
>
@@ -9,12 +9,10 @@ import { Icon } from '@iconify/react';
import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory';
import { cn } from '@/lib/helper';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput from '@/components/input/SelectInput';
@@ -52,38 +50,15 @@ const MovementTable = () => {
} = useTableFilter({
initial: {
search: '',
product: '',
warehouse: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
product: 'product_id',
warehouse: 'warehouse_id',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
} = useSelect<Product>('/products', 'id', 'name');
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
} = useSelect<Warehouse>('/warehouses', 'id', 'name');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
null
);
const { data: movements, isLoading } = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher
@@ -99,16 +74,6 @@ const MovementTable = () => {
setPage(1);
};
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedProduct(val as OptionType);
updateFilter('product', val ? ((val as OptionType).value as string) : '');
};
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedWarehouse(val as OptionType);
updateFilter('warehouse', val ? ((val as OptionType).value as string) : '');
};
const movementColumns: ColumnDef<Movement>[] = [
{
header: '#',
@@ -200,33 +165,7 @@ const MovementTable = () => {
/>
</div>
<div className='grid grid-cols-12 justify-end gap-4'>
<SelectInput
label='Produk'
options={productOptions}
isLoading={isLoadingProductOptions}
value={selectedProduct}
onChange={productChangeHandler}
onInputChange={setProductInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<div className='flex justify-end gap-4'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
@@ -171,7 +171,17 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(),
destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!'),
.typeError('Gudang tujuan wajib diisi!')
.test(
'different-warehouse',
'Gudang tujuan tidak boleh sama dengan gudang asal!',
function (value) {
const { source_warehouse_id } = this.parent;
return (
!value || !source_warehouse_id || value !== source_warehouse_id
);
}
),
products: Yup.array()
.of(ProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!')
@@ -8,6 +8,7 @@ import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
@@ -26,6 +27,7 @@ import {
DeliverySchema,
} from '@/components/pages/inventory/movement/form/MovementForm.schema';
import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { ProductWarehouseApi } from '@/services/api/inventory';
import { toast } from 'react-hot-toast';
import { MovementApi } from '@/services/api/inventory';
@@ -100,11 +102,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
// ===== SELECT INPUT DATA =====
const {
setInputValue: setSupplierSelectInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
} = useSelect(SupplierApi.basePath, 'id', 'name', 'search');
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'BOP',
});
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
@@ -171,6 +176,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
enableReinitialize: true,
onSubmit: async (values) => {
setMovementFormErrorMessage('');
if (values.source_warehouse_id === values.destination_warehouse_id) {
const sourceWarehouseName =
(values.source_warehouse as WarehouseOptionType)?.label ||
'Gudang asal';
const destinationWarehouseName =
(values.destination_warehouse as WarehouseOptionType)?.label ||
'gudang tujuan';
setMovementFormErrorMessage(
`Tidak bisa submit form. ${sourceWarehouseName} tidak boleh sama dengan ${destinationWarehouseName}.`
);
toast.error(
`Tidak bisa submit form. Gudang asal dan tujuan tidak boleh sama!`
);
return;
}
const documents: File[] = [];
const deliveriesPayload = values.deliveries.map((d) => {
let documentIndex = 0;
@@ -305,6 +326,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
};
};
const handleTransferDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value);
};
// ===== EVENT HANDLERS =====
// Product Handlers
const addProduct = () => {
@@ -745,6 +770,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}
}, [formik.values.source_warehouse_id]);
useEffect(() => {
if (
formik.values.source_warehouse_id &&
formik.values.destination_warehouse_id &&
formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id
) {
formik.setFieldError(
'destination_warehouse_id',
'Gudang tujuan tidak boleh sama dengan gudang asal!'
);
} else {
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}
}, [
formik.values.source_warehouse_id,
formik.values.destination_warehouse_id,
formik.errors.destination_warehouse_id,
]);
return (
<>
<section className='w-full'>
@@ -792,13 +842,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
errorMessage={formik.errors.transfer_reason}
readOnly={type === 'detail'}
/>
<TextInput
<DateInput
required
label='Tanggal Transfer'
type='date'
name='transfer_date'
value={formik.values.transfer_date}
onChange={formik.handleChange}
onChange={handleTransferDateChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transfer_date &&
@@ -825,13 +874,41 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse}
onChange={(val) => {
const newSourceWarehouseId = (val as WarehouseOptionType)
?.value;
if (newSourceWarehouseId) {
if (
newSourceWarehouseId ===
formik.values.destination_warehouse_id
) {
const destinationWarehouseName =
(
formik.values
.destination_warehouse as WarehouseOptionType
)?.label || 'gudang tujuan';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
);
return;
}
}
formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue(
'source_warehouse_id',
(val as WarehouseOptionType)?.value
newSourceWarehouseId
);
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
@@ -896,13 +973,39 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
placeholder='Pilih gudang tujuan...'
value={formik.values.destination_warehouse}
onChange={(val) => {
const newDestinationWarehouseId = (val as WarehouseOptionType)
?.value;
if (newDestinationWarehouseId) {
if (
newDestinationWarehouseId ===
formik.values.source_warehouse_id
) {
const sourceWarehouseName =
(formik.values.source_warehouse as WarehouseOptionType)
?.label || 'gudang asal';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
);
return;
}
}
formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
(val as WarehouseOptionType)?.value
newDestinationWarehouseId
);
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
@@ -1622,7 +1725,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
hasInvalidQty ||
hasExceededStock ||
!formik.isValid ||
formik.isSubmitting
formik.isSubmitting ||
(formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id &&
formik.values.source_warehouse_id !== 0 &&
formik.values.destination_warehouse_id !== 0)
}
>
Submit
@@ -10,7 +10,9 @@ export const ProductCategoryFormSchema: Yup.ObjectSchema<ProductCategoryFormSche
code: Yup.string()
.required('Kode wajib diisi!')
.max(3, 'Kode kategori produk melebihi 3 karakter!'),
name: Yup.string().required('Nama wajib diisi!'),
name: Yup.string()
.required('Nama wajib diisi!')
.max(50, 'Nama kategori produk melebihi 50 karakter!'),
});
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
@@ -0,0 +1,338 @@
'use client';
import { ChangeEventHandler, useCallback, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatDate, formatCurrency } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { Purchase } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
// ===== INTERFACES =====
interface RowOptionsMenuProps {
type: 'dropdown' | 'collapse';
props: CellContext<Purchase, unknown>;
deleteClickHandler: () => void;
}
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: RowOptionsMenuProps) => {
return (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/purchase/detail/?purchaseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
{/*<Button*/}
{/* href={`/purchase/detail/edit/?purchaseId=${props.row.original.id}`}*/}
{/* variant='ghost'*/}
{/* color='warning'*/}
{/* className='justify-start text-sm'*/}
{/*>*/}
{/* <Icon icon='material-symbols:edit-outline' width={16} height={16} />*/}
{/* Edit*/}
{/*</Button>*/}
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RowOptionsMenuWrapper>
);
};
const PurchaseTable = () => {
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [sorting, setSorting] = useState<SortingState>([]);
// ===== TABLE FILTER STATE =====
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
// ===== MODAL HOOKS =====
const deleteModal = useModal();
// ===== API DATA FETCHING =====
const {
data: purchaseRequests,
isLoading,
mutate: refreshPurchaseRequests,
} = useSWR(
`${PurchaseApi.basePath}${getTableFilterQueryString()}`,
PurchaseApi.getAllFetcher
);
// ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [
{
header: 'No. PR/PO',
cell: (props) => {
const { pr_number, po_number } = props.row.original;
return po_number ? po_number : pr_number;
},
},
{
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
{
accessorKey: 'po_date',
header: 'Tgl. PO',
cell: (props) =>
props.row.original.po_date
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
: '-',
},
{
header: 'Aging',
cell: (props) => {
const purchase = props.row.original;
if (!purchase.po_date) return '-';
const poDate = new Date(purchase.po_date);
const today = new Date();
const diffTime = Math.abs(today.getTime() - poDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return `${diffDays} hari`;
},
},
{
accessorKey: 'grand_total',
header: 'Total (Rp.)',
cell: (props) => formatCurrency(props.row.original.grand_total),
},
{
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 deleteClickHandler = () => {
setSelectedPurchase(props.row.original);
deleteModal.openModal();
};
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>
)}
</>
);
},
},
];
// ===== EVENT HANDLERS =====
const confirmationModalDeleteClickHandler = useCallback(async () => {
setIsDeleteLoading(true);
try {
await PurchaseApi.delete(selectedPurchase?.id as number);
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} catch {
toast.error('Gagal menghapus data permintaan pembelian!');
}
setIsDeleteLoading(false);
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
updateFilter('search', e.target.value);
},
[updateFilter]
);
const pageSizeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
},
[setPageSize]
);
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 xl:flex-row justify-between items-end xl:items-center gap-2'>
<div className='w-full flex flex-row gap-2'>
<Button
href='/purchase/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Pembelian'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{
wrapper: 'sm:max-w-3xs',
}}
/>
</div>
<div className='flex flex-wrap justify-end gap-4'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'w-full sm:w-24',
}}
/>
</div>
</div>
<Table<Purchase>
data={
isResponseSuccess(purchaseRequests) ? purchaseRequests?.data : []
}
columns={purchaseColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.page
: 0
}
totalItems={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(purchaseRequests) &&
purchaseRequests?.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>
{/* ===== MODAL COMPONENTS ===== */}
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data permintaan pembelian ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default PurchaseTable;
@@ -0,0 +1,758 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useRouter } from 'next/navigation';
import {
PurchaseRequestAcceptApprovalFormDefaultValues,
PurchaseRequestAcceptApprovalFormInitialValues,
PurchaseRequestAcceptApprovalFormSchema,
} from './PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper';
import { PurchaseApi } from '@/services/api/purchase';
import {
CreateAcceptApprovalRequestPayload,
Purchase,
} from '@/types/api/purchase/purchase';
import DateInput from '@/components/input/DateInput';
import { formatNumber } from '@/lib/helper';
import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data';
interface PurchaseOrderAcceptApprovalFormProps {
type?: 'add' | 'edit';
initialValues?: Purchase;
onCancel?: () => void;
refreshApprovals?: () => void;
onModalClose?: () => void;
onRefetchData?: () => void;
}
const PurchaseOrderAcceptApprovalForm = ({
type = 'add',
initialValues,
onCancel,
refreshApprovals,
onModalClose,
onRefetchData,
}: PurchaseOrderAcceptApprovalFormProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState('');
// ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = (
idx: number,
field:
| 'purchase_item_id'
| 'received_date'
| 'travel_number'
| 'travel_document_path'
| 'vehicle_number'
| 'expedition_vendor_id'
| 'received_qty'
| 'transport_per_item'
| 'transport_total'
): { isError: boolean; errorMessage: string } => {
const touchedItem = formik.touched.items?.[idx];
const errorItem = formik.errors.items?.[idx] as
| Record<string, string>
| undefined;
if (!touchedItem) {
return { isError: false, errorMessage: '' };
}
const isTouched = (touchedItem as Record<string, boolean>)?.[field];
const errorMessage = errorItem?.[field] || '';
return {
isError: Boolean(isTouched && errorMessage),
errorMessage: isTouched && errorMessage ? errorMessage : '',
};
};
// ===== SUBMISSION HANDLERS =====
const createAcceptApprovalHandler = useCallback(
async (payload: CreateAcceptApprovalRequestPayload) => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequestId) {
setPurchaseOrderFormErrorMessage('Purchase Request ID is required');
return;
}
const res = await PurchaseApi.acceptApproval.create(
purchaseRequestId,
payload
);
if (isResponseError(res)) {
setPurchaseOrderFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
refreshApprovals?.();
onRefetchData?.();
formik.resetForm();
onCancel?.();
onModalClose?.();
router.refresh();
},
[
initialValues?.id,
searchParams,
refreshApprovals,
onModalClose,
onRefetchData,
]
);
const updateAcceptApprovalHandler = useCallback(
async (purchaseId: number, payload: CreateAcceptApprovalRequestPayload) => {
const res = await PurchaseApi.acceptApproval.create(purchaseId, payload);
if (isResponseError(res)) {
setPurchaseOrderFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
refreshApprovals?.();
onRefetchData?.();
formik.resetForm();
onCancel?.();
onModalClose?.();
router.refresh();
},
[refreshApprovals, onModalClose, onRefetchData]
);
// ===== SELECT INPUT DATA =====
const {
setInputValue: setExpeditionsSelectInputValue,
options: expeditionVendors,
isLoadingOptions: isLoadingExpeditions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'BOP',
});
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo(() => {
return initialValues
? PurchaseRequestAcceptApprovalFormDefaultValues(initialValues)
: PurchaseRequestAcceptApprovalFormInitialValues;
}, [initialValues]);
const formik = useFormik({
initialValues: formikInitialValues,
validationSchema: PurchaseRequestAcceptApprovalFormSchema,
validateOnChange: true,
validateOnBlur: true,
onSubmit: async (values) => {
const payload: CreateAcceptApprovalRequestPayload = {
notes: values.notes || '',
items:
values.items?.map((formItem) => {
return {
purchase_item_id: formItem.purchase_item_id || 0,
received_date: formItem.received_date || '',
travel_number: formItem.travel_number || '',
travel_document_path: formItem.travel_document_path || '',
vehicle_number: formItem.vehicle_number || '',
expedition_vendor_id: formItem.expedition_vendor_id || 0,
received_qty:
typeof formItem.received_qty === 'string'
? parseFloat(formItem.received_qty) || 0
: formItem.received_qty || 0,
transport_per_item:
typeof formItem.transport_per_item === 'string'
? parseFloat(formItem.transport_per_item) || 0
: formItem.transport_per_item || 0,
transport_total:
typeof formItem.transport_total === 'string'
? parseFloat(formItem.transport_total) || 0
: formItem.transport_total || 0,
};
}) || [],
};
switch (type) {
case 'add':
await createAcceptApprovalHandler(payload);
break;
case 'edit':
await updateAcceptApprovalHandler(
initialValues?.id as number,
payload
);
break;
}
},
});
// ===== API DATA FETCHING =====
const purchaseItems = useMemo(() => {
if (initialValues?.items) {
return initialValues.items.map((item) => ({
value: item.id,
label: item.product.name,
id: item.id,
quantity: item.sub_qty,
product: {
name: item.product.name,
product_category: item.product.product_category || '',
uom: item.product.uom || { name: '' },
},
warehouse: {
name: item.warehouse?.name || '',
},
}));
}
return [];
}, [initialValues?.items]);
useEffect(() => {
if (purchaseItems.length > 0 && initialValues?.items) {
const updatedItems = initialValues.items.map((item) => {
return {
purchase_item: null,
purchase_item_id: item.id,
received_date: item.received_date
? new Date(item.received_date).toISOString().split('T')[0]
: '',
travel_number: item.travel_number || '',
travel_document_path: item.travel_document_path || '',
vehicle_number: item.vehicle_number || '',
expedition_vendor: null,
expedition_vendor_id: 0,
received_qty: '',
transport_per_item: '',
transport_total: '',
};
});
formik.setFieldValue('items', updatedItems);
}
}, [purchaseItems, initialValues]);
// ===== HELPER FUNCTIONS =====
const getQuantityExceededError = useCallback(
(idx: number, receivedQty: number) => {
if (!receivedQty) return null;
const originalQty = purchaseItems[idx]?.quantity || 0;
if (receivedQty > originalQty) {
return `Tidak boleh melebihi ${formatNumber(originalQty)}`;
}
return null;
},
[purchaseItems]
);
const hasQuantityExceededErrors = useMemo(() => {
if (!formik.values.items || purchaseItems.length === 0) return false;
return formik.values.items.some((item, idx) => {
if (!item.received_qty) return false;
const receivedQty =
typeof item.received_qty === 'string'
? parseFloat(item.received_qty) || 0
: item.received_qty;
const originalQty = purchaseItems[idx]?.quantity || 0;
return receivedQty > originalQty;
});
}, [formik.values.items, purchaseItems]);
const getExpeditionVendorOptions = useCallback(() => {
return expeditionVendors;
}, [expeditionVendors]);
// ===== FIELD CHANGE HANDLERS =====
const expeditionVendorChangeHandler = (
idx: number,
val: OptionType | OptionType[] | null
) => {
const expeditionVendor = val as OptionType | null;
formik.setFieldTouched(`items.${idx}.expedition_vendor`, true);
formik.setFieldValue(`items.${idx}.expedition_vendor`, expeditionVendor);
formik.setFieldTouched(`items.${idx}.expedition_vendor_id`, true);
formik.setFieldValue(
`items.${idx}.expedition_vendor_id`,
expeditionVendor?.value || 0
);
};
// ===== PURCHASE ITEM OPERATIONS =====
const handlePurchaseItemChange = (
idx: number,
field: 'received_qty' | 'transport_per_item' | 'transport_total',
value: string | number
) => {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
formik.setFieldValue(`items.${idx}.${field}`, numValue);
if (field === 'received_qty' || field === 'transport_per_item') {
const receivedQty =
field === 'received_qty'
? numValue
: parseFloat(formik.values.items?.[idx]?.received_qty as string) || 0;
const transportPerItem =
field === 'transport_per_item'
? numValue
: parseFloat(
formik.values.items?.[idx]?.transport_per_item as string
) || 0;
if (receivedQty > 0 && transportPerItem >= 0) {
const calculatedTransportTotal = receivedQty * transportPerItem;
formik.setFieldValue(
`items.${idx}.transport_total`,
calculatedTransportTotal
);
}
}
if (field === 'transport_total') {
const receivedQty =
parseFloat(formik.values.items?.[idx]?.received_qty as string) || 0;
if (receivedQty > 0 && numValue >= 0) {
const calculatedTransportPerItem = numValue / receivedQty;
formik.setFieldValue(
`items.${idx}.transport_per_item`,
calculatedTransportPerItem
);
}
}
};
return (
<form onSubmit={formik.handleSubmit} className='w-full flex flex-col gap-6'>
<div className='w-full'>
<h2 className='text-lg font-semibold mb-4'>
{type === 'add'
? 'Konfirmasi Penerimaan Produk'
: 'Edit Penerimaan Produk'}
</h2>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Produk</th>
<th>Gudang</th>
<th>Jumlah</th>
<th>Satuan</th>
<th>
Tanggal Diterima
<span className='text-error'>*</span>
</th>
<th>
No. Surat Jalan
<span className='text-error'>*</span>
</th>
<th>
Dokumen Surat Jalan
<span className='text-error'>*</span>
</th>
<th>
Nomor Kendaraan
<span className='text-error'>*</span>
</th>
<th>
Vendor Ekspedisi
<span className='text-error'>*</span>
</th>
<th>
Jumlah Diterima
<span className='text-error'>*</span>
</th>
<th>
Transport/Item
<span className='text-error'>*</span>
</th>
<th>
Total Transport
<span className='text-error'>*</span>
</th>
</tr>
</thead>
<tbody>
{purchaseItems?.map((purchaseItem, idx) => {
const formItem = formik.values.items?.[idx];
return (
<tr key={`purchase-item-${idx}`}>
<td>
<TextInput
name={`items.${idx}.product_name`}
type='text'
value={purchaseItem?.product?.name || ''}
readOnly={true}
className={{
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
}}
disabled={true}
/>
<input
type='hidden'
name={`items.${idx}.purchase_item_id`}
value={purchaseItem?.value || 0}
/>
</td>
<td>
<TextInput
name={`items.${idx}.warehouse`}
type='text'
value={purchaseItem?.warehouse?.name || ''}
readOnly={true}
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
disabled={true}
/>
</td>
<td>
<TextInput
name={`items.${idx}.quantity`}
type='text'
value={
purchaseItem?.quantity
? formatNumber(purchaseItem.quantity)
: ''
}
readOnly={true}
className={{
wrapper: 'min-w-32',
}}
disabled={true}
/>
</td>
<td>
<TextInput
name={`items.${idx}.uom`}
type='text'
value={purchaseItem?.product?.uom?.name || ''}
readOnly={true}
className={{
wrapper: 'min-w-24',
}}
disabled={true}
/>
</td>
<td>
<DateInput
required
isNestedModal={true}
name={`items.${idx}.received_date`}
value={formItem?.received_date || ''}
onChange={(e) =>
formik.setFieldValue(
`items.${idx}.received_date`,
e.target.value
)
}
onBlur={formik.handleBlur}
isError={
isRepeaterInputError(idx, 'received_date').isError
}
errorMessage={
isRepeaterInputError(idx, 'received_date')
.errorMessage
}
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.travel_number`}
type='text'
value={formItem?.travel_number || ''}
onChange={(e) =>
formik.setFieldValue(
`items.${idx}.travel_number`,
e.target.value
)
}
onBlur={formik.handleBlur}
isError={
isRepeaterInputError(idx, 'travel_number').isError
}
errorMessage={
isRepeaterInputError(idx, 'travel_number')
.errorMessage
}
placeholder='Masukkan no. surat jalan'
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.travel_document_path`}
type='text'
value={formItem?.travel_document_path || ''}
onChange={(e) =>
formik.setFieldValue(
`items.${idx}.travel_document_path`,
e.target.value
)
}
onBlur={formik.handleBlur}
isError={
isRepeaterInputError(idx, 'travel_document_path')
.isError
}
errorMessage={
isRepeaterInputError(idx, 'travel_document_path')
.errorMessage
}
placeholder='Masukkan path dokumen'
className={{
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
}}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.vehicle_number`}
type='text'
value={formItem?.vehicle_number || ''}
onChange={(e) =>
formik.setFieldValue(
`items.${idx}.vehicle_number`,
e.target.value
)
}
onBlur={formik.handleBlur}
isError={
isRepeaterInputError(idx, 'vehicle_number').isError
}
errorMessage={
isRepeaterInputError(idx, 'vehicle_number')
.errorMessage
}
placeholder='Masukkan nomor kendaraan'
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
<td>
<SelectInput
required
isClearable={true}
value={formItem?.expedition_vendor}
key={`expedition-vendor-${idx}`}
onChange={(val) =>
expeditionVendorChangeHandler(idx, val)
}
options={getExpeditionVendorOptions()}
isError={
isRepeaterInputError(idx, 'expedition_vendor_id')
.isError
}
errorMessage={
isRepeaterInputError(idx, 'expedition_vendor_id')
.errorMessage
}
placeholder='Pilih Vendor...'
className={{
wrapper: 'min-w-48 md:min-w-64 lg:min-w-72',
}}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.received_qty`}
value={formItem?.received_qty || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'received_qty',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan jumlah diterima'
allowNegative={false}
decimalScale={0}
thousandSeparator=','
decimalSeparator='.'
bottomLabel={`Total: ${purchaseItems[idx]?.quantity ? formatNumber(purchaseItems[idx].quantity) : 0}`}
isError={
isRepeaterInputError(idx, 'received_qty').isError ||
(formItem?.received_qty
? getQuantityExceededError(
idx,
Number(formItem.received_qty)
) !== null
: false)
}
errorMessage={
isRepeaterInputError(idx, 'received_qty')
.errorMessage ||
(formItem?.received_qty
? getQuantityExceededError(
idx,
Number(formItem.received_qty)
) || undefined
: undefined)
}
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.transport_per_item`}
value={formItem?.transport_per_item || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'transport_per_item',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan transport/item'
allowNegative={false}
decimalScale={2}
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
isError={
isRepeaterInputError(idx, 'transport_per_item')
.isError
}
errorMessage={
isRepeaterInputError(idx, 'transport_per_item')
.errorMessage
}
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.transport_total`}
value={formItem?.transport_total || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'transport_total',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan total transport'
allowNegative={false}
decimalScale={2}
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
isError={
isRepeaterInputError(idx, 'transport_total').isError
}
errorMessage={
isRepeaterInputError(idx, 'transport_total')
.errorMessage
}
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className={'col-span-2'}>
<TextInput
label='Notes'
name='notes'
value={formik.values.notes || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes as string}
placeholder='Masukkan catatan'
/>
</div>
{/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap mt-5'>
<div className='flex flex-row justify-end gap-2 w-full'>
<Button
type='button'
color='warning'
className='px-4'
onClick={() => {
formik.resetForm();
setPurchaseOrderFormErrorMessage('');
onCancel?.();
onModalClose?.();
}}
>
Cancel
</Button>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={
!formik.isValid ||
formik.isSubmitting ||
hasQuantityExceededErrors
}
>
Submit
</Button>
</div>
</div>
{purchaseOrderFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseOrderFormErrorMessage}</span>
</div>
)}
</div>
</form>
);
};
export default PurchaseOrderAcceptApprovalForm;
@@ -0,0 +1,450 @@
import * as Yup from 'yup';
import { Purchase } from '@/types/api/purchase/purchase';
type PurchaseRequestStaffApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null;
items: {
purchase_item_id?: number;
product_id?: number | null;
warehouse_id?: number | null;
product?: {
value?: number;
label?: string;
} | null;
warehouse?: {
value?: number;
label?: string;
} | null;
qty: number;
price: number | string;
total_price: number | string;
}[];
};
type PurchaseRequestManagerApprovalFormSchemaType = {
notes: string | null;
};
type PurchaseRequestAcceptApprovalFormSchemaType = {
notes: string | null;
items: {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
travel_document_path: string;
vehicle_number: string;
expedition_vendor?: {
value: number;
label: string;
} | null;
expedition_vendor_id: number;
received_qty: number | string;
transport_per_item: number | string;
transport_total: number | string;
}[];
};
export type PurchaseStaffApprovalItemSchema = {
purchase_item_id?: number;
product_id?: number | null;
warehouse_id?: number | null;
product?: {
value?: number;
label?: string;
} | null;
warehouse?: {
value?: number;
label?: string;
} | null;
qty: number;
price: number | string;
total_price: number | string;
};
export type PurchaseAcceptApprovalItemSchema = {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
travel_document_path: string;
vehicle_number: string;
expedition_vendor?: {
value: number;
label: string;
} | null;
expedition_vendor_id: number;
received_qty: number | string;
transport_per_item: number | string;
transport_total: number | string;
};
export type PurchaseDeleteItemsSchema = {
item_ids: number[];
};
const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseStaffApprovalItemSchema> =
Yup.object({
purchase_item_id: Yup.number()
.optional()
.min(0, 'Purchase item ID tidak valid!')
.typeError('Purchase item ID harus berupa angka!'),
product: Yup.object({
value: Yup.number(),
label: Yup.string(),
})
.nullable()
.optional(),
product_id: Yup.number()
.optional()
.nullable()
.typeError('Product ID harus berupa angka!'),
warehouse: Yup.object({
value: Yup.number(),
label: Yup.string(),
})
.nullable()
.optional(),
warehouse_id: Yup.number()
.optional()
.nullable()
.typeError('Warehouse ID harus berupa angka!'),
qty: Yup.number()
.required('Jumlah wajib diisi!')
.min(1, 'Jumlah harus berupa angka lebih dari 0!')
.typeError('Jumlah harus berupa angka lebih dari 0!'),
price: Yup.mixed<number | string>()
.required('Harga wajib diisi!')
.test(
'is-valid-price',
'Harga harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
)
.typeError('Harga harus berupa angka!'),
total_price: Yup.mixed<number | string>()
.required('Total harga wajib diisi!')
.test(
'is-valid-total-price',
'Total harga harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
)
.typeError('Total harga harus berupa angka!'),
});
const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> =
Yup.object({
notes: Yup.string().nullable().default(null),
});
const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApprovalItemSchema> =
Yup.object({
purchase_item: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
purchase_item_id: Yup.number()
.min(1, 'Purchase item is required!')
.required('Purchase item is required!')
.typeError('Purchase item is required!')
.test(
'is-valid-purchase-item',
'Purchase item must be selected!',
function (value) {
return Boolean(value && value > 0);
}
),
received_date: Yup.string()
.required('Tanggal penerimaan wajib diisi!')
.typeError('Tanggal penerimaan wajib diisi!'),
travel_number: Yup.string()
.required('No. Surat jalan wajib diisi!')
.typeError('No. Surat jalan wajib diisi!'),
travel_document_path: Yup.string()
.required('Dokumen Surat jalan wajib diisi!')
.typeError('Dokumen Surat jalan wajib diisi!'),
vehicle_number: Yup.string()
.required('Nomor kendaraan wajib diisi!')
.typeError('Nomor kendaraan wajib diisi!'),
expedition_vendor: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
expedition_vendor_id: Yup.number()
.min(1, 'Vendor ekspedisi wajib diisi!')
.required('Vendor ekspedisi wajib diisi!')
.test(
'is-valid-expedition-vendor',
'Vendor ekspedisi harus dipilih!',
function (value) {
if (!this.parent.expedition_vendor) return true;
return Boolean(value && value > 0);
}
)
.typeError('Vendor ekspedisi harus dipilih!'),
received_qty: Yup.mixed<string | number>()
.required('Jumlah diterima wajib diisi!')
.test(
'is-valid-received-qty',
'Harus berupa angka lebih dari 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue > 0;
}
)
.typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>()
.required('Biaya transport per item wajib diisi!')
.test(
'is-valid-transport-per-item',
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
)
.typeError('Biaya transport per item harus berupa angka!'),
transport_total: Yup.mixed<string | number>()
.required('Total biaya transport wajib diisi!')
.test(
'is-valid-transport-total',
'Total biaya transport harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
)
.typeError('Total biaya transport harus berupa angka!'),
});
export const PurchaseRequestStaffApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestStaffApprovalFormSchemaType> =
Yup.object({
action: Yup.mixed<'APPROVED' | 'REJECTED'>()
.oneOf(['APPROVED', 'REJECTED'], 'Action harus APPROVED atau REJECTED')
.required('Action wajib diisi!')
.default('APPROVED'),
notes: Yup.string().nullable().default(null),
items: Yup.array()
.of(PurchaseStaffApprovalItemObjectSchema)
.min(1, 'Minimal harus ada 1 item pembelian!')
.required('Item pembelian wajib diisi!')
.typeError('Item pembelian wajib diisi!')
.test(
'items-validation',
'Setiap item harus valid: existing items hanya butuh purchase_item_id, new items butuh product_id & warehouse_id',
function (items) {
if (!items || items.length === 0) return false;
return items.every((item) => {
const isExisting =
item.purchase_item_id && item.purchase_item_id > 0;
const isNew = !item.purchase_item_id || item.purchase_item_id === 0;
if (isExisting) {
return true;
}
if (isNew) {
return Boolean(
item.product_id &&
item.product_id > 0 &&
item.warehouse_id &&
item.warehouse_id > 0
);
}
return false;
});
}
),
});
export const PurchaseDeleteItemsSchema: Yup.ObjectSchema<PurchaseDeleteItemsSchema> =
Yup.object({
item_ids: Yup.array()
.of(
Yup.number()
.min(1, 'Item ID tidak valid!')
.required('Item ID tidak valid!')
.typeError('Item ID tidak valid!')
)
.min(1, 'Minimal harus ada 1 item yang dihapus!')
.required('Item yang dihapus wajib diisi!')
.typeError('Item yang dihapus wajib diisi!'),
});
export const PurchaseRequestStaffApprovalFormInitialValues: PurchaseRequestStaffApprovalFormSchemaType =
{
action: 'APPROVED',
notes: '',
items: [
{
product_id: 0,
warehouse_id: 0,
product: null,
warehouse: null,
qty: 0,
price: '',
total_price: '',
},
],
};
export const PurchaseRequestStaffApprovalFormDefaultValues = (
purchase?: Purchase
): PurchaseRequestStaffApprovalFormSchemaType => {
return {
action: 'APPROVED',
notes: purchase?.notes ?? null,
items: purchase?.items
? purchase.items.map((item) => ({
purchase_item_id: item.id,
product_id: item.product_id || 0,
warehouse_id: item.warehouse?.id || 0,
product: {
value: item.product_id || 0,
label: item.product?.name || '',
},
warehouse: {
value: item.warehouse?.id || 0,
label: item.warehouse?.name || '',
},
qty: item.sub_qty || item.qty || 0,
price: item.price,
total_price: item.total_price,
}))
: [
{
product_id: 0,
warehouse_id: 0,
product: null,
warehouse: null,
qty: 0,
price: '',
total_price: '',
},
],
};
};
export type PurchaseRequestStaffApprovalFormValues = Yup.InferType<
typeof PurchaseRequestStaffApprovalFormSchema
>;
export const PurchaseRequestManagerApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> =
PurchaseManagerApprovalObjectSchema;
export const PurchaseRequestManagerApprovalFormDefaultValues = (
purchase?: Purchase
): PurchaseRequestManagerApprovalFormSchemaType => {
return {
notes: purchase?.notes ?? null,
};
};
export type PurchaseRequestManagerApprovalFormValues = Yup.InferType<
typeof PurchaseRequestManagerApprovalFormSchema
>;
export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestAcceptApprovalFormSchemaType> =
Yup.object({
notes: Yup.string().nullable().default(null),
items: Yup.array()
.of(PurchaseAcceptApprovalItemObjectSchema)
.min(1, 'Minimal harus ada 1 item pembelian!')
.required('Item pembelian wajib diisi!')
.typeError('Item pembelian wajib diisi!'),
});
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
{
notes: '',
items: [
{
purchase_item_id: 0,
received_date: '',
travel_number: '',
travel_document_path: '',
vehicle_number: '',
expedition_vendor_id: 0,
received_qty: '',
transport_per_item: '',
transport_total: '',
},
],
};
export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase?: Purchase
): PurchaseRequestAcceptApprovalFormSchemaType => {
return {
notes: purchase?.notes ?? null,
items: purchase?.items
? purchase.items.map((item) => ({
purchase_item_id: item.id,
received_date: '',
travel_number: '',
travel_document_path: '',
vehicle_number: '',
expedition_vendor_id: 0,
received_qty: '',
transport_per_item: '',
transport_total: '',
}))
: [
{
purchase_item_id: 0,
received_date: '',
travel_number: '',
travel_document_path: '',
vehicle_number: '',
expedition_vendor_id: 0,
received_qty: '',
transport_per_item: '',
transport_total: '',
},
],
};
};
export type PurchaseRequestAcceptApprovalFormValues = Yup.InferType<
typeof PurchaseRequestAcceptApprovalFormSchema
>;
export const PurchaseDeleteItemsInitialValues: PurchaseDeleteItemsSchema = {
item_ids: [],
};
export type PurchaseDeleteItemsFormValues = Yup.InferType<
typeof PurchaseDeleteItemsSchema
>;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
import * as Yup from 'yup';
import { Purchase } from '@/types/api/purchase/purchase';
type PurchaseRequestFormSchemaType = {
supplier?: {
value: number;
label: string;
} | null;
supplier_id: number;
credit_term: number;
area?: {
value: number;
label: string;
} | null;
area_id: number | undefined;
location?: {
value: number;
label: string;
} | null;
location_id: number | undefined;
notes: string | null;
items: {
warehouse?: {
value: number;
label: string;
} | null;
warehouse_id: number;
product?: {
value: number;
label: string;
} | null;
product_id: number;
qty: number;
}[];
};
export type PurchaseItemSchema = {
warehouse?: {
value: number;
label: string;
} | null;
warehouse_id: number;
product?: {
value: number;
label: string;
} | null;
product_id: number;
qty: number;
};
const PurchaseItemObjectSchema: Yup.ObjectSchema<PurchaseItemSchema> =
Yup.object({
warehouse: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
warehouse_id: Yup.number()
.required('Gudang wajib dipilih!')
.min(1, 'Gudang wajib dipilih!')
.typeError('Gudang wajib dipilih!'),
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number()
.required('Produk wajib dipilih!')
.min(1, 'Produk wajib dipilih!')
.typeError('Produk wajib dipilih!'),
qty: Yup.number()
.required('Kuantitas wajib diisi!')
.min(1, 'Kuantitas tidak boleh kurang dari 1!')
.typeError('Kuantitas wajib diisi!'),
});
export const PurchaseRequestFormSchema: Yup.ObjectSchema<PurchaseRequestFormSchemaType> =
Yup.object({
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
credit_term: Yup.number()
.required('Jangka waktu kredit wajib diisi!')
.min(0, 'Jangka waktu kredit tidak boleh kurang dari 0!')
.typeError('Jangka waktu kredit wajib diisi!'),
supplier_id: Yup.number()
.required('Supplier wajib dipilih!')
.min(1, 'Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
area: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
area_id: Yup.number()
.min(0, 'Area tidak boleh kurang dari 0!')
.typeError('Area harus berupa angka!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.min(0, 'Lokasi tidak boleh kurang dari 0!')
.typeError('Lokasi harus berupa angka!'),
notes: Yup.string().nullable().default(null),
items: Yup.array()
.of(PurchaseItemObjectSchema)
.min(1, 'Minimal harus ada 1 item pembelian!')
.required('Item pembelian wajib diisi!')
.typeError('Item pembelian wajib diisi!'),
});
export const UpdatePurchaseRequestFormSchema = PurchaseRequestFormSchema;
export type PurchaseRequestFormValues = Yup.InferType<
typeof PurchaseRequestFormSchema
>;
export const getPurchaseRequestFormInitialValues = (
initialValues?: Purchase
): PurchaseRequestFormValues => ({
supplier: initialValues?.supplier
? {
value: initialValues.supplier.id,
label: initialValues.supplier.name,
}
: null,
supplier_id: initialValues?.supplier?.id ?? 0,
credit_term: initialValues?.credit_term ?? 0,
area: initialValues?.area
? {
value: initialValues.area.id,
label: initialValues.area.name,
}
: null,
area_id: initialValues?.area?.id ?? undefined,
location: initialValues?.location
? {
value: initialValues.location.id,
label: initialValues.location.name,
}
: null,
location_id: initialValues?.location?.id ?? undefined,
notes: initialValues?.notes ?? null,
items: initialValues?.items?.length
? initialValues.items.map((item) => ({
warehouse: item.warehouse
? {
value: item.warehouse.id,
label: item.warehouse.name,
}
: null,
warehouse_id: item.warehouse?.id ?? 0,
product: item.product
? {
value: item.product.id,
label: item.product.name,
}
: null,
product_id: item.product?.id ?? 0,
qty: item.qty ?? 0,
}))
: [
{
warehouse: null,
warehouse_id: 0,
product: null,
product_id: 0,
qty: 0,
},
],
});
@@ -0,0 +1,973 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import useSWR from 'swr';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
import CheckboxInput from '@/components/input/CheckboxInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal';
import {
PurchaseRequestFormSchema,
PurchaseRequestFormValues,
getPurchaseRequestFormInitialValues,
UpdatePurchaseRequestFormSchema,
} from './PurchaseRequestForm.schema';
import {
SupplierApi,
AreaApi,
LocationApi,
WarehouseApi,
ProductApi,
} from '@/services/api/master-data';
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper';
import { PurchaseApi } from '@/services/api/purchase';
import Card from '@/components/Card';
import {
CreatePurchaseRequestPayload,
Purchase,
} from '@/types/api/purchase/purchase';
interface PurchaseRequestFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Purchase;
}
const PurchaseRequestForm = ({
type = 'add',
initialValues,
}: PurchaseRequestFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[]
);
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
useState('');
// ===== TYPE DEFINITIONS =====
interface ProductOptionType {
value: number;
label: string;
}
// ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = (
idx: number,
field: 'warehouse_id' | 'product_id' | 'qty'
): { isError: boolean; errorMessage: string } => {
if (!formik.touched.items || !Array.isArray(formik.touched.items)) {
return {
isError: false,
errorMessage: '',
};
}
const touchedField = (
formik.touched.items[idx] as Partial<{
warehouse_id: boolean;
product_id: boolean;
qty: boolean;
}>
)?.[field];
const errorItem = formik.errors.items?.[idx] as
| Record<string, string>
| undefined;
return {
isError: Boolean(touchedField && Boolean(errorItem?.[field])),
errorMessage: touchedField && errorItem?.[field] ? errorItem[field] : '',
};
};
// ===== SUBMISSION HANDLERS =====
const createPurchaseRequestHandler = useCallback(
async (payload: CreatePurchaseRequestPayload) => {
const res = await PurchaseApi.create(payload);
if (isResponseError(res)) {
setPurchaseRequestFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/purchase');
},
[router]
);
const updatePurchaseRequestHandler = useCallback(
async (
purchaseRequestId: number,
payload: CreatePurchaseRequestPayload
) => {
const res = await PurchaseApi.update(purchaseRequestId, payload);
if (isResponseError(res)) {
setPurchaseRequestFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/purchase');
},
[router]
);
const deletePurchaseRequestClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValues?.id) return;
setIsDeleteLoading(true);
await PurchaseApi.delete(initialValues.id);
deleteModal.closeModal();
toast.success('Successfully delete Purchase Request!');
setIsDeleteLoading(false);
router.push('/purchase');
}, [deleteModal, initialValues?.id, router]);
// ===== SELECT INPUT DATA =====
const {
setInputValue: setSupplierSelectInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
rawData: supplierRawData,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'SAPRONAK',
});
const {
setInputValue: setAreaSelectInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<PurchaseRequestFormValues>(
() => getPurchaseRequestFormInitialValues(initialValues),
[initialValues]
);
const formik = useFormik<PurchaseRequestFormValues>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit'
? UpdatePurchaseRequestFormSchema
: PurchaseRequestFormSchema,
validateOnChange: true,
validateOnBlur: true,
validateOnMount: false,
enableReinitialize: true,
onSubmit: async (values) => {
const payload: CreatePurchaseRequestPayload = {
supplier_id:
typeof values.supplier_id === 'string'
? parseInt(values.supplier_id) || 0
: values.supplier_id || 0,
credit_term:
typeof values.credit_term === 'string'
? parseInt(values.credit_term) || 0
: values.credit_term || 0,
notes: values.notes || '',
items: (values.items || []).map((item) => ({
warehouse_id: Number(item.warehouse_id) || 0,
product_id: Number(item.product_id) || 0,
qty: Number(item.qty) || 0,
})),
};
switch (type) {
case 'add':
await createPurchaseRequestHandler(payload);
break;
case 'edit':
await updatePurchaseRequestHandler(
initialValues?.id as number,
payload
);
break;
}
},
});
// ===== API DATA FETCHING =====
const { data: supplierData, isLoading: isLoadingProducts } = useSWR(
formik.values.supplier_id && Number(formik.values.supplier_id) > 0
? formik.values.supplier_id?.toString()
: null,
(id: string) => SupplierApi.getSingle(Number(id))
);
const supplierProductOptions = useMemo(() => {
if (!supplierData || !isResponseSuccess(supplierData)) {
return [];
}
const supplier = supplierData.data as SupplierProducts;
const products = supplier.products || [];
return products.map((product) => ({
value: product.id,
label: product.name,
}));
}, [supplierData]);
const supplierProductData = useMemo(() => {
if (!supplierData || !isResponseSuccess(supplierData)) {
return {};
}
const supplier = supplierData.data as SupplierProducts;
const products = supplier.products || [];
const data: Record<number, NonNullable<typeof supplier.products>[0]> = {};
products.forEach((product) => {
data[product.id] = product;
});
return data;
}, [supplierData]);
const locationsUrl = useMemo(() => {
const params = new URLSearchParams({
search: locationSelectInputValue,
...(formik.values.area_id && formik.values.area_id > 0
? { area_id: formik.values.area_id.toString() }
: {}),
});
return `${LocationApi.basePath}?${params.toString()}`;
}, [locationSelectInputValue, formik.values.area_id]);
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = useMemo(() => {
if (!isResponseSuccess(locations)) return [];
return (
locations?.data.map((location) => ({
value: location.id,
label: location.name,
})) || []
);
}, [locations]);
const warehousesUrl = useMemo(() => {
const params = new URLSearchParams({ search: warehouseSelectInputValue });
if (formik.values.area_id && formik.values.area_id > 0) {
params.append('area_id', formik.values.area_id.toString());
}
if (formik.values.location_id && formik.values.location_id > 0) {
params.append('location_id', formik.values.location_id.toString());
}
return `${WarehouseApi.basePath}?${params.toString()}`;
}, [
warehouseSelectInputValue,
formik.values.area_id,
formik.values.location_id,
]);
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
const warehouseOptions = useMemo(() => {
if (!isResponseSuccess(warehouses)) return [];
return (
warehouses?.data.map((w) => ({
value: w.id,
label: w.name,
area: w.area?.name,
location:
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
? w.location?.name
: undefined,
})) || []
);
}, [warehouses]);
const addPurchaseItem = () => {
const newItems = [
...(formik.values.items || []),
{
warehouse: null,
warehouse_id: 0,
product: null,
product_id: 0,
qty: 0,
},
];
formik.setFieldValue('items', newItems);
};
const removePurchaseItem = (idx: number) => {
const updatedPurchaseItems = formik.values.items?.filter(
(_, i) => i !== idx
);
formik.setFieldValue('items', updatedPurchaseItems);
};
const removeSelectedPurchaseItems = () => {
const updatedPurchaseItems = formik.values.items?.filter(
(_, idx) => !selectedPurchaseItems.includes(idx)
);
formik.setFieldValue('items', updatedPurchaseItems);
setSelectedPurchaseItems([]);
};
// ===== UTILITY FUNCTIONS =====
const updateCreditTermBasedOnSupplier = useCallback(
(supplierId: number) => {
if (supplierId > 0 && isResponseSuccess(supplierRawData)) {
const supplierData = supplierRawData.data.find(
(s: Supplier) => s.id === supplierId
);
if (supplierData?.due_date) {
formik.setFieldTouched('credit_term', false);
formik.setFieldValue('credit_term', supplierData.due_date.toString());
} else {
formik.setFieldTouched('credit_term', false);
formik.setFieldValue('credit_term', '');
}
} else {
formik.setFieldTouched('credit_term', false);
formik.setFieldValue('credit_term', '');
}
},
[supplierRawData]
);
const resetPurchaseItems = useCallback(() => {
if (formik.values.items) {
formik.values.items.forEach((_, idx) => {
formik.setFieldTouched(`items.${idx}.product`, false);
formik.setFieldValue(`items.${idx}.product`, null);
formik.setFieldTouched(`items.${idx}.product_id`, false);
formik.setFieldValue(`items.${idx}.product_id`, 0);
formik.setFieldTouched(`items.${idx}.qty`, false);
formik.setFieldValue(`items.${idx}.qty`, 0);
});
}
}, []);
// ===== SIDE EFFECTS =====
useEffect(() => {
if (formik.values.supplier_id && Number(formik.values.supplier_id) > 0) {
updateCreditTermBasedOnSupplier(Number(formik.values.supplier_id));
resetPurchaseItems();
} else {
formik.setFieldTouched('credit_term', false);
formik.setFieldValue('credit_term', '');
resetPurchaseItems();
}
}, [formik.values.supplier_id]);
// ===== FORM HANDLERS =====
const handleSupplierChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const supplier = val as OptionType | null;
const supplierId = Number(supplier?.value);
formik.setFieldTouched('supplier', true);
formik.setFieldValue('supplier', supplier);
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier_id', supplierId);
},
[]
);
const handleCreditTermChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldTouched('credit_term', true);
formik.setFieldValue('credit_term', value);
},
[]
);
const handleCreditTermBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
formik.handleBlur(e);
},
[formik]
);
const handleAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
formik.setFieldTouched('area_id', true);
formik.setFieldValue('area_id', (area as OptionType)?.value || 0);
formik.setFieldTouched('area', true);
formik.setFieldValue('area', area);
},
[]
);
const handleLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
formik.setFieldTouched('location_id', true);
formik.setFieldValue('location_id', (location as OptionType)?.value || 0);
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
},
[]
);
const handleWarehouseChange = useCallback(
(idx: number, val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = (warehouse as OptionType)?.value || 0;
formik.setFieldTouched(`items.${idx}.warehouse`, true);
formik.setFieldValue(`items.${idx}.warehouse`, warehouse);
formik.setFieldTouched(`items.${idx}.warehouse_id`, true);
formik.setFieldValue(`items.${idx}.warehouse_id`, warehouseId);
},
[]
);
// ===== PURCHASE ITEM OPERATIONS =====
const handlePurchaseItemChange = (
idx: number,
field: 'qty',
value: string | number
) => {
if (field === 'qty') {
const numValue =
typeof value === 'string' ? parseFloat(value) || 0 : value;
formik.setFieldTouched(`items.${idx}.qty`, true);
formik.setFieldValue(`items.${idx}.qty`, numValue);
}
};
return (
<>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/purchase'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Purchase Request'}
{type === 'edit' && 'Edit Purchase Request'}
{type === 'detail' && 'Detail Purchase Request'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
{/* Basic Info Card */}
<Card
title='Informasi Purchase Request'
className={{
wrapper: 'w-full mb-4 shadow',
body: 'flex flex-col gap-6',
}}
>
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
<SelectInput
required
label='Vendor'
placeholder='Pilih Vendor...'
value={formik.values.supplier}
onChange={handleSupplierChange}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_id &&
Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
isDisabled={type === 'detail'}
isClearable
/>
<NumberInput
required={!!formik.values.supplier_id}
label='Jatuh tempo (hari)'
name='credit_term'
value={formik.values.credit_term || ''}
onChange={handleCreditTermChange}
onBlur={handleCreditTermBlur}
isError={
formik.touched.credit_term &&
Boolean(formik.errors.credit_term)
}
errorMessage={formik.errors.credit_term as string}
readOnly={type === 'detail' || !formik.values.supplier_id}
disabled={type === 'detail' || !formik.values.supplier_id}
allowNegative={false}
decimalScale={0}
placeholder={
!formik.values.supplier_id
? 'Pilih Vendor terlebih dahulu'
: 'Masukkan jumlah hari jatuh tempo'
}
/>
<SelectInput
label='Area'
placeholder='Pilih Area...'
value={formik.values.area}
onChange={handleAreaChange}
options={areaOptions}
onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi...'
value={formik.values.location}
onChange={handleLocationChange}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
isDisabled={type === 'detail'}
isClearable={type !== 'detail'}
/>
<div className={'col-span-2'}>
<TextInput
label='Notes'
name='notes'
value={formik.values.notes || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes as string}
readOnly={type === 'detail'}
disabled={type === 'detail'}
placeholder='Masukkan catatan'
/>
</div>
</div>
</Card>
{/* Purchase Items Table */}
<Card
title='Item Pembelian'
className={{
wrapper: 'w-full mb-4 shadow',
title: 'mb-4',
}}
>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
<th>
<CheckboxInput
name='select-all-items'
checked={
formik.values.items?.length ===
selectedPurchaseItems.length &&
formik.values.items?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedPurchaseItems(
formik.values.items?.map((_, idx) => idx) ?? []
);
} else {
setSelectedPurchaseItems([]);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</th>
)}
<th>
Gudang
<span className='text-error'>*</span>
</th>
<th>
Item
<span className='text-error'>*</span>
</th>
<th>
Jumlah
<span className='text-error'>*</span>
</th>
<th>Estimasi Harga</th>
<th>Satuan</th>
{type !== 'detail' && <th>Action</th>}
</tr>
</thead>
<tbody>
{formik.values.items?.map((item, idx) => (
<tr key={`purchase-item-${idx}`}>
{type !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`purchase-item-${idx}`}
checked={selectedPurchaseItems.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedPurchaseItems([
...selectedPurchaseItems,
idx,
]);
} else {
setSelectedPurchaseItems(
selectedPurchaseItems.filter((i) => i !== idx)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</td>
)}
<td>
<SelectInput
placeholder='Pilih Gudang...'
value={item.warehouse}
onChange={(val) => handleWarehouseChange(idx, val)}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses}
isError={
isRepeaterInputError(idx, 'warehouse_id').isError
}
errorMessage={
isRepeaterInputError(idx, 'warehouse_id')
.errorMessage
}
isDisabled={type === 'detail'}
isClearable={type !== 'detail'}
className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}}
/>
</td>
<td>
<SelectInput
required
value={item.product ?? undefined}
onChange={(val) => {
const product = val as ProductOptionType | null;
const productId =
(product as ProductOptionType)?.value || 0;
formik.setFieldTouched(
`items.${idx}.product`,
true
);
formik.setFieldValue(
`items.${idx}.product`,
product
);
formik.setFieldTouched(
`items.${idx}.product_id`,
true
);
formik.setFieldValue(
`items.${idx}.product_id`,
productId
);
}}
options={supplierProductOptions}
isLoading={isLoadingProducts}
isError={
isRepeaterInputError(idx, 'product_id').isError
}
errorMessage={
isRepeaterInputError(idx, 'product_id').errorMessage
}
isDisabled={
type === 'detail' || !formik.values.supplier_id
}
isClearable={
type !== 'detail' && !!formik.values.supplier_id
}
placeholder={
!formik.values.supplier_id
? 'Pilih Vendor terlebih dahulu'
: 'Pilih Produk'
}
className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}}
/>
</td>
<td>
<NumberInput
required
name={`items.${idx}.qty`}
value={item.qty || ''}
onChange={(e) =>
handlePurchaseItemChange(idx, 'qty', e.target.value)
}
onBlur={formik.handleBlur}
placeholder={
!formik.values.supplier_id
? 'Pilih Vendor terlebih dahulu'
: 'Masukkan kuantitas'
}
readOnly={
type === 'detail' || !formik.values.supplier_id
}
disabled={
type === 'detail' || !formik.values.supplier_id
}
allowNegative={false}
decimalScale={0}
isError={isRepeaterInputError(idx, 'qty').isError}
errorMessage={
isRepeaterInputError(idx, 'qty').errorMessage
}
className={{
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
}}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.price`}
value={
item.product_id &&
supplierProductData[item.product_id]
? formatNumber(
supplierProductData[item.product_id]
.ProductPrice *
(parseFloat(item.qty?.toString() || '0') ||
0)
)
: ''
}
onChange={() => {}}
onBlur={formik.handleBlur}
type='text'
className={{
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
}}
disabled={true}
readOnly={true}
inputPrefix={'Rp'}
placeholder={
item.product_id
? 'Loading...'
: 'Pilih produk terlebih dahulu'
}
bottomLabel={
item.product_id &&
supplierProductData[item.product_id]
? `Harga per unit: Rp ${formatNumber(
supplierProductData[item.product_id]
.ProductPrice
)}`
: ''
}
/>
</td>
<td>
<TextInput
required
name={`items.${idx}.uom`}
value={
item.product_id &&
supplierProductData[item.product_id]
? supplierProductData[item.product_id].uom.name
: ''
}
onBlur={formik.handleBlur}
type='text'
readOnly={true}
disabled={true}
className={{
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
}}
placeholder={
item.product_id
? 'Loading...'
: 'Pilih produk terlebih dahulu'
}
/>
</td>
{type !== 'detail' && (
<td>
<div className='flex justify-center'>
<Button
type='button'
color='error'
onClick={() => removePurchaseItem(idx)}
>
<Icon
icon='mdi:trash-can'
width={24}
height={24}
/>
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedPurchaseItems.length > 0 && (
<Button
type='button'
color='error'
onClick={removeSelectedPurchaseItems}
disabled={selectedPurchaseItems.length === 0}
className='w-fit'
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedPurchaseItems.length})
</Button>
)}
<Button
type='button'
color='success'
onClick={addPurchaseItem}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Item
</Button>
</div>
)}
</Card>
{/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'detail' && (
<div className='flex flex-row justify-end gap-2 w-full'>
<Button type='reset' color='warning' className='px-4'>
Reset
</Button>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
)}
{type === 'detail' && (
<div className='flex flex-row justify-start gap-2'>
<Button
href={`/purchase/detail/edit/?purchaseId=${initialValues?.id}`}
color='warning'
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
<Button
type='button'
color='error'
onClick={deletePurchaseRequestClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
)}
</div>
{purchaseRequestFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{purchaseRequestFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data Purchase Request ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default PurchaseRequestForm;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,534 @@
'use client';
import { useMemo, useState } from 'react';
import {
Page,
Text,
View,
Document,
Image,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { Purchase } from '@/types/api/purchase/purchase';
import { formatDate, formatNumber } from '@/lib/helper';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
header: {
marginBottom: 20,
},
logo: {
width: 120,
height: 30,
marginBottom: 8,
},
companyInfo: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
color: '#1f74bf',
},
address: {
fontSize: 8,
color: '#666666',
maxWidth: 400,
marginBottom: 10,
},
divider: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
marginBottom: 15,
},
titleSection: {
flexDirection: 'row',
marginBottom: 20,
justifyContent: 'space-between',
alignItems: 'flex-start',
},
title: {
fontSize: 18,
fontWeight: 'bold',
flex: 3,
color: '#1f74bf',
},
poInfo: {
flex: 1,
fontSize: 9,
textAlign: 'right',
},
sectionTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
},
tableCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderLast: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
grandTotalRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#000000',
borderTopStyle: 'solid',
},
grandTotalLabel: {
flex: 3,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
},
grandTotalValue: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 0,
},
allocationSection: {
marginBottom: 15,
},
allocationTable: {
borderWidth: 1,
borderColor: '#000000',
},
innerTable: {
marginTop: 5,
borderWidth: 1,
borderColor: '#000000',
},
innerRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
innerCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
},
innerCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
innerCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
innerCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
footer: {
marginTop: 30,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
footerCompany: {
fontSize: 12,
fontWeight: 'bold',
textAlign: 'right',
flex: 1,
color: '#1f74bf',
},
specialInstructionTable: {
width: '60%',
maxWidth: 300,
borderWidth: 1,
borderColor: '#000000',
flex: 1,
},
});
interface PurchaseOrderInvoiceProps {
data?: Purchase;
className?: string;
}
const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
const [, setIsGeneratingPDF] = useState(false);
const purchaseData = data;
const grandTotal = useMemo(() => {
return (
purchaseData?.items?.reduce(
(sum, item) => sum + (item.total_price || 0),
0
) || 0
);
}, [purchaseData?.items]);
const handleDownloadPDF = async () => {
if (!purchaseData) {
alert('No purchase order data available');
return;
}
setIsGeneratingPDF(true);
try {
const PDFDocument = () => (
<Document>
<Page size='A4' style={pdfStyles.page}>
{/* Header Section */}
<View style={pdfStyles.header}>
<Image
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
<Text style={pdfStyles.companyInfo}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
{/* Purchase Order Title */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.title}>PURCHASE ORDER</Text>
<View style={pdfStyles.poInfo}>
<Text>PO Number: {purchaseData?.po_number || '-'}</Text>
<Text>
Date:{' '}
{purchaseData?.po_date
? formatDate(purchaseData.po_date, 'DD MMM YYYY')
: formatDate(new Date(), 'DD MMM YYYY')}
</Text>
</View>
</View>
{/* Vendor and Ship To Table */}
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Vendor</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Ship To</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text style={{ fontWeight: 'bold' }}>
{purchaseData?.supplier?.name || '-'} (
{purchaseData?.supplier?.alias || ''})
</Text>
<Text>{purchaseData?.supplier?.category || '-'}</Text>
<Text>
Credit Term: {purchaseData?.credit_term || 0} hari
</Text>
<Text>
Due Date:{' '}
{purchaseData?.due_date
? formatDate(purchaseData.due_date, 'DD MMM YYYY')
: '-'}
</Text>
</View>
<View style={pdfStyles.tableCellLast}>
<Text style={{ fontWeight: 'bold' }}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
? purchaseData.items[0].warehouse.location.name
: '-'}
</Text>
<Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
? purchaseData.items[0].warehouse.location.address
: '-'}
</Text>
</View>
</View>
</View>
{/* Item Description Table */}
<View>
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Item Description</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Unit Price</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Quantity</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Total Amount</Text>
</View>
</View>
{purchaseData?.items?.map((item, index) => {
const isLastItem =
index === (purchaseData?.items?.length || 0) - 1;
return (
<View
key={index}
style={[
pdfStyles.tableRow,
isLastItem ? {} : pdfStyles.tableBorderBottom,
]}
>
<View style={pdfStyles.tableCell}>
<Text>{item.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>Rp{formatNumber(item.price || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>{formatNumber(item.sub_qty || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRightLast}>
<Text>Rp{formatNumber(item.total_price || 0)}</Text>
</View>
</View>
);
}) || []}
{/* Grand Total Row inside table */}
<View style={pdfStyles.grandTotalRow}>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View
style={[pdfStyles.tableCellRight, { borderRightWidth: 0 }]}
>
<Text></Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text style={{ fontWeight: 'bold' }}>Grand Total</Text>
</View>
<View
style={[
pdfStyles.tableCellRightLast,
{ fontWeight: 'bold' },
]}
>
<Text>Rp{formatNumber(grandTotal)}</Text>
</View>
</View>
</View>
</View>
{/* Product Allocation Section */}
<View style={pdfStyles.allocationSection}>
<Text style={pdfStyles.sectionTitle}>Product Allocation</Text>
<View style={pdfStyles.allocationTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Warehouse Name</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Area</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Location Address</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Product Allocation</Text>
</View>
</View>
{purchaseData?.items?.map((item, itemIndex) => (
<View key={itemIndex} style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text>{item.warehouse?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.warehouse?.area?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>
{item.warehouse?.type === 'LOKASI'
? item.warehouse.location.address
: '-'}
</Text>
</View>
<View style={pdfStyles.tableCellLast}>
{/* Inner table for product allocation */}
<View style={pdfStyles.innerTable}>
{/* Header for inner table */}
<View
style={[
pdfStyles.innerRow,
{ backgroundColor: '#F5F5F5' },
]}
>
<Text style={pdfStyles.innerCell}>Item</Text>
<Text style={pdfStyles.innerCellRightLast}>
Quantity
</Text>
</View>
{/* Data row */}
<View style={pdfStyles.innerRow}>
<Text style={pdfStyles.innerCell}>
{item.product?.name || '-'}
</Text>
<Text style={pdfStyles.innerCellRightLast}>
{formatNumber(item.total_qty || 0)} of{' '}
{formatNumber(item.sub_qty || 0)}
</Text>
</View>
</View>
</View>
</View>
)) || []}
</View>
</View>
{/* Footer with Special Instructions */}
<View style={pdfStyles.footer}>
<View style={pdfStyles.specialInstructionTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Notes</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCellLast}>
<Text>{purchaseData?.notes || '-'}</Text>
</View>
</View>
</View>
<View style={pdfStyles.footerCompany}>
<Text>PT LUMBUNG TELUR INDONESIA</Text>
</View>
</View>
</Page>
</Document>
);
const blob = await pdf(<PDFDocument />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${purchaseData?.po_number || 'purchase-order'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
};
if (!purchaseData) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'>No purchase order data available</div>
</div>
);
}
return purchaseData?.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={handleDownloadPDF}
>
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
{purchaseData.po_number}
</Button>
) : null;
};
export default PurchaseOrderInvoice;
+23
View File
@@ -93,6 +93,29 @@ export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [
},
] as const;
export const PURCHASE_ORDER_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Pengajuan',
},
{
step_number: 2,
step_name: 'Staff Purchase',
},
{
step_number: 3,
step_name: 'Manager Purchase',
},
{
step_number: 4,
step_name: 'Penerimaan Produk',
},
{
step_number: 5,
step_name: 'Selesai',
},
] as const;
export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
+12 -6
View File
@@ -41,9 +41,9 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
},
{
title: 'Biaya Operasional',
link: '/expense',
icon: 'uil:wallet',
title: 'Pembelian',
link: '/purchase',
icon: 'gg:shopping-cart',
},
{
@@ -52,6 +52,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
icon: 'mdi:attach-money',
},
{
title: 'Biaya Operasional',
link: '/expense',
icon: 'uil:wallet',
},
{
title: 'Persediaan',
link: '/inventory',
@@ -233,9 +239,9 @@ export const SUPPLIER_FLAG_OPTIONS = [
];
export const RECORDING_FLAG_OPTIONS = [
{ label: 'Ayam Afkir', value: 'Afkir' },
{ label: 'Ayam Culling', value: 'Culling' },
{ label: 'Ayam Mati', value: 'Mati' },
{ label: 'Ayam Afkir', value: 'Ayam Afkir' },
{ label: 'Ayam Culling', value: 'Ayam Culling' },
{ label: 'Ayam Mati', value: 'Ayam Mati' },
];
export const APPROVAL_WORKFLOWS = [
+97
View File
@@ -0,0 +1,97 @@
import {
CreatePurchaseRequestPayload,
Purchase,
UpdatePurchaseRequestPayload,
CreateStaffApprovalRequestPayload,
UpdateStaffApprovalRequestPayload,
CreateManagerApprovalRequestPayload,
CreateAcceptApprovalRequestPayload,
DeletePurchaseRequestItemPayload,
} from '@/types/api/purchase/purchase';
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
const basePurchaseApi = new BaseApiService<
Purchase,
CreatePurchaseRequestPayload,
UpdatePurchaseRequestPayload
>('/purchases');
export const PurchaseApi = {
basePath: basePurchaseApi.basePath,
header: basePurchaseApi.header,
getAllFetcher: basePurchaseApi.getAllFetcher.bind(basePurchaseApi),
getSingle: basePurchaseApi.getSingle.bind(basePurchaseApi),
create: basePurchaseApi.create.bind(basePurchaseApi),
update: basePurchaseApi.update.bind(basePurchaseApi),
delete: basePurchaseApi.delete.bind(basePurchaseApi),
customRequest: basePurchaseApi.customRequest.bind(basePurchaseApi),
staffApproval: {
create: async (
purchaseRequestId: number,
payload: CreateStaffApprovalRequestPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
return await basePurchaseApi.customRequest<
BaseApiResponse<{ message: string }>
>(`${purchaseRequestId}/approvals/staff`, {
method: 'POST',
payload,
});
},
update: async (
purchaseRequestId: number,
payload: UpdateStaffApprovalRequestPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
return await basePurchaseApi.customRequest<
BaseApiResponse<{ message: string }>
>(`${purchaseRequestId}/approvals/staff`, {
method: 'POST',
payload,
});
},
},
managerApproval: {
create: async (
purchaseRequestId: number,
payload: CreateManagerApprovalRequestPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
return await basePurchaseApi.customRequest<
BaseApiResponse<{ message: string }>
>(`${purchaseRequestId}/approvals/manager`, {
method: 'POST',
payload,
});
},
},
acceptApproval: {
create: async (
purchaseRequestId: number,
payload: CreateAcceptApprovalRequestPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
return await basePurchaseApi.customRequest<
BaseApiResponse<{ message: string }>
>(`${purchaseRequestId}/receipts`, {
method: 'POST',
payload,
});
},
},
items: {
delete: async (
purchaseRequestId: number,
payload: DeletePurchaseRequestItemPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
return await basePurchaseApi.customRequest<
BaseApiResponse<{ message: string }>
>(`${purchaseRequestId}/items`, {
method: 'DELETE',
payload,
});
},
},
};
+12
View File
@@ -1,4 +1,5 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Uom } from '@/types/api/master-data/uom';
export type BaseSupplier = {
id: number;
@@ -19,6 +20,17 @@ export type BaseSupplier = {
export type Supplier = BaseMetadata & BaseSupplier;
export type SupplierProducts = Supplier & {
products?: Array<{
id: number;
name: string;
ProductPrice: number;
SellingPrice?: number;
uom: Uom;
flags: string[];
}>;
};
export type CreateSupplierPayload = {
name: string;
alias: string;
+128
View File
@@ -0,0 +1,128 @@
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
import { Supplier } from '@/types/api/master-data/supplier';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Product } from '@/types/api/master-data/product';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
export type PurchaseItemProduct = {
id: number;
name: string;
flags?: string[];
uom?: {
name: string;
};
product_category?:
| {
name: string;
}
| string;
};
export type PurchaseItem = {
id: number;
product_id: number;
warehouse: Warehouse;
product: PurchaseItemProduct | Product;
product_warehouse: ProductWarehouse;
quantity: number;
qty: number;
sub_qty: number;
total_qty: number;
total_used: number;
price: number;
total_price: number;
received_date?: string | null;
travel_number?: string | null;
travel_number_docs?: string | null;
travel_document_path?: string | null;
vehicle_number?: string | null;
expedition_vendor_id?: number | null;
expedition_vendor_name?: string | null;
received_qty?: number | null;
transport_per_item?: number | null;
transport_total?: number | null;
};
export type BasePurchase = {
id: number;
pr_number: string;
po_number: string;
po_document_path?: string | null;
po_date: string;
supplier: Supplier;
credit_term: number;
due_date: string;
grand_total: number;
notes?: string | null;
deleted_at?: string | null;
created_by: number;
area?: Area;
location?: Location;
warehouse?: Warehouse;
items?: PurchaseItem[];
approval?: BaseApproval;
};
export type Purchase = BaseMetadata & BasePurchase;
export type CreatePurchaseRequestPayload = {
supplier_id: number;
credit_term: number;
notes?: string | null;
items: {
warehouse_id: number;
product_id: number;
qty: number;
}[];
};
export type CreateStaffApprovalRequestPayload = {
action: 'APPROVED' | 'REJECTED';
notes?: string | null;
items: {
purchase_item_id: number;
qty: number;
price: number;
total_price: number;
}[];
};
export type UpdateStaffApprovalRequestPayload = {
action: 'APPROVED' | 'REJECTED';
notes?: string | null;
items: Array<{
purchase_item_id?: number;
product_id?: number;
warehouse_id?: number;
qty: number;
price: number;
total_price: number;
}>;
};
export type CreateManagerApprovalRequestPayload = {
notes?: string | null;
};
export type CreateAcceptApprovalRequestPayload = {
notes?: string;
items: {
purchase_item_id: number;
received_date: string;
travel_number: string;
travel_document_path: string;
vehicle_number: string;
expedition_vendor_id: number;
received_qty: number;
transport_per_item: number;
transport_total: number;
}[];
};
export type DeletePurchaseRequestItemPayload = {
item_ids: number[];
};
export type UpdatePurchaseRequestPayload = CreatePurchaseRequestPayload;