Merge branch 'dev/restu' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-78/TASK-170-174-slicing-ui-and-validation-create-daily-recording-laying-form

This commit is contained in:
rstubryan
2025-11-02 21:53:56 +07:00
10 changed files with 664 additions and 486 deletions
@@ -1,24 +1,56 @@
'use client'; 'use client';
import { useState } from 'react'; import { ChangeEventHandler, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { SortingState } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { useModal } from '@/components/Modal'; import { Icon } from '@iconify/react';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory'; import { MovementApi } from '@/services/api/inventory';
import { cn } from '@/lib/helper'; 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 { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { TableToolbar } from '@/components/table/TableToolbar'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import Button from '@/components/Button';
import { OptionType } from '@/components/input/SelectInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { TableRowOptions } from '@/components/table/TableRowOptions';
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Movement, unknown>;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</div>
);
};
const MovementTable = () => { const MovementTable = () => {
const { const {
@@ -28,30 +60,47 @@ const MovementTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit' }, search: '',
product: '',
warehouse: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
product: 'product_id',
warehouse: 'warehouse_id',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const deleteModal = useModal();
const { const {
data: movements, setInputValue: setProductInputValue,
isLoading, options: productOptions,
mutate: refreshMovements, isLoadingOptions: isLoadingProductOptions,
} = useSWR( } = 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.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
setPage(1);
}; };
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -60,167 +109,178 @@ const MovementTable = () => {
setPage(1); setPage(1);
}; };
const confirmationModalDeleteClickHandler = async () => { const productChangeHandler = (val: OptionType | OptionType[] | null) => {
setIsDeleteLoading(true); setSelectedProduct(val as OptionType);
try { updateFilter('product', val ? ((val as OptionType).value as string) : '');
await MovementApi.delete(selectedMovement?.id as number);
refreshMovements();
deleteModal.closeModal();
} finally {
setIsDeleteLoading(false);
}
}; };
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedWarehouse(val as OptionType);
updateFilter('warehouse', val ? ((val as OptionType).value as string) : '');
};
const movementColumns: ColumnDef<Movement>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
},
header: 'Biaya Pengiriman',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='dropdown' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
];
return ( return (
<div className='flex flex-col gap-4'> <>
<div className='flex flex-col gap-2 mb-4'> <div className='w-full p-0 sm:p-4'>
<TableToolbar <div className='flex flex-col gap-2 mb-4'>
addButton={{ <div className='w-full flex flex-col xl:flex-row justify-between items-end xl:items-center gap-2'>
href: '/inventory/movement/add', <div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
label: 'Tambah Movement', <Button
href='/inventory/movement/add'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Movement
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Movement'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</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',
}}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
</div>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(movements) && movements?.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',
}} }}
search={{
value: tableFilterState.search,
onChange: searchChangeHandler,
placeholder: 'Cari Movement',
}}
/>
<TableRowSizeSelector
value={tableFilterState.pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/> />
</div> </div>
</>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={[
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString(
'id-ID'
),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
},
header: 'Biaya Pengiriman',
},
{
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 = () => {
setSelectedMovement(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<TableRowOptions
type='dropdown'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<TableRowOptions
type='collapse'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowCollapseOptions>
)}
</>
);
},
},
]}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(movements) && movements?.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',
}}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</div>
); );
}; };
@@ -1,34 +1,82 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
type MovementFormSchemaType = {
transfer_reason: string;
transfer_date: string;
source_warehouse?: {
value: number;
label: string;
area?: string;
location?: string;
} | null;
source_warehouse_id: number;
destination_warehouse?: {
value: number;
label: string;
area?: string;
location?: string;
} | null;
destination_warehouse_id: number;
products: {
product?: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number | string;
}[];
deliveries: {
delivery_cost?: number | string;
delivery_cost_per_item?: number | string;
document?: File | string | null;
document_path?: string | null;
driver_name: string;
vehicle_plate: string;
supplier?: {
value: number;
label: string;
} | null;
supplier_id: number;
products: {
product?: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number | string;
}[];
}[];
};
export type ProductSchema = { export type ProductSchema = {
product: { product?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
product_qty: number; product_qty: number | string;
}; };
export type DeliverySchema = { export type DeliverySchema = {
delivery_cost?: number | undefined; delivery_cost?: number | string;
delivery_cost_per_item?: number | undefined; delivery_cost_per_item?: number | string;
document?: File | string | null; document?: File | string | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name: string;
vehicle_plate: string; vehicle_plate: string;
supplier: { supplier?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
supplier_id: number; supplier_id: number;
products: { products: {
product: { product?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
product_qty: number; product_qty: number | string;
}[]; }[];
}; };
@@ -102,7 +150,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
.required('Produk wajib diisi!'), .required('Produk wajib diisi!'),
}); });
export const MovementFormSchema = Yup.object({ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> = Yup.object({
transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'),
transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'),
source_warehouse: Yup.object({ source_warehouse: Yup.object({
@@ -133,8 +181,6 @@ export const MovementFormSchema = Yup.object({
.required('Pengiriman wajib diisi!'), .required('Pengiriman wajib diisi!'),
}); });
export const UpdateMovementFormSchema = MovementFormSchema;
export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>; export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>;
export const getMovementFormInitialValues = ( export const getMovementFormInitialValues = (
@@ -8,26 +8,27 @@ import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
import { FormHeader } from '@/components/helper/form/FormHeader'; OptionType,
import { FormActions } from '@/components/helper/form/FormActions'; useSelect,
} from '@/components/input/SelectInput';
import { import {
CreateMovementPayload, CreateMovementPayload,
Movement, Movement,
} from '@/types/api/inventory/movement'; } from '@/types/api/inventory/movement';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useRouter } from 'next/navigation';
import { import {
MovementFormSchema, MovementFormSchema,
MovementFormValues, MovementFormValues,
UpdateMovementFormSchema,
getMovementFormInitialValues, getMovementFormInitialValues,
ProductSchema, ProductSchema,
DeliverySchema, DeliverySchema,
} from '@/components/pages/inventory/movement/form/MovementForm.schema'; } from '@/components/pages/inventory/movement/form/MovementForm.schema';
import { useMovementFormHandlers } from './useMovementFormHandlers';
import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { MovementApi } from '@/services/api/inventory';
import FileInput from '@/components/input/FileInput'; import FileInput from '@/components/input/FileInput';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
@@ -38,24 +39,38 @@ interface MovementFormProps {
} }
const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const router = useRouter();
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [, setMovementFormErrorMessage] = useState(''); const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [ const [
productWarehouseSelectInputValue, productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue, setProductWarehouseSelectInputValue,
] = useState(''); ] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [warehouseSelectInputValue, setWarehouseSelectInputValue] =
useState('');
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const { const createMovementHandler = useCallback(
movementFormErrorMessage, async (payload: CreateMovementPayload, documents: File[] = []) => {
createMovementHandler, const formData = new FormData();
updateMovementHandler, formData.append('data', JSON.stringify(payload));
} = useMovementFormHandlers(initialValues?.id); documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
const res = await MovementApi.create(
formData as unknown as CreateMovementPayload
);
if (isResponseError(res)) {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/inventory/movement');
},
[router]
);
// ===== INTERFACES ===== // ===== INTERFACES =====
interface WarehouseOptionType extends OptionType { interface WarehouseOptionType extends OptionType {
@@ -77,18 +92,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
ProductWarehouseApi.getAllFetcher ProductWarehouseApi.getAllFetcher
); );
// ===== USE SELECT HOOKS =====
const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setSupplierSelectInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
} = useSelect(SupplierApi.basePath, 'id', 'name', 'search');
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( const { data: warehouses } = useSWR(
warehousesUrl, warehousesUrl,
WarehouseApi.getAllFetcher WarehouseApi.getAllFetcher
); );
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
// ===== DATA PROCESSING ===== // ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => { const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map(); if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -114,8 +136,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return stockMap; return stockMap;
}, [allProductWarehouses]); }, [allProductWarehouses]);
const warehouseOptions = isResponseSuccess(warehouses) const warehouseOptions = useMemo(() => {
? warehouses?.data.map((w) => { if (!isResponseSuccess(warehouses)) return [];
return (
warehouses?.data.map((w) => {
warehouseStockMap.get(w.id); warehouseStockMap.get(w.id);
return { return {
value: w.id, value: w.id,
@@ -126,12 +151,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
? w.location?.name ? w.location?.name
: undefined, : undefined,
}; };
}) }) || []
: []; );
}, [warehouses, warehouseStockMap]);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data.map((s) => ({ value: s.id, label: s.name }))
: [];
// ===== FORM INITIALIZATION ===== // ===== FORM INITIALIZATION =====
const formikInitialValues = useMemo<MovementFormValues>( const formikInitialValues = useMemo<MovementFormValues>(
@@ -141,8 +163,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const formik = useFormik<MovementFormValues>({ const formik = useFormik<MovementFormValues>({
initialValues: formikInitialValues, initialValues: formikInitialValues,
validationSchema: validationSchema: MovementFormSchema,
type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema,
validateOnChange: true, validateOnChange: true,
validateOnBlur: true, validateOnBlur: true,
validateOnMount: false, validateOnMount: false,
@@ -150,7 +171,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
onSubmit: async (values) => { onSubmit: async (values) => {
setMovementFormErrorMessage(''); setMovementFormErrorMessage('');
const documents: File[] = []; const documents: File[] = [];
const deliveriesPayload = values.deliveries.map((d, idx) => { const deliveriesPayload = values.deliveries.map((d) => {
let documentIndex = 0; let documentIndex = 0;
if (d.document && d.document instanceof File) { if (d.document && d.document instanceof File) {
@@ -159,8 +180,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
return { return {
delivery_cost: d.delivery_cost ?? 0, delivery_cost: parseInt((d.delivery_cost || '').toString()) || 0,
delivery_cost_per_item: d.delivery_cost_per_item ?? 0, delivery_cost_per_item:
parseInt((d.delivery_cost_per_item || '').toString()) || 0,
document_index: documentIndex, document_index: documentIndex,
document_path: d.document_path, document_path: d.document_path,
driver_name: d.driver_name, driver_name: d.driver_name,
@@ -168,7 +190,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
supplier_id: d.supplier_id, supplier_id: d.supplier_id,
products: d.products.map((p) => ({ products: d.products.map((p) => ({
product_id: p.product_id, product_id: p.product_id,
product_qty: p.product_qty, product_qty: parseInt(p.product_qty.toString()) || 0,
})), })),
}; };
}); });
@@ -180,7 +202,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
destination_warehouse_id: values.destination_warehouse_id, destination_warehouse_id: values.destination_warehouse_id,
products: values.products.map((p) => ({ products: values.products.map((p) => ({
product_id: p.product_id, product_id: p.product_id,
product_qty: p.product_qty, product_qty: parseInt(p.product_qty.toString()) || 0,
})), })),
deliveries: deliveriesPayload, deliveries: deliveriesPayload,
}; };
@@ -189,13 +211,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
case 'add': case 'add':
await createMovementHandler(payload, documents); await createMovementHandler(payload, documents);
break; break;
case 'edit':
await updateMovementHandler(
initialValues?.id as number,
payload,
documents
);
break;
} }
}, },
}); });
@@ -297,7 +312,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ {
product: null, product: null,
product_id: 0, product_id: 0,
product_qty: 0, product_qty: '',
}, },
]; ];
formik.setFieldValue('products', newProducts); formik.setFieldValue('products', newProducts);
@@ -332,8 +347,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.setFieldValue('deliveries', [ formik.setFieldValue('deliveries', [
...(formik.values.deliveries || []), ...(formik.values.deliveries || []),
{ {
delivery_cost: undefined, delivery_cost: '',
delivery_cost_per_item: undefined, delivery_cost_per_item: '',
document: null, document: null,
driver_name: '', driver_name: '',
vehicle_plate: '', vehicle_plate: '',
@@ -343,7 +358,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ {
product: null, product: null,
product_id: 0, product_id: 0,
product_qty: 0, product_qty: '',
}, },
], ],
}, },
@@ -385,7 +400,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const delivery = formik.values.deliveries?.[idx]; const delivery = formik.values.deliveries?.[idx];
if (delivery) { if (delivery) {
const productQty = delivery.products.reduce( const productQty = delivery.products.reduce(
(sum, p) => sum + p.product_qty, (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0 0
); );
if (productQty > 0 && value > 0) { if (productQty > 0 && value > 0) {
@@ -409,7 +424,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const delivery = formik.values.deliveries?.[idx]; const delivery = formik.values.deliveries?.[idx];
if (delivery) { if (delivery) {
const productQty = delivery.products.reduce( const productQty = delivery.products.reduce(
(sum, p) => sum + p.product_qty, (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0 0
); );
if (productQty > 0 && value > 0) { if (productQty > 0 && value > 0) {
@@ -683,36 +698,38 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
useEffect(() => { useEffect(() => {
formik.values.deliveries?.forEach((delivery, idx) => { formik.values.deliveries?.forEach((delivery, idx) => {
const productQty = delivery.products.reduce( const productQty = delivery.products.reduce(
(sum, p) => sum + p.product_qty, (sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0 0
); );
if ( const deliveryCost =
delivery.delivery_cost && parseInt((delivery.delivery_cost || '').toString()) || 0;
delivery.delivery_cost > 0 && const deliveryCostPerItem =
productQty > 0 parseInt((delivery.delivery_cost_per_item || '').toString()) || 0;
) {
const perItem = delivery.delivery_cost / productQty; if (deliveryCost > 0 && productQty > 0) {
if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { const perItem = deliveryCost / productQty;
if (Math.abs(deliveryCostPerItem - perItem) > 0.01) {
formik.setFieldValue( formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`, `deliveries.${idx}.delivery_cost_per_item`,
perItem perItem
); );
} }
} else if ( } else if (deliveryCostPerItem > 0 && productQty > 0) {
delivery.delivery_cost_per_item && const totalCost = deliveryCostPerItem * productQty;
delivery.delivery_cost_per_item > 0 && if (Math.abs(deliveryCost - totalCost) > 0.01) {
productQty > 0
) {
const totalCost = delivery.delivery_cost_per_item * productQty;
if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
} }
} }
}); });
}, [ }, [
formik.values.deliveries formik.values.deliveries
?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) ?.map((d) =>
d.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
)
)
.join(','), .join(','),
]); ]);
@@ -730,11 +747,21 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
<FormHeader <header className='flex flex-col gap-4'>
type={type} <Button
title='Movement' href='/inventory/movement'
backUrl='/inventory/movement' 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 Movement'}
{type === 'edit' && 'Edit Movement'}
{type === 'detail' && 'Detail Movement'}
</h1>
</header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
@@ -748,6 +775,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required required
label='Alasan Transfer' label='Alasan Transfer'
name='transfer_reason' name='transfer_reason'
placeholder='Masukkan alasan transfer...'
value={formik.values.transfer_reason} value={formik.values.transfer_reason}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -785,6 +813,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput <SelectInput
required required
label='Gudang' label='Gudang'
placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse} value={formik.values.source_warehouse}
onChange={(val) => { onChange={(val) => {
formik.setFieldTouched('source_warehouse', true); formik.setFieldTouched('source_warehouse', true);
@@ -852,6 +881,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput <SelectInput
required required
label='Gudang' label='Gudang'
placeholder='Pilih gudang tujuan...'
value={formik.values.destination_warehouse} value={formik.values.destination_warehouse}
onChange={(val) => { onChange={(val) => {
formik.setFieldTouched('destination_warehouse', true); formik.setFieldTouched('destination_warehouse', true);
@@ -1038,8 +1068,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
placeholder={ placeholder={
!formik.values.source_warehouse_id !formik.values.source_warehouse_id
? 'Pilih gudang asal terlebih dahulu' ? 'Pilih gudang asal terlebih dahulu...'
: 'Pilih produk' : 'Pilih produk...'
} }
isClearable isClearable
{...isRepeaterInputError( {...isRepeaterInputError(
@@ -1057,6 +1087,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<NumberInput <NumberInput
required required
name={`products.${idx}.product_qty`} name={`products.${idx}.product_qty`}
placeholder='Masukkan kuantitas...'
value={product.product_qty ?? ''} value={product.product_qty ?? ''}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -1277,6 +1308,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<td> <td>
<SelectInput <SelectInput
required required
placeholder='Pilih produk...'
value={delivery.products[0]?.product ?? undefined} value={delivery.products[0]?.product ?? undefined}
onChange={(val) => { onChange={(val) => {
formik.setFieldTouched( formik.setFieldTouched(
@@ -1317,6 +1349,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<NumberInput <NumberInput
required required
name={`deliveries.${idx}.products.0.product_qty`} name={`deliveries.${idx}.products.0.product_qty`}
placeholder='Masukkan kuantitas...'
value={delivery.products[0]?.product_qty ?? ''} value={delivery.products[0]?.product_qty ?? ''}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -1347,6 +1380,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<td> <td>
<SelectInput <SelectInput
required required
placeholder='Pilih supplier...'
value={delivery.supplier} value={delivery.supplier}
onChange={(val) => { onChange={(val) => {
formik.setFieldTouched( formik.setFieldTouched(
@@ -1386,6 +1420,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<TextInput <TextInput
required required
name={`deliveries.${idx}.vehicle_plate`} name={`deliveries.${idx}.vehicle_plate`}
placeholder='Masukkan plat nomor...'
value={delivery.vehicle_plate} value={delivery.vehicle_plate}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -1463,6 +1498,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<NumberInput <NumberInput
required required
name={`deliveries.${idx}.delivery_cost`} name={`deliveries.${idx}.delivery_cost`}
placeholder='Masukkan biaya pengiriman...'
value={delivery.delivery_cost || ''} value={delivery.delivery_cost || ''}
onChange={handleDeliveryCostChangeWrapper(idx)} onChange={handleDeliveryCostChangeWrapper(idx)}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -1487,6 +1523,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<NumberInput <NumberInput
required required
name={`deliveries.${idx}.delivery_cost_per_item`} name={`deliveries.${idx}.delivery_cost_per_item`}
placeholder='Masukkan biaya per item...'
value={delivery.delivery_cost_per_item || ''} value={delivery.delivery_cost_per_item || ''}
onChange={handleDeliveryCostPerItemChangeWrapper( onChange={handleDeliveryCostPerItemChangeWrapper(
idx idx
@@ -1513,6 +1550,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<TextInput <TextInput
required required
name={`deliveries.${idx}.driver_name`} name={`deliveries.${idx}.driver_name`}
placeholder='Masukkan nama sopir...'
value={delivery.driver_name} value={delivery.driver_name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -1582,11 +1620,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
{/* Action buttons */} {/* Action buttons */}
<FormActions<MovementFormValues> <div className='flex flex-row justify-between gap-2 flex-wrap'>
type={type} {type !== 'detail' && (
formik={formik} <div className='flex flex-row justify-end gap-2 w-full'>
disableSubmit={hasInvalidQty || hasExceededStock} <Button type='reset' color='warning' className='px-4'>
/> Reset
</Button>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={
hasInvalidQty ||
hasExceededStock ||
!formik.isValid ||
formik.isSubmitting
}
>
Submit
</Button>
</div>
)}
</div>
{movementFormErrorMessage && ( {movementFormErrorMessage && (
<div role='alert' className='alert alert-error'> <div role='alert' className='alert alert-error'>
@@ -1,95 +0,0 @@
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { useModal } from '@/components/Modal';
import { MovementApi } from '@/services/api/inventory';
import {
CreateMovementPayload,
UpdateMovementPayload,
} from '@/types/api/inventory/movement';
import { isResponseError } from '@/lib/api-helper';
export const useMovementFormHandlers = (initialValuesId?: number) => {
const router = useRouter();
const deleteModal = useModal();
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createMovementHandler = useCallback(
async (payload: CreateMovementPayload, documents: File[] = []) => {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
const res = await MovementApi.create(
formData as unknown as CreateMovementPayload
);
if (isResponseError(res)) {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/inventory/movement');
},
[router]
);
const updateMovementHandler = useCallback(
async (
movementId: number,
payload: UpdateMovementPayload,
documents: File[] = []
) => {
let finalPayload: UpdateMovementPayload | FormData;
if (documents.length > 0) {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
finalPayload = formData as unknown as UpdateMovementPayload;
} else {
finalPayload = payload;
}
const res = await MovementApi.update(movementId, finalPayload);
if (res?.status === 'error') {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/inventory/movement');
},
[router]
);
const deleteMovementClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValuesId) return;
setIsDeleteLoading(true);
await MovementApi.delete(initialValuesId);
deleteModal.closeModal();
toast.success('Successfully delete Movement!');
setIsDeleteLoading(false);
router.push('/inventory/movement');
}, [deleteModal, initialValuesId, router]);
return {
deleteModal,
movementFormErrorMessage,
isDeleteLoading,
createMovementHandler,
updateMovementHandler,
deleteMovementClickHandler,
confirmationModalDeleteClickHandler,
};
};
@@ -1,9 +1,17 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProductCategoryFormSchema = Yup.object({ type ProductCategoryFormSchemaType = {
code: Yup.string().required('Kode wajib diisi!').max(3, 'Kode kategori produk melebihi 3 karakter!'), code: string;
name: Yup.string().required('Nama wajib diisi!'), name: string;
}); };
export const ProductCategoryFormSchema: Yup.ObjectSchema<ProductCategoryFormSchemaType> =
Yup.object({
code: Yup.string()
.required('Kode wajib diisi!')
.max(3, 'Kode kategori produk melebihi 3 karakter!'),
name: Yup.string().required('Nama wajib diisi!'),
});
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema; export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
@@ -30,7 +30,10 @@ interface ProductCategoryFormProps {
initialValues?: ProductCategory; initialValues?: ProductCategory;
} }
const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFormProps) => { const ProductCategoryForm = ({
type = 'add',
initialValues,
}: ProductCategoryFormProps) => {
const router = useRouter(); const router = useRouter();
const deleteModal = useModal(); const deleteModal = useModal();
@@ -68,16 +71,20 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
[router] [router]
); );
const formikInitialValues = useMemo<ProductCategoryFormValues>(() => { const formikInitialValues = useMemo<ProductCategoryFormValues>(
return { () => ({
code: initialValues?.code ?? '', code: initialValues?.code ?? '',
name: initialValues?.name ?? '', name: initialValues?.name ?? '',
}; }),
}, [initialValues]); [initialValues]
);
const formik = useFormik<ProductCategoryFormValues>({ const formik = useFormik<ProductCategoryFormValues>({
initialValues: formikInitialValues, initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateProductCategoryFormSchema : ProductCategoryFormSchema, validationSchema:
type === 'edit'
? UpdateProductCategoryFormSchema
: ProductCategoryFormSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
setFormErrorMessage(''); setFormErrorMessage('');
@@ -91,7 +98,10 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
await createProductCategoryHandler(payload); await createProductCategoryHandler(payload);
break; break;
case 'edit': case 'edit':
await updateProductCategoryHandler(initialValues?.id as number, payload); await updateProductCategoryHandler(
initialValues?.id as number,
payload
);
break; break;
} }
}, },
@@ -109,7 +119,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
await ProductCategoryApi.delete(initialValues?.id as number); await ProductCategoryApi.delete(initialValues?.id as number);
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Product Category!'); toast.success('Berhasil menghapus data Kategori Produk!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
router.push('/master-data/product-category'); router.push('/master-data/product-category');
}; };
@@ -120,7 +130,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product-category' href='/master-data/product-category'
@@ -132,9 +142,9 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
</Button> </Button>
<h1 className='text-2xl font-bold text-center'> <h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Product Category'} {type === 'add' && 'Tambah Kategori Produk'}
{type === 'edit' && 'Edit Product Category'} {type === 'edit' && 'Edit Kategori Produk'}
{type === 'detail' && 'Detail Product Category'} {type === 'detail' && 'Detail Kategori Produk'}
</h1> </h1>
</header> </header>
@@ -148,7 +158,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
required required
label='Kode' label='Kode'
name='code' name='code'
placeholder='Masukkan kode kategori produk' placeholder='Masukkan kode...'
value={formik.values.code} value={formik.values.code}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -160,7 +170,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama kategori produk' placeholder='Masukkan nama...'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -247,7 +257,7 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Product Category ini (${initialValues?.name})?`} text={`Apakah anda yakin ingin menghapus data Kategori Produk ini (${initialValues?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -263,4 +273,4 @@ const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFor
); );
}; };
export default ProductCategoryForm; export default ProductCategoryForm;
@@ -1,53 +1,83 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProductFormSchema = Yup.object({ type ProductFormSchemaType = {
name: Yup.string().required('Nama wajib diisi!'), name: string;
brand: Yup.string().required('Merek wajib diisi!'), brand: string;
sku: Yup.string().required('SKU wajib diisi!'), sku: string;
uom: Yup.object({ uom?: {
value: Yup.number().min(1).required(), value: number;
label: Yup.string().required(), label: string;
}).nullable(), } | null;
uom_id: Yup.number().required('Satuan wajib diisi!').typeError('Satuan wajib diisi!'), uom_id: number;
product_category: Yup.object({ product_category?: {
value: Yup.number().min(1).required(), value: number;
label: Yup.string().required(), label: string;
}).nullable(), } | null;
product_category_id: Yup.number() product_category_id: number;
product_price: number | string;
selling_price: number | string;
tax: number | string;
expiry_period: number | string;
supplier_ids: number[];
flags: string[];
};
export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable().required('Satuan wajib diisi!'),
uom_id: Yup.number()
.required('Satuan wajib diisi!')
.typeError('Satuan wajib diisi!'),
product_category: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable().required('Kategori produk wajib diisi!'),
product_category_id: Yup.number()
.required('Kategori produk wajib diisi!') .required('Kategori produk wajib diisi!')
.typeError('Kategori produk wajib diisi!'), .typeError('Kategori produk wajib diisi!'),
product_price: Yup.number()
product_price: Yup.number()
.required('Harga produk wajib diisi!') .required('Harga produk wajib diisi!')
.typeError('Harga produk wajib diisi!') .typeError('Harga produk wajib diisi!')
.min(0, 'Harga produk tidak boleh kurang dari 0!'), .min(0, 'Harga produk tidak boleh kurang dari 0!'),
selling_price: Yup.number()
selling_price: Yup.number()
.required('Harga jual wajib diisi!') .required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!') .typeError('Harga jual wajib diisi!')
.min(0, 'Harga jual tidak boleh kurang dari 0!'), .min(0, 'Harga jual tidak boleh kurang dari 0!'),
tax: Yup.number()
tax: Yup.number()
.required('Pajak wajib diisi!') .required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!') .typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number()
expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa wajib diisi!')
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
supplier: Yup.object({
value: Yup.number().min(1).required(), supplier_ids: Yup.array()
label: Yup.string().required(), .of(Yup.number().required().typeError('Supplier tidak valid!'))
}).nullable(),
supplier_ids: Yup.array()
.of(Yup.number().typeError('Supplier tidak valid!'))
.min(1, 'Minimal harus ada 1 supplier!') .min(1, 'Minimal harus ada 1 supplier!')
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array()
.of(Yup.string()) flags: Yup.array()
.of(Yup.string().required())
.min(1, 'Minimal harus ada 1 flag!') .min(1, 'Minimal harus ada 1 flag!')
.required('Flag wajib diisi!'), .required('Flag wajib diisi!'),
}); });
export const UpdateProductFormSchema = ProductFormSchema; export const UpdateProductFormSchema = ProductFormSchema;
export type ProductFormValues = Yup.InferType<typeof ProductFormSchema>; export type ProductFormValues = Yup.InferType<typeof ProductFormSchema>;
@@ -9,7 +9,11 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
@@ -24,7 +28,12 @@ import {
CreateProductPayload, CreateProductPayload,
UpdateProductPayload, UpdateProductPayload,
} from '@/types/api/master-data/product'; } from '@/types/api/master-data/product';
import { UomApi, ProductCategoryApi, SupplierApi, ProductApi } from '@/services/api/master-data'; import {
UomApi,
ProductCategoryApi,
SupplierApi,
ProductApi,
} from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
@@ -67,30 +76,36 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
[router] [router]
); );
const formikInitialValues = useMemo<ProductFormValues>(() => ({ const formikInitialValues = useMemo<ProductFormValues>(
name: initialValues?.name ?? '', () => ({
brand: initialValues?.brand ?? '', name: initialValues?.name ?? '',
sku: initialValues?.sku ?? '', brand: initialValues?.brand ?? '',
uom: initialValues?.uom sku: initialValues?.sku ?? '',
? { value: initialValues.uom.id, label: initialValues.uom.name } uom: initialValues?.uom
: null, ? { value: initialValues.uom.id, label: initialValues.uom.name }
uom_id: initialValues?.uom?.id ?? 0, : undefined,
product_category: initialValues?.product_category uom_id: initialValues?.uom?.id ?? 0,
? { value: initialValues.product_category.id, label: initialValues.product_category.name } product_category: initialValues?.product_category
: null, ? {
product_category_id: initialValues?.product_category?.id ?? 0, value: initialValues.product_category.id,
product_price: initialValues?.product_price ?? 0, label: initialValues.product_category.name,
selling_price: initialValues?.selling_price ?? 0, }
tax: initialValues?.tax ?? 0, : undefined,
expiry_period: initialValues?.expiry_period ?? 0, product_category_id: initialValues?.product_category?.id ?? 0,
supplier: null, // not used for payload, just for UI product_price: initialValues?.product_price ?? '',
supplier_ids: initialValues?.suppliers?.map(s => s.id) ?? [], selling_price: initialValues?.selling_price ?? '',
flags: initialValues?.flags ?? [], tax: initialValues?.tax ?? '',
}), [initialValues]); expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
flags: initialValues?.flags ?? [],
}),
[initialValues]
);
const formik = useFormik<ProductFormValues>({ const formik = useFormik<ProductFormValues>({
initialValues: formikInitialValues, initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateProductFormSchema : ProductFormSchema, validationSchema:
type === 'edit' ? UpdateProductFormSchema : ProductFormSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
setProductFormErrorMessage(''); setProductFormErrorMessage('');
const payload: CreateProductPayload = { const payload: CreateProductPayload = {
@@ -99,12 +114,16 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku, sku: values.sku,
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: values.product_price, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: values.selling_price, selling_price: parseInt(values.selling_price.toString()) || 0,
tax: values.tax, tax: parseInt(values.tax.toString()) || 0,
expiry_period: values.expiry_period, expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: (values.supplier_ids ?? []).filter((id): id is number => typeof id === 'number'), supplier_ids: values.supplier_ids.filter(
flags: (values.flags ?? []).filter((f): f is string => typeof f === 'string'), (id): id is number => typeof id === 'number'
),
flags: values.flags.filter(
(f): f is string => typeof f === 'string'
),
}; };
switch (type) { switch (type) {
case 'add': case 'add':
@@ -120,12 +139,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// UOM // UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState(''); const {
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; setInputValue: setUomSelectInputValue,
const { data: uoms, isLoading: isLoadingUoms } = useSWR(uomsUrl, UomApi.getAllFetcher); options: uomOptions,
const uomOptions = isResponseSuccess(uoms) isLoadingOptions: isLoadingUoms,
? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name })) } = useSelect(UomApi.basePath, 'id', 'name');
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val); formik.setFieldValue('uom', val);
@@ -134,12 +152,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}; };
// Product Category // Product Category
const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); const {
const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; setInputValue: setCategorySelectInputValue,
const { data: categories, isLoading: isLoadingCategories } = useSWR(categoriesUrl, ProductCategoryApi.getAllFetcher); options: categoryOptions,
const categoryOptions = isResponseSuccess(categories) isLoadingOptions: isLoadingCategories,
? categories?.data.map((cat) => ({ value: cat.id, label: cat.name })) } = useSelect(ProductCategoryApi.basePath, 'id', 'name');
: [];
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true); formik.setFieldTouched('product_category', true);
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
@@ -147,19 +164,25 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formik.setFieldValue('product_category_id', (val as OptionType)?.value); formik.setFieldValue('product_category_id', (val as OptionType)?.value);
}; };
// Supplier (multi select) // Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(suppliersUrl, SupplierApi.getAllFetcher); const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers) const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data ? suppliers?.data
.filter((sup) => sup.category === 'SAPRONAK') .filter((sup) => sup.category === 'SAPRONAK')
.map((sup) => ({ value: sup.id, label: sup.name })) .map((sup) => ({ value: sup.id, label: sup.name }))
: []; : [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldTouched('supplier_ids', true); formik.setFieldTouched('supplier_ids', true);
formik.setFieldValue('supplier_ids', arr.map((v) => (v as OptionType).value)); formik.setFieldValue(
'supplier_ids',
arr.map((v) => (v as OptionType).value)
);
}; };
const deleteProductClickHandler = () => { const deleteProductClickHandler = () => {
@@ -181,7 +204,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product' href='/master-data/product'
@@ -207,7 +230,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama produk' placeholder='Masukkan nama...'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -219,7 +242,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Merek' label='Merek'
name='brand' name='brand'
placeholder='Masukkan merek produk' placeholder='Masukkan merek...'
value={formik.values.brand} value={formik.values.brand}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -231,7 +254,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='SKU' label='SKU'
name='sku' name='sku'
placeholder='Masukkan SKU produk' placeholder='Masukkan SKU...'
value={formik.values.sku} value={formik.values.sku}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -242,6 +265,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Satuan' label='Satuan'
placeholder='Pilih satuan...'
value={formik.values.uom ?? undefined} value={formik.values.uom ?? undefined}
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
@@ -255,78 +279,113 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Kategori Produk' label='Kategori Produk'
placeholder='Pilih kategori produk...'
value={formik.values.product_category ?? undefined} value={formik.values.product_category ?? undefined}
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
options={categoryOptions} options={categoryOptions}
onInputChange={setCategorySelectInputValue} onInputChange={setCategorySelectInputValue}
isLoading={isLoadingCategories} isLoading={isLoadingCategories}
isError={formik.touched.product_category_id && Boolean(formik.errors.product_category_id)} isError={
formik.touched.product_category_id &&
Boolean(formik.errors.product_category_id)
}
errorMessage={formik.errors.product_category_id as string} errorMessage={formik.errors.product_category_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
<TextInput <NumberInput
required required
label='Harga Produk' label='Harga Produk'
name='product_price' name='product_price'
type='number' placeholder='Masukkan harga produk...'
placeholder='Masukkan harga produk'
value={formik.values.product_price} value={formik.values.product_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.product_price && Boolean(formik.errors.product_price)} decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={
formik.touched.product_price &&
Boolean(formik.errors.product_price)
}
errorMessage={formik.errors.product_price as string} errorMessage={formik.errors.product_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
type='number' placeholder='Masukkan harga jual...'
placeholder='Masukkan harga jual'
value={formik.values.selling_price} value={formik.values.selling_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.selling_price && Boolean(formik.errors.selling_price)} decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={
formik.touched.selling_price &&
Boolean(formik.errors.selling_price)
}
errorMessage={formik.errors.selling_price as string} errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
type='number' placeholder='Masukkan pajak...'
placeholder='Masukkan pajak'
value={formik.values.tax} value={formik.values.tax}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='%'
isError={formik.touched.tax && Boolean(formik.errors.tax)} isError={formik.touched.tax && Boolean(formik.errors.tax)}
errorMessage={formik.errors.tax as string} errorMessage={formik.errors.tax as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
type='number' placeholder='Masukkan periode kadaluarsa...'
placeholder='Masukkan periode kadaluarsa'
value={formik.values.expiry_period} value={formik.values.expiry_period}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.expiry_period && Boolean(formik.errors.expiry_period)} decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='hari'
isError={
formik.touched.expiry_period &&
Boolean(formik.errors.expiry_period)
}
errorMessage={formik.errors.expiry_period as string} errorMessage={formik.errors.expiry_period as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<SelectInput <SelectInput
required required
label='Supplier' label='Supplier'
placeholder='Pilih supplier...'
isMulti isMulti
value={supplierOptions.filter(opt => formik.values.supplier_ids.includes(opt.value))} value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
onInputChange={setSupplierSelectInputValue} onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers} isLoading={isLoadingSuppliers}
isError={formik.touched.supplier_ids && Boolean(formik.errors.supplier_ids)} isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string} errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
@@ -334,11 +393,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
placeholder='Pilih flags...'
isMulti isMulti
value={PRODUCT_FLAG_OPTIONS.filter(opt => formik.values.flags.includes(opt.value))} value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
onChange={val => { (formik.values.flags || []).includes(opt.value)
)}
onChange={(val) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldValue('flags', arr.map((v) => (v as OptionType).value)); formik.setFieldValue(
'flags',
arr.map((v) => (v as OptionType).value)
);
}} }}
options={PRODUCT_FLAG_OPTIONS} options={PRODUCT_FLAG_OPTIONS}
isError={formik.touched.flags && Boolean(formik.errors.flags)} isError={formik.touched.flags && Boolean(formik.errors.flags)}
@@ -435,4 +500,4 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
); );
}; };
export default ProductForm; export default ProductForm;
+1 -2
View File
@@ -7,7 +7,6 @@ import {
import { import {
CreateMovementPayload, CreateMovementPayload,
Movement, Movement,
UpdateMovementPayload,
} from '@/types/api/inventory/movement'; } from '@/types/api/inventory/movement';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -23,7 +22,7 @@ export const ProductWarehouseApi = new BaseApiService<
export const MovementApi = new BaseApiService< export const MovementApi = new BaseApiService<
Movement, Movement,
CreateMovementPayload, CreateMovementPayload,
UpdateMovementPayload unknown
>('/inventory/transfers'); >('/inventory/transfers');
export const inventoryAdjustmentApi = new BaseApiService< export const inventoryAdjustmentApi = new BaseApiService<
-2
View File
@@ -71,5 +71,3 @@ export type CreateMovementPayload = {
}[]; }[];
}[]; }[];
}; };
export type UpdateMovementPayload = CreateMovementPayload;