Merge branch 'development' into feat/FE/US-164/TASK-200-204-205-206-207-expense-realization

This commit is contained in:
ValdiANS
2025-11-26 13:44:01 +07:00
49 changed files with 6780 additions and 534 deletions
+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;
+7 -5
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(() => {
@@ -213,7 +215,7 @@ const DateInput = ({
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
'input h-12 bg-inherit px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
{
'border-error': finalIsError,
'border-success': externalValid && !finalIsError,
@@ -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
>
@@ -0,0 +1,44 @@
'use client';
import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
import TextArea, { TextAreaProps } from '@/components/input/TextArea';
interface DebouncedTextAreaProps extends TextAreaProps {
delay?: number;
}
const DebouncedTextArea = (props: DebouncedTextAreaProps) => {
const { delay, onChange } = props;
const [internalChangeEvent, setInternalChangeEvent] =
useState<ChangeEvent<HTMLTextAreaElement>>();
const [internalValue, setInternalValue] = useState(props.value);
const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300);
const [debouncedValue] = useDebounce(internalValue, delay ?? 300);
const internalChangeHandler: ChangeEventHandler<HTMLTextAreaElement> = (
e
) => {
setInternalValue(e.target.value);
setInternalChangeEvent(e);
};
useEffect(() => {
if (debouncedChangeEvent) {
onChange?.(debouncedChangeEvent);
}
}, [debouncedValue]);
return (
<TextArea
{...props}
value={internalValue}
onChange={internalChangeHandler}
/>
);
};
export default DebouncedTextArea;
@@ -71,9 +71,8 @@ const InventoryAdjustmentForm = ({
Partial<InventoryAdjustmentFormValues>
>(() => {
return {
product_category_id: initialValues?.product_category?.id ?? 0,
product_id: initialValues?.product?.id ?? 0,
warehouse_id: initialValues?.warehouse?.id ?? 0,
product_id: initialValues?.product_warehouse?.product_id ?? 0,
warehouse_id: initialValues?.product_warehouse?.warehouse_id ?? 0,
product_category: undefined,
product: undefined,
warehouse: undefined,
@@ -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
@@ -2,7 +2,7 @@
import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput';
import { OptionType } from '@/components/input/SelectInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
@@ -183,14 +183,18 @@ const MarketingTable = () => {
);
const hasApprovable = selectedRowsData.some(
(row) => row.latest_approval.step_number === 1
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const hasRejectable = selectedRowsData.some(
(row) => row.latest_approval.step_number === 2
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const disableApprove = !hasApprovable || hasRejectable;
// const disableReject = !hasRejectable || hasApprovable;
const disableApprove = !hasApprovable;
const disableReject = !hasRejectable;
const idsToProcess =
approveAction === 'APPROVED'
@@ -204,15 +208,9 @@ const MarketingTable = () => {
const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
if (approveAction === 'APPROVED') {
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
} else if (approveAction === 'REJECTED') {
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
}
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
@@ -263,8 +261,8 @@ const MarketingTable = () => {
});
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const step = row.original.latest_approval?.step_number;
return step === 1;
const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
};
return (
@@ -282,11 +280,6 @@ const MarketingTable = () => {
placeholder: 'Cari Sales Order',
}}
/>
<TableRowSizeSelector
value={pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
<div className='flex flex-row gap-2'>
<Button
color='success'
@@ -298,7 +291,7 @@ const MarketingTable = () => {
Approve
</Button>
{/* <Button
<Button
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
@@ -306,8 +299,36 @@ const MarketingTable = () => {
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button> */}
</Button>
</div>
<TableRowSizeSelector
value={pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
>
{/* select multiple product */}
<SelectInput
label='Product'
isClearable
placeholder='Pilih product'
options={[]}
isMulti
/>
{/* select status */}
<SelectInput
label='Status'
isClearable
placeholder='Pilih status'
options={[]}
/>
{/* select customer */}
<SelectInput
label='Customer'
isClearable
placeholder='Pilih customer'
options={[]}
/>
</TableRowSizeSelector>
</div>
<Table
rowSelection={rowSelection}
@@ -32,7 +32,7 @@ import { useRouter } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '../pdf/DeliveryOrderExport';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
const MarketingDetail = ({
initialValues,
@@ -71,10 +71,10 @@ const MarketingDetail = ({
confirmationModal.openModal();
};
// const rejectClickHandler = () => {
// setApprovalAction('REJECTED');
// confirmationModal.openModal();
// };
const rejectClickHandler = () => {
setApprovalAction('REJECTED');
confirmationModal.openModal();
};
const deliveryClickHandler = () => {
deliveryModal.openModal();
@@ -87,10 +87,11 @@ const MarketingDetail = ({
const confirmationModalDeleteClickHandler = async () => {
setIsLoading(true);
const res = await MarketingApi.delete(initialValues?.id as number);
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing');
toast.success(res?.message as string);
refresh?.();
setIsLoading(false);
};
const confirmationModalApproveClickHandler = async (notes: string) => {
@@ -131,32 +132,45 @@ const MarketingDetail = ({
<ApprovalSteps approvals={approvals} />
)}
<div className='flex-row flex gap-3'>
{initialValues?.latest_approval?.step_number != 3 && (
{initialValues?.latest_approval?.step_number == 1 && (
<>
<Button
color='success'
onClick={approveClickHandler}
disabled={initialValues?.latest_approval?.step_number != 1}
disabled={
initialValues?.latest_approval?.step_number == 1 &&
initialValues?.latest_approval?.action == 'REJECTED'
}
>
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
{/* <Button
<Button
color='error'
onClick={rejectClickHandler}
disabled={initialValues?.latest_approval?.step_number != 2}
disabled={
initialValues?.latest_approval?.step_number == 1 &&
initialValues?.latest_approval?.action == 'REJECTED'
}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button> */}
</Button>
</>
)}
{initialValues?.latest_approval?.step_number == 2 && (
{initialValues?.latest_approval?.step_number != 1 && (
<Button
color='success'
href={`/marketing/add/delivery-orders?marketingId=${initialValues?.id}`}
href={
initialValues?.latest_approval?.step_number == 3
? `/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
: `/marketing/add/delivery-orders?marketingId=${initialValues?.id}`
}
>
<Icon icon='mdi:truck' width={24} height={24} />
{initialValues?.latest_approval?.step_number == 3
? 'Edit '
: 'Tambah '}
Delivery Order
</Button>
)}
@@ -398,14 +412,16 @@ const MarketingDetail = ({
</Card>
)}
<div className='flex flex-row gap-3'>
<Button
color='warning'
type='button'
href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
</Button>
{initialValues?.latest_approval?.step_number != 3 && (
<Button
color='warning'
type='button'
href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
</Button>
)}
<Button color='error' onClick={deleteClickHandler}>
<Icon icon='mdi:delete' width={24} height={24} />
Hapus
@@ -429,7 +445,7 @@ const MarketingDetail = ({
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
type={approvalAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction} data penjualan ini?`}
text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -49,22 +49,23 @@ export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
Yup.object({
delivery_order: Yup.array()
.of(DeliveryOrderProductSchema)
.min(1, 'Pengiriman wajib diisi!')
.required()
.required('Pengiriman wajib diisi!')
.test(
'at-least-one-delivery-date',
'Minimal ada satu tanggal pengiriman yang harus diisi!',
(value) => {
if (!value || value.length == 0) {
return false;
}
return value.some(
(item) =>
item.delivery_date !== null &&
item.delivery_date !== undefined &&
item.delivery_date !== ''
);
'at-least-one-valid-row',
'Minimal ada satu baris pengiriman yang valid!',
function (items) {
if (!items || items.length === 0) return false;
// VALIDASI: minimal 1 item valid full
const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.some((item) => {
if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true });
});
return hasValidItem;
}
),
});
@@ -47,13 +47,23 @@ import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm'
import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct';
import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema';
import DebouncedTextArea from '@/components/input/DebouncedTextArea';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
const MemoizedDeliveryOrderProductTable = memo(DeliveryOrderProductTable);
const MemoizedDeliveryOrderProductForm = memo(DeliveryOrderProductForm);
const MarketingProductToFieldValues = (
// ================== EXTERNAL HELPER FUNCTION ==================
export interface ProductCalculationFields {
qty: string | number | undefined;
unit_price: string | number | undefined;
total_price: string | number | undefined;
avg_weight: string | number | undefined;
total_weight: string | number | undefined;
}
export const SalesProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
@@ -76,8 +86,7 @@ const MarketingProductToFieldValues = (
total_price: product.total_price,
};
};
const DeliveryProductToFieldValues = (
export const DeliveryProductToFieldValues = (
salesOrders: BaseSalesOrder[],
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
@@ -119,8 +128,7 @@ const DeliveryProductToFieldValues = (
});
return data;
};
const mergeSOwithDO = (
export const mergeSOwithDO = (
salesOrders: SalesOrderProductFormValues[],
deliveryOrders: DeliveryOrderProductFormValues[]
): DeliveryOrderProductFormValues[] => {
@@ -135,15 +143,63 @@ const mergeSOwithDO = (
delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price: delivery?.unit_price ?? so.unit_price,
total_weight: delivery?.total_weight ?? so.total_weight,
qty: delivery?.qty ?? so.qty,
avg_weight: delivery?.avg_weight ?? so.avg_weight,
total_price: delivery?.total_price ?? so.total_price,
unit_price: delivery?.unit_price,
total_weight: delivery?.total_weight,
qty: delivery?.qty,
avg_weight: delivery?.avg_weight,
total_price: delivery?.total_price,
marketing_product: so, // jika ada, override
} as DeliveryOrderProductFormValues;
});
};
export const recalculate = (
field: string,
values: ProductCalculationFields
) => {
console.log('Values');
console.log(values);
const { qty, unit_price, total_price, avg_weight, total_weight } = values;
const result: Partial<ProductCalculationFields> = {};
if (field == 'unit_price' || field == 'total_price' || field == 'qty') {
if (qty && unit_price && (field == 'unit_price' || field == 'qty')) {
result.total_price = Number(qty) * Number(unit_price);
} else if (qty && total_price && field == 'total_price') {
result.unit_price = Number(total_price) / Number(qty);
}
}
if (field == 'avg_weight' || field == 'total_weight' || field == 'qty') {
if (qty && avg_weight && (field == 'avg_weight' || field == 'qty')) {
result.total_weight = Number(qty) * Number(avg_weight);
} else if (qty && total_weight && field == 'total_weight') {
result.avg_weight = Number(total_weight) / Number(qty);
}
}
console.log('Result');
console.log(result);
return result;
};
export const getSubmitField = (values: ProductCalculationFields) => {
const { qty, unit_price, total_price, avg_weight, total_weight } = values;
// Harga logic
if (qty && unit_price && !total_price) {
return 'unit_price';
}
if (qty && total_price && !unit_price) {
return 'total_price';
}
// Bobot logic
if (qty && avg_weight && !total_weight) {
return 'avg_weight';
}
if (qty && total_weight && !avg_weight) {
return 'total_weight';
}
// Tidak ada yang perlu dihitung
return '';
};
const MarketingForm = ({
formType = 'add',
@@ -162,19 +218,21 @@ const MarketingForm = ({
useState<SalesOrderProductFormValues | null>(null);
const [selectedDeliveryProduct, setSelectedDeliveryProduct] =
useState<DeliveryOrderProductFormValues | null>(null);
const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>(
'add'
);
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[]
>(
mergeSOwithDO(
initialValues?.sales_order?.map(MarketingProductToFieldValues) ?? [],
initialValues?.sales_order?.map(SalesProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
)
);
// Repeater Props
// ================== REPEATER ==================
const addSOModal = useModal();
const addDOModal = useModal();
const [rowSOSelection, setRowSOSelection] = useState<Record<string, boolean>>(
@@ -183,19 +241,14 @@ const MarketingForm = ({
const selectedRowSOIds = Object.keys(rowSOSelection).map((item) =>
parseInt(item)
);
const [rowDOSelection, setRowDOSelection] = useState<Record<string, boolean>>(
{}
);
const selectedRowDOIds = Object.keys(rowDOSelection).map((item) =>
parseInt(item)
);
// End Repeater Props
// ================== FETCH OPTIONS ==================
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ==================
const formikInitialValues = useMemo<
SalesOrderFormValues & DeliveryOrderFormValues
>(() => {
@@ -212,17 +265,16 @@ const MarketingForm = ({
: null,
sales_order:
initialValues?.sales_order?.map((product) =>
MarketingProductToFieldValues(product)
SalesProductToFieldValues(product)
) ?? [],
delivery_order: mergeSOwithDO(
initialValues?.sales_order?.map(MarketingProductToFieldValues) ?? [],
initialValues?.sales_order?.map(SalesProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
),
};
}, [initialValues]);
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
enableReinitialize: true,
initialValues: formikInitialValues,
@@ -297,14 +349,7 @@ const MarketingForm = ({
},
});
const grandTotal = useMemo(() => {
return formik.values.sales_order.reduce(
(total, product) =>
total + parseFloat((product.total_price as string) || '0'),
0
);
}, [formik.values.sales_order]);
// ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
@@ -334,7 +379,6 @@ const MarketingForm = ({
}
setIsLoading(false);
};
const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
@@ -350,9 +394,7 @@ const MarketingForm = ({
)
) ?? []
);
router.push(
`/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
);
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(createDeliveryRes)) {
console.log(createDeliveryRes);
@@ -360,7 +402,6 @@ const MarketingForm = ({
}
setIsLoading(false);
};
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
@@ -371,15 +412,18 @@ const MarketingForm = ({
if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string);
// router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
setDeliveryOrderValues(
updateDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
updateDeliveryRes.data?.sales_order,
delivery
)
) ?? []
mergeSOwithDO(
formik.values.sales_order,
updateDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
updateDeliveryRes.data?.sales_order,
delivery
)
) ?? []
)
);
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
@@ -388,6 +432,7 @@ const MarketingForm = ({
setIsLoading(false);
};
// ================== MARKETING HANDLER ==================
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
@@ -404,28 +449,27 @@ const MarketingForm = ({
}
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing/sales-orders');
router.push('/marketing');
};
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[formik]
[]
);
const handleDelete = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
// Repeater Handle
const handleDeleteSO = useCallback(
(id: number) => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter((p) => p.id != id)
);
},
[formik]
);
// ================== SALES ORDER HANDLER ==================
const handleDeleteSO = useCallback((id: number) => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter((p) => p.id != id)
);
}, []);
const handleBulkDeleteSO = useCallback(() => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
@@ -435,10 +479,7 @@ const MarketingForm = ({
)
);
setRowSOSelection({});
}, [formik, selectedRowSOIds]);
const handleDelete = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
}, [selectedRowSOIds]);
const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null);
addSOModal.openModal();
@@ -446,48 +487,54 @@ const MarketingForm = ({
const handleAddSubmitSO = useCallback(
async (values: SalesOrderProductFormValues) => {
const currentProducts = formik.values.sales_order;
const newValues = {
...values,
id: values.id ?? Date.now(),
};
formik.setFieldValue('sales_order', [...currentProducts, newValues]);
const existingIndex = currentProducts.findIndex(
(item) =>
item.kandang_id === newValues.kandang_id &&
item.product_warehouse_id === newValues.product_warehouse_id
);
let updatedProducts = [];
if (existingIndex !== -1) {
// Overwrite
updatedProducts = currentProducts.map((item, index) =>
index === existingIndex ? newValues : item
);
} else {
// Add new item
updatedProducts = [...currentProducts, newValues];
}
formik.setFieldValue('sales_order', updatedProducts);
addSOModal.closeModal();
},
[formik, addSOModal]
[addSOModal]
);
const handleDeleteDO = useCallback(
(id: number) => {
const currentProducts = formik.values.delivery_order;
setDeliveryOrderValues((prev) => prev.filter((p) => p.id !== id));
},
[formik]
);
// ================== DELIVERY ORDER HANDLER ==================
const handleEditDO = useCallback(
(id: number) => {
(id: number, values?: DeliveryOrderProductFormValues) => {
setDeliveryFormState('edit');
const currentProducts = formik.values.delivery_order.find(
(product) => product.id == id
);
setSelectedDeliveryProduct(currentProducts ?? null);
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
addDOModal.openModal();
},
[formik]
[addDOModal]
);
const handleBulkDeleteDO = useCallback(() => {
setDeliveryOrderValues((prev) =>
prev.filter((product) => !selectedRowDOIds.includes(product.id ?? -1))
);
setRowDOSelection({});
}, [formik, selectedRowDOIds]);
const handleAddDOClick = useCallback(() => {
setDeliveryFormState('add');
setSelectedDeliveryProduct(null);
addDOModal.openModal();
}, [addDOModal]);
const handleAddSubmitDO = useCallback(
async (values: DeliveryOrderProductFormValues) => {
const newValues = {
@@ -496,23 +543,10 @@ const MarketingForm = ({
};
setDeliveryOrderValues((prev) => [...prev, newValues]);
addDOModal.closeModal();
setSelectedDeliveryProduct(null);
},
[formik, addDOModal]
);
const handleInputDate = useCallback(
(newData: DeliveryOrderProductFormValues) => {
setDeliveryOrderValues((prev) => {
return prev.map((item) => {
if (item.marketing_product_id == newData.marketing_product_id) {
return newData;
}
return item;
});
});
},
[]
[addDOModal]
);
const handleUpdateDO = useCallback(
async (id: number, values: DeliveryOrderProductFormValues) => {
@@ -521,12 +555,11 @@ const MarketingForm = ({
product.id === id ? { ...product, ...values } : product
)
);
setSelectedDeliveryProduct(null);
addDOModal.closeModal();
setSelectedDeliveryProduct(null);
},
[formik, addDOModal]
[addDOModal]
);
// End Repeater Handle
const memoSalesOrder = formik.values.sales_order;
@@ -534,6 +567,14 @@ const MarketingForm = ({
formik.setFieldValue('delivery_order', deliveryOrderValues);
}, [deliveryOrderValues, initialValues]);
const grandTotal = useMemo(() => {
return memoSalesOrder.reduce(
(total, product) =>
total + parseFloat((product.total_price as string) || '0'),
0
);
}, [memoSalesOrder]);
return (
<>
<form
@@ -545,6 +586,7 @@ const MarketingForm = ({
title={`${formType == 'add' || formType == 'add_deliver' ? 'Tambah' : 'Edit'} ${formType === 'add_deliver' || formType === 'edit_deliver' ? 'Delivery' : 'Sales'} Order`}
backUrl='/marketing'
/>
{/* Input Cutomer And Date */}
<Card
title='Informasi Order'
className={{
@@ -580,28 +622,27 @@ const MarketingForm = ({
/>
</div>
</Card>
{(formType == 'add' || formType == 'edit') && (
<Card
title='Informasi Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
{/* <div className='text-blue-500'>{JSON.stringify(initialValues)}</div>
<div className='text-green-500'>{JSON.stringify(formik.values)}</div>
<div className='text-red-500'>{JSON.stringify(formik.errors)}</div> */}
<MemoizedSalesOrderProductTable
formType={formType}
data={memoSalesOrder}
rowSelection={rowSOSelection}
setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO}
onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick}
/>
</Card>
)}
{/* Input Table Repeater Sales Order */}
<Card
title='Informasi Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
<MemoizedSalesOrderProductTable
formType={formType}
data={memoSalesOrder}
rowSelection={rowSOSelection}
setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO}
onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick}
/>
</Card>
{/* Input Table Repeater Delivery Order */}
{(formType == 'add_deliver' || formType == 'edit_deliver') &&
initialValues?.sales_order &&
initialValues?.sales_order.length > 0 && (
@@ -611,29 +652,24 @@ const MarketingForm = ({
wrapper: 'bg-white w-full',
}}
>
{/* {JSON.stringify(memoSalesOrder)} */}
{/* <small>{JSON.stringify(memoDeliveryOrder)}</small> */}
{/* <small className='block text-error'>
{/* <div className='text-blue-500'>
{JSON.stringify(formik.values)}
</div>
<div className='text-red-500'>
{JSON.stringify(formik.errors)}
</small> */}
</div> */}
<MemoizedDeliveryOrderProductTable
formType={formType}
data={deliveryOrderValues}
salesOrder={memoSalesOrder}
rowSelection={rowDOSelection}
setRowSelection={setRowDOSelection}
selectedRowIds={selectedRowDOIds}
onDelete={handleDeleteDO}
onEdit={handleEditDO}
onBulkDelete={handleBulkDeleteDO}
onAddProductClick={handleAddDOClick}
onInputDate={handleInputDate}
/>
</Card>
)}
{/* Input Notes */}
<div className='grid grid-cols-2 gap-3'>
<TextArea
<DebouncedTextArea
required
name='notes'
label='Catatan'
@@ -652,6 +688,8 @@ const MarketingForm = ({
</span>
</div>
</div>
{/* Form Actions */}
<div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
@@ -665,6 +703,8 @@ const MarketingForm = ({
</Button>
</div>
</form>
{/* Actions button */}
{formType == 'edit' && (
<div className='flex flex-row justify-start'>
<Button
@@ -678,6 +718,8 @@ const MarketingForm = ({
</Button>
</div>
)}
{/* Modals */}
<Modal
ref={addSOModal.ref}
closeOnBackdrop
@@ -701,6 +743,7 @@ const MarketingForm = ({
<MemoizedSalesOrderProductForm
onSubmitForm={handleAddSubmitSO}
initialValues={selectedMarketingProduct ?? undefined}
exisitingValues={memoSalesOrder}
/>
</div>
</div>
@@ -728,7 +771,9 @@ const MarketingForm = ({
</div>
<div>
<MemoizedDeliveryOrderProductForm
formState={deliveryFormState}
salesOrders={initialValues?.sales_order ?? []}
exisitingValues={deliveryOrderValues}
onSubmitForm={handleAddSubmitDO}
initialValues={selectedDeliveryProduct ?? undefined}
onUpdateForm={handleUpdateDO}
@@ -1,9 +1,5 @@
import * as Yup from 'yup';
import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '../sales-order/SalesOrderProduct.schema';
import { de } from 'react-day-picker/locale';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
type DeliveryOrderProductSchemaType = {
id?: number | undefined;
@@ -42,22 +38,10 @@ export const DeliveryOrderProductSchema: Yup.ObjectSchema<DeliveryOrderProductSc
.min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
delivery_date: Yup.string()
.required('Tanggal Pengiriman wajib diisi!')
.nullable()
.optional(),
delivery_date: Yup.string().required('Tanggal Pengiriman wajib diisi!'),
do_number: Yup.string().nullable().optional(),
});
export type DeliveryOrderProductFormValues = Yup.InferType<
typeof DeliveryOrderProductSchema
>;
// "marketing_product_id": 3,
// "qty": 20,
// "unit_price": 1000,
// "avg_weight": 1.1,
// "total_weight": 220,
// "total_price": 20000,
// "delivery_date": "2025-11-09",
// "vehicle_number": "D 4321 XXX"
@@ -10,20 +10,24 @@ import NumberInput from '@/components/input/NumberInput';
import PatternInput from '@/components/input/PatternInput';
import { formatVechicleNumber } from '@/lib/helper';
import DateInput from '@/components/input/DateInput';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { SalesOrderProductFormValues } from '../sales-order/SalesOrderProduct.schema';
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import Badge from '@/components/Badge';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm';
import * as Yup from 'yup';
const DeliveryOrderProductForm = ({
formState,
salesOrders,
initialValues,
exisitingValues,
onSubmitForm,
onUpdateForm,
}: {
formState: 'add' | 'edit';
salesOrders: BaseSalesOrder[];
initialValues?: DeliveryOrderProductFormValues;
exisitingValues?: DeliveryOrderProductFormValues[];
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
onUpdateForm?: (
id: number,
@@ -34,13 +38,18 @@ const DeliveryOrderProductForm = ({
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const [currentInput, setCurrentInput] = useState<string>('');
const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id
);
const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
delivery_date: initialValues?.delivery_date || undefined,
vehicle_number: initialValues?.vehicle_number || undefined,
marketing_product_id: initialValues?.marketing_product_id || undefined,
marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
@@ -48,15 +57,33 @@ const DeliveryOrderProductForm = ({
total_price: initialValues?.total_price || undefined,
marketing_product: initialValues?.marketing_product || undefined,
},
validationSchema: DeliveryOrderProductSchema,
isInitialValid: false,
validationSchema: Yup.object().shape({
...DeliveryOrderProductSchema.fields,
qty: Yup.lazy((_, context) => {
// values diambil aman dari context
const { parent } = context;
const mpId = parent?.marketing_product_id;
const selectedSO = salesOrders.find((item) => item.id === mpId);
const maxQty = selectedSO?.qty ?? Infinity;
return Yup.number()
.min(1, 'Kuantitas wajib diisi!')
.max(maxQty, `Maksimal kuantitas adalah ${maxQty}`)
.required('Kuantitas wajib diisi!');
}),
}),
validateOnChange: true,
validateOnBlur: true,
validateOnChange: false,
onSubmit: async (values) => {
setFormErrorMessage('');
if (initialValues?.id) {
await onUpdateForm?.(initialValues.id, values);
} else {
await onSubmitForm?.(values);
await onUpdateForm?.(values.marketing_product_id as number, values);
}
handleResetForm();
},
@@ -81,6 +108,7 @@ const DeliveryOrderProductForm = ({
};
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
@@ -101,47 +129,37 @@ const DeliveryOrderProductForm = ({
}
};
const MarketingProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
id: product.id,
vehicle_number: product.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
avg_weight: product.avg_weight,
total_price: product.total_price,
};
};
const options = salesOrders.map((item) => ({
value: item.id,
label: `${item.product_warehouse.product.name} - ${item.product_warehouse.warehouse.name}`,
}));
const options = exisitingValues
?.map((item) => {
if (!Boolean(item.qty)) {
return {
value: item.id,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
} as OptionType;
} else {
return null;
}
})
?.filter((item) => item != null) as OptionType[];
const { setValues: setFormikValues } = formik;
useEffect(() => {
if (initialValues) {
setFormikValues(initialValues);
const value = salesOrders.find(
(item) => item.id === initialValues.marketing_product_id
);
setSelectedProduct({
value: value?.id,
label: `${value?.product_warehouse.product.name} - ${value?.product_warehouse.warehouse.name}`,
} as OptionType);
if (!Boolean(initialValues.qty)) {
handleResetForm();
} else {
setFormikValues(initialValues);
// const value = exisitingValues?.find(
// (item) => item.id === initialValues?.id
// );
if (initialValues?.marketing_product_id) {
setSelectedProduct({
value: initialValues?.id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
} as OptionType);
}
}
}
}, [initialValues]);
@@ -149,17 +167,21 @@ const DeliveryOrderProductForm = ({
<>
<form
className='size-full'
onSubmit={formik.handleSubmit}
onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm}
>
{/* <small className='block text-blue-500'>
{JSON.stringify(initialValues)}
</small>
<small className='block text-red-500'>
{JSON.stringify(formik.errors)}
{JSON.stringify(exisitingValues)}
</small>
<small className='block text-emerald-500'>
{JSON.stringify(formik.values)}
</small> */}
{/* <small className='block text-red-500'>
{JSON.stringify(formik.errors)}
</small>
<div className='hidden'>
{JSON.stringify(formik.values.marketing_product)}
@@ -176,14 +198,14 @@ const DeliveryOrderProductForm = ({
options={options}
label='Produk'
placeholder='Pilih Produk'
isDisabled
isDisabled={formState == 'edit'}
value={
selectedProduct
? ({
value: selectedProduct?.value,
label: salesOrders.find(
label: exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.product_warehouse.product.name,
)?.marketing_product?.product_warehouse?.label,
} as OptionType)
: null
}
@@ -191,7 +213,7 @@ const DeliveryOrderProductForm = ({
const selected = value as OptionType;
setSelectedProduct(selected);
const so = salesOrders.find(
const so = salesOrders?.find(
(item) => item.id === selected?.value
);
if (!so) {
@@ -212,7 +234,7 @@ const DeliveryOrderProductForm = ({
formik.setValues({
...formik.values,
marketing_product_id: selected.value as number,
marketing_product: MarketingProductToFieldValues(so),
marketing_product: SalesProductToFieldValues(so),
qty: formik.values.qty || so.qty,
unit_price: so.unit_price,
total_price: so.total_price,
@@ -230,9 +252,9 @@ const DeliveryOrderProductForm = ({
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
{
salesOrders.find(
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.product_warehouse?.warehouse?.name
)?.marketing_product?.kandang?.label
}
</Badge>
)
@@ -254,6 +276,9 @@ const DeliveryOrderProductForm = ({
}
errorMessage={formik.errors.delivery_date}
placeholder='Pilih Tanggal'
className={{
inputWrapper: 'bg-white',
}}
required
/>
@@ -278,7 +303,10 @@ const DeliveryOrderProductForm = ({
label='Kuantitas'
name='qty'
value={formik.values.qty}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('qty')}
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
@@ -290,7 +318,10 @@ const DeliveryOrderProductForm = ({
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
@@ -302,7 +333,10 @@ const DeliveryOrderProductForm = ({
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
@@ -314,7 +348,10 @@ const DeliveryOrderProductForm = ({
label='Total Bobot (Kg)'
name='total_weight'
value={formik.values.total_weight}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_weight')}
isError={Boolean(formik.errors.total_weight)}
errorMessage={formik.errors.total_weight}
@@ -326,7 +363,10 @@ const DeliveryOrderProductForm = ({
label='Total Penjualan (Rp)'
name='total_price'
value={formik.values.total_price}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_price')}
isError={Boolean(formik.errors.total_price)}
errorMessage={formik.errors.total_price}
@@ -5,7 +5,7 @@ import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useState } from 'react';
import { RefObject, useMemo, useState } from 'react';
import SelectInput, {
OptionType,
useSelect,
@@ -23,13 +23,16 @@ import Alert from '@/components/Alert';
const SalesOrderProductForm = ({
initialValues,
exisitingValues,
onSubmitForm,
}: {
initialValues?: SalesOrderProductFormValues;
exisitingValues?: SalesOrderProductFormValues[];
modalRef?: RefObject<HTMLDialogElement | null>;
onSubmitForm?: (value: SalesOrderProductFormValues) => Promise<void>;
}) => {
const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>('');
const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true,
@@ -51,6 +54,8 @@ const SalesOrderProductForm = ({
onSubmitForm?.(values);
handleResetForm();
},
validateOnBlur: true,
isInitialValid: false,
});
const {
@@ -72,6 +77,15 @@ const SalesOrderProductForm = ({
}
);
const productOptionsFiltered = useMemo(() => {
return warehouseSourceOptions.filter(
(product) =>
!exisitingValues
?.map((item) => item.product_warehouse_id)
.includes(product.value)
);
}, [warehouseSourceOptions, exisitingValues]);
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
@@ -115,6 +129,7 @@ const SalesOrderProductForm = ({
};
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
@@ -151,7 +166,11 @@ const SalesOrderProductForm = ({
<>
<form
className='size-full'
onSubmit={formik.handleSubmit}
onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm}
>
{formErrorMessage && (
@@ -200,12 +219,18 @@ const SalesOrderProductForm = ({
<SelectInput
required
label='Produk'
options={warehouseSourceOptions}
options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse}
onChange={warehouseChangeHandler}
isClearable
placeholder='Pilih Kandang Terlebih Dahulu'
placeholder={
formik.values.kandang_id
? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia'
: 'Pilih produk'
: 'Pilih Kandang Terlebih Dahulu'
}
isDisabled={!formik.values.kandang_id}
isError={
formik.touched.product_warehouse_id &&
@@ -218,7 +243,10 @@ const SalesOrderProductForm = ({
label='Kuantitas'
name='qty'
value={formik.values.qty}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('qty')}
isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
@@ -229,7 +257,10 @@ const SalesOrderProductForm = ({
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
@@ -242,7 +273,10 @@ const SalesOrderProductForm = ({
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
@@ -255,7 +289,10 @@ const SalesOrderProductForm = ({
label='Total Bobot (Kg)'
name='total_weight'
value={formik.values.total_weight}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_weight')}
isError={
formik.touched.total_weight && Boolean(formik.errors.total_weight)
@@ -268,7 +305,10 @@ const SalesOrderProductForm = ({
label='Total Penjualan (Rp)'
name='total_price'
value={formik.values.total_price}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_price')}
isError={
formik.touched.total_price && Boolean(formik.errors.total_price)
@@ -1,10 +1,9 @@
import Table from '@/components/Table';
import { DeliveryOrderProductFormValues } from '../repeater/delivery-order/DeliverOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import * as TanStack from '@tanstack/react-table';
import { useMemo, useRef } from 'react';
import CheckboxInput from '@/components/input/CheckboxInput';
import {
cn,
formatCurrency,
@@ -12,49 +11,24 @@ import {
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { SalesOrderProductFormValues } from '../repeater/sales-order/SalesOrderProduct.schema';
import DateInput from '@/components/input/DateInput';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
salesOrder: SalesOrderProductFormValues[];
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
selectedRowIds: number[];
onDelete: (id: number) => void;
onEdit: (id: number) => void;
onBulkDelete: () => void;
onAddProductClick: () => void;
onInputDate: (data: DeliveryOrderProductFormValues) => void;
};
const DeliveryOrderProductTable = ({
data,
salesOrder,
formType,
rowSelection,
setRowSelection,
selectedRowIds,
onDelete,
onEdit,
onBulkDelete,
onAddProductClick,
onInputDate,
}: DeliveryOrderProductTableProps) => {
const onDeleteRef = useRef(onDelete);
const onEditRef = useRef(onDelete);
onDeleteRef.current = onDelete;
const onEditRef = useRef(onEdit);
onEditRef.current = onEdit;
const canAddData = salesOrder.reduce((acc, curr) => {
const deliveredQty = data.filter(
(deliveryItem) => deliveryItem.marketing_product_id == curr.id
);
return acc && deliveredQty.length != salesOrder.length;
}, true);
const canAddData = data.filter((item) => !Boolean(item.qty));
const columns = useMemo(() => {
const cols = [
@@ -93,54 +67,23 @@ const DeliveryOrderProductTable = ({
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.do_number,
header: 'No. Pengiriman',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => <div>{props.row.original.do_number}</div>,
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.delivery_date
? formatDate(row.delivery_date as string, 'DD MMM YYYY')
: '-',
header: 'Tanggal Delivery',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => (
<>
{formType == 'add_deliver' && (
<DateInput
name={`delivery_date_${props.row.original.marketing_product_id}`}
className={{
input: 'p-0',
inputWrapper: 'py-1 px-3 h-fit w-fit bg-white',
wrapper: 'p-0',
}}
value={
props.row.original.delivery_date
? formatDate(props.row.original.delivery_date, 'yyyy-MM-DD')
: undefined
}
onChange={(val) => {
onInputDate({
...props.row.original,
delivery_date: val.target.value,
});
}}
/>
)}
{formType == 'edit_deliver' &&
formatDate(
props.row.original.delivery_date as string,
'DD MMM YYYY'
)}
{props.row.original.do_number ? props.row.original.do_number : '-'}
</>
),
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatVechicleNumber(row.vehicle_number as string),
accessorFn: (row: DeliveryOrderProductFormValues) => row.vehicle_number,
header: 'No. Polisi',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.vehicle_number
? formatVechicleNumber(props.row.original.vehicle_number as string)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
@@ -154,29 +97,77 @@ const DeliveryOrderProductTable = ({
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatCurrency(parseFloat(row.unit_price as string)),
row.delivery_date
? formatDate(row.delivery_date as string, 'DD MMM YYYY')
: '-',
header: 'Tanggal Delivery',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.delivery_date
? formatDate(
props.row.original.delivery_date as string,
'DD MMM YYYY'
)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.unit_price,
header: 'Harga Satuan (Rp)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.unit_price
? formatCurrency(
parseFloat(props.row.original.unit_price as string)
)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string)),
accessorFn: (row: DeliveryOrderProductFormValues) => row.total_weight,
header: 'Total Bobot (Kg)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.total_weight
? formatNumber(
parseFloat(props.row.original.total_weight as string)
)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)),
accessorFn: (row: DeliveryOrderProductFormValues) => row.qty,
header: 'Kuantitas',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.qty
? formatNumber(parseFloat(props.row.original.qty as string))
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string)),
accessorFn: (row: DeliveryOrderProductFormValues) => row.avg_weight,
header: 'Avg. Bobot (Kg)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.avg_weight
? formatNumber(parseFloat(props.row.original.avg_weight as string))
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatCurrency(parseFloat(row.total_price as string)),
accessorFn: (row: DeliveryOrderProductFormValues) => row.total_price,
header: 'Total Penjualan (Rp)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.total_price
? formatCurrency(
parseFloat(props.row.original.total_price as string)
)
: '-',
},
{
header: 'Aksi',
cell: (
@@ -184,44 +175,45 @@ const DeliveryOrderProductTable = ({
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<>
<Button
color='warning'
className='px-2 py-1 text-sm'
onClick={() =>
onEditRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:edit' width={16} height={16} /> Edit
</Button>
{/* <Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button> */}
{props.row.original.qty && (
<Button
color='warning'
className='px-2 py-1 text-sm'
onClick={() =>
onEditRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:edit' width={16} height={16} /> Edit
</Button>
)}
{!props.row.original.qty && '-'}
{/* {formType == 'add_deliver' && (
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
)} */}
</>
</div>
),
},
];
if (formType == 'add_deliver') {
return cols.filter(
(col) => col.header != 'Aksi' && col.header != 'No. Pengiriman'
);
return cols.filter((col) => col.header != 'No. Pengiriman');
}
return cols;
}, [formType, onInputDate, onEditRef]);
}, [formType, onEditRef]);
return (
<>
<Table<DeliveryOrderProductFormValues>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={data}
columns={columns}
className={{
@@ -246,17 +238,17 @@ const DeliveryOrderProductTable = ({
}
/>
<div className='flex flex-row gap-3 mt-3'>
{/* <Button
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
// disabled={!canAddData}
disabled={!canAddData}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Pengiriman
</Button> */}
{selectedRowIds.length > 0 && (
</Button>
{/* {selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
@@ -271,7 +263,7 @@ const DeliveryOrderProductTable = ({
: ''}{' '}
Pengiriman
</Button>
)}
)} */}
</div>
</>
);
@@ -16,7 +16,7 @@ import CheckboxInput from '@/components/input/CheckboxInput';
type SalesOrderProductTableProps = {
data: SalesOrderProductFormValues[];
formType?: 'add' | 'edit' | 'deliver';
formType: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
@@ -140,7 +140,7 @@ const SalesOrderProductTable = ({
setRowSelection={setRowSelection}
data={data}
columns={
formType == 'deliver'
formType == 'add_deliver' || formType == 'edit_deliver'
? columns.filter(
(col) => col.header != 'Aksi' && col.id != 'select'
)
@@ -167,7 +167,7 @@ const SalesOrderProductTable = ({
</div>
}
/>
{formType != 'deliver' && (
{formType != 'add_deliver' && formType != 'edit_deliver' && (
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
@@ -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;
@@ -6,7 +6,7 @@ import {
ChickinFormValues,
ChickinRequestFormValues,
ChickinSchema,
} from '../ChickinForm.schema';
} from '@/components/pages/production/chickin/form/ChickinForm.schema';
import DateInput from '@/components/input/DateInput';
import Button from '@/components/Button';
import { useCallback, useEffect, useState } from 'react';
@@ -42,6 +42,7 @@ import ApprovalSteps, {
} from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import NumberInput from '@/components/input/NumberInput';
interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -548,10 +549,18 @@ const ProjectFlockForm = ({
setIsApproveLoading(false);
};
const selectedPeriod = isResponseSuccess(periodFlocks)
? periodFlocks.data.find((kandang) =>
formik.values.kandang_ids?.includes(kandang.id)
)?.period
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
return (
<>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<header className='flex flex-col gap-4 mb-6'>
<Button
href='/production/project-flock'
variant='link'
@@ -563,6 +572,7 @@ const ProjectFlockForm = ({
<h1 className='text-2xl font-bold text-center'>
{formType === 'add' && 'Tambah Project Flock'}
{formType === 'edit' && 'Edit Project Flock'}
{formType === 'detail' && 'Detail Project Flock'}
</h1>
</header>
@@ -740,6 +750,14 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType === 'detail'}
/>
<NumberInput
name='period'
label='Periode'
disabled
readOnly
placeholder='Period'
value={selectedLocation ? inputPeriod : ''}
/>
</div>
</div>
</div>
@@ -781,7 +799,7 @@ const ProjectFlockForm = ({
setRowSelection={setRowSelection}
selectedIds={formik.values.kandang_ids}
formType={formType}
initialValues={initialValues?.kandangs ?? []}
initialValues={initialValues}
/>
</div>
</Collapse>
@@ -5,7 +5,10 @@ import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { cn } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockPeriods } from '@/types/api/production/project-flock';
import {
ProjectFlock,
ProjectFlockPeriods,
} from '@/types/api/production/project-flock';
import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react';
@@ -23,11 +26,11 @@ const ProjectFlockKandangTable = ({
rowSelection: Record<string, boolean>;
setRowSelection: OnChangeFn<Record<string, boolean>>;
selectedIds: (number | undefined)[];
initialValues?: Kandang[];
initialValues?: ProjectFlock;
formType: 'add' | 'edit' | 'detail';
}) => {
const initialKandangIdSet = useMemo(() => {
return initialValues?.map((k) => k.id) ?? [];
return initialValues?.kandangs.map((k) => k.id) ?? [];
}, [initialValues]);
const isRowEnabled = (row: Row<Kandang>) => {
const isDisabled =
@@ -147,7 +150,18 @@ const ProjectFlockKandangTable = ({
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
return period?.period ?? '-';
const calcPeriod = period?.period == 0 ? 1 : period?.period;
const selected = props.row.getIsSelected();
const initPeriod = initialValues?.period;
return formType == 'detail'
? selected
? initPeriod
: '-'
: formType == 'add'
? (calcPeriod ?? '-')
: selected
? (initPeriod ?? '-')
: (calcPeriod ?? '-');
},
},
{
@@ -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;
+1 -1
View File
@@ -1,5 +1,5 @@
import { Icon } from '@iconify/react';
import Button from '../Button';
import Button from '@/components/Button';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
interface TableRowOptionsProps {
@@ -1,4 +1,4 @@
import SelectInput from '../input/SelectInput';
import SelectInput from '@/components/input/SelectInput';
export interface OptionType {
label: string;
@@ -9,15 +9,18 @@ interface TableRowSizeSelectorProps {
value: number;
onChange: (val: OptionType | OptionType[] | null) => void;
options: OptionType[];
children?: React.ReactNode;
}
export const TableRowSizeSelector = ({
value,
onChange,
options,
children,
}: TableRowSizeSelectorProps) => {
return (
<div className='flex flex-row justify-end'>
<div className='flex flex-row gap-3 items-end justify-end'>
{children}
<SelectInput
label='Baris'
options={options}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Icon } from '@iconify/react';
import Button from '../Button';
import DebouncedTextInput from '../input/DebouncedTextInput';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
interface TableToolbarProps {
addButton?: {
+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 = [
+1 -1
View File
@@ -3,7 +3,7 @@ import {
CreateChickinPayload,
UpdateChickinPayload,
} from '@/types/api/production/chickin';
import { BaseApiService } from '../base';
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
+2 -2
View File
@@ -3,7 +3,7 @@ import {
ProjectFlock,
UpdateProjectFlockPayload,
} from '@/types/api/production/project-flock';
import { BaseApiService } from '../base';
import { BaseApiService } from '@/services/api/base';
import {
BaseApiResponse,
BaseGroupedApproval,
@@ -120,7 +120,7 @@ export class ProjectFlockService extends BaseApiService<
| undefined
> {
try {
const path = `${this.basePath}/location/${locationId.toString()}/periods`;
const path = `${this.basePath}/locations/${locationId.toString()}/periods`;
return await httpClient<
SuccessApiResponse<
{
+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,
});
},
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { Product } from '@/types/api/master-data/product';
import { BaseMetadata } from '../base-metadata';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { BaseMetadata } from '@/types/api/api-general';
export type BaseInventoryAdjustment = {
id: number;
+1 -1
View File
@@ -7,7 +7,7 @@ import {
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Kandang } from '@/types/api/master-data/kandang';
import { id } from 'react-day-picker/locale';
import { Warehouse } from '../master-data/warehouse';
import { Warehouse } from '@/types/api/master-data/warehouse';
/**
* Base Data Response
+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;
+2 -2
View File
@@ -1,8 +1,8 @@
import { Kandang } from '@/type/master-data/kandang';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Supplier } from '../master-data/supplier';
import { BaseApproval } from '../api-general';
import { Supplier } from '@/types/api/master-data/supplier';
import { BaseApproval } from '@/types/api/api-general';
export type BaseProjectFlockKandang = {
id: number;
+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;