diff --git a/.husky/pre-commit b/.husky/pre-commit index e7bb3165..3782914b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,3 @@ npm run format npm run lint -npm run build +npm run build \ No newline at end of file diff --git a/src/app/purchase/add/page.tsx b/src/app/purchase/add/page.tsx new file mode 100644 index 00000000..7e1cb9e7 --- /dev/null +++ b/src/app/purchase/add/page.tsx @@ -0,0 +1,11 @@ +import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm'; + +const AddPurchaseRequest = () => { + return ( +
+ +
+ ); +}; + +export default AddPurchaseRequest; diff --git a/src/app/purchase/detail/edit/page.tsx b/src/app/purchase/detail/edit/page.tsx new file mode 100644 index 00000000..f93d1618 --- /dev/null +++ b/src/app/purchase/detail/edit/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isLoadingPurchase && (!purchase || isResponseError(purchase))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingPurchase && ( + + )} + {!isLoadingPurchase && isResponseSuccess(purchase) && ( + + )} +
+ ); +}; + +export default PurchaseEdit; diff --git a/src/app/purchase/detail/page.tsx b/src/app/purchase/detail/page.tsx new file mode 100644 index 00000000..df0de97b --- /dev/null +++ b/src/app/purchase/detail/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isLoadingPurchase && (!purchase || isResponseError(purchase))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingPurchase && ( +
+ +
+ )} + {!isLoadingPurchase && isResponseSuccess(purchase) && ( + + )} +
+ ); +}; + +export default PurchaseDetail; diff --git a/src/app/purchase/layout.tsx b/src/app/purchase/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/purchase/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/purchase/page.tsx b/src/app/purchase/page.tsx new file mode 100644 index 00000000..dc25a99d --- /dev/null +++ b/src/app/purchase/page.tsx @@ -0,0 +1,11 @@ +import PurchaseTable from '@/components/pages/purchase/PurchaseTable'; + +const Purchase = () => { + return ( +
+ +
+ ); +}; + +export default Purchase; diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 7b022971..d3ff80b1 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -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, '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 = ( +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ {collapsible && ( + + )} +
+ ); + + const cardContent = ( +
+ {children} + {actions &&
{actions}
} + {footer &&
{footer}
} +
+ ); + + return ( + <> + {image && ( +
+ {imageAlt +
+ )} +
+ {collapsible && hasContent ? ( + + {cardContent} + + ) : ( + <> + {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + {hasContent && cardContent} + + )} +
+ + ); + }; + if (variant === 'image-full' && image) { return (
-
- {imageAlt -
-
- {title &&

{title}

} - {subtitle &&

{subtitle}

} - {children} - {actions &&
{actions}
} -
- {footer &&
{footer}
} + {renderCardContent()}
); } return (
- {image && ( -
- {imageAlt -
- )} -
- {title &&

{title}

} - {subtitle &&

{subtitle}

} - {children} - {actions &&
{actions}
} -
- {footer &&
{footer}
} + {renderCardContent()}
); }; diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index 8506f65c..50d68017 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -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 ); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index a242b1e4..5a1dc806 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -10,15 +10,19 @@ import { } from 'react'; import { cn } from '@/lib/helper'; -export const useModal = () => { +export const useModal = (isNestingModal = false) => { const ref = useRef(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; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 79870274..77267090 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -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; onBlur?: FocusEventHandler; @@ -58,6 +59,7 @@ const DateInput = ({ readOnly = false, isLoading = false, isRange = false, + isNestedModal = false, }: DateInputProps) => { const [internalError, setInternalError] = useState(null); const [selected, setSelected] = useState(); @@ -74,7 +76,7 @@ const DateInput = ({ ? new Date(max.split('/').reverse().join('-')) : undefined; - const calendarModal = useModal(); + const calendarModal = useModal(isNestedModal); // --- Sync value props --- useEffect(() => { @@ -264,7 +266,7 @@ const DateInput = ({ ref={calendarModal.ref} className={{ modal: 'rounded', - modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`, + modalBox: `!max-w-max min-h-${isRange ? '124' : '110'} flex flex-col`, }} closeOnBackdrop > diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 8ff39e3d..35625883 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -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([]); - const { - setInputValue: setProductInputValue, - options: productOptions, - isLoadingOptions: isLoadingProductOptions, - } = useSelect('/products', 'id', 'name'); - - const { - setInputValue: setWarehouseInputValue, - options: warehouseOptions, - isLoadingOptions: isLoadingWarehouseOptions, - } = useSelect('/warehouses', 'id', 'name'); - - const [selectedProduct, setSelectedProduct] = useState( - null - ); - const [selectedWarehouse, setSelectedWarehouse] = useState( - 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[] = [ { header: '#', @@ -200,33 +165,7 @@ const MovementTable = () => { /> -
- - - - +
= }).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!') diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 438c09c6..ae9b68d9 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -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(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) => { + 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 ( <>
@@ -792,13 +842,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { errorMessage={formik.errors.transfer_reason} readOnly={type === 'detail'} /> - { 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 diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts index c9cb2b7b..bbcf9a8d 100644 --- a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts +++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts @@ -10,7 +10,9 @@ export const ProductCategoryFormSchema: Yup.ObjectSchema; + deleteClickHandler: () => void; +} + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: RowOptionsMenuProps) => { + return ( + + + + {/**/} + {/* */} + {/* Edit*/} + {/**/} + + + + ); +}; + +const PurchaseTable = () => { + // ===== STATE MANAGEMENT ===== + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [selectedPurchase, setSelectedPurchase] = useState( + null + ); + const [sorting, setSorting] = useState([]); + + // ===== 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[] = [ + { + 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 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + // ===== 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 = 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 ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + 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', + }} + /> +
+ + {/* ===== MODAL COMPONENTS ===== */} + + + ); +}; + +export default PurchaseTable; diff --git a/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx new file mode 100644 index 00000000..7909ade9 --- /dev/null +++ b/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx @@ -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 + | undefined; + + if (!touchedItem) { + return { isError: false, errorMessage: '' }; + } + + const isTouched = (touchedItem as Record)?.[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(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 ( +
+
+

+ {type === 'add' + ? 'Konfirmasi Penerimaan Produk' + : 'Edit Penerimaan Produk'} +

+ +
+ + + + + + + + + + + + + + + + + + + {purchaseItems?.map((purchaseItem, idx) => { + const formItem = formik.values.items?.[idx]; + return ( + + + + + + + + + + + + + + + ); + })} + +
ProdukGudangJumlahSatuan + Tanggal Diterima + * + + No. Surat Jalan + * + + Dokumen Surat Jalan + * + + Nomor Kendaraan + * + + Vendor Ekspedisi + * + + Jumlah Diterima + * + + Transport/Item + * + + Total Transport + * +
+ + + + + + + + + + + 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', + }} + /> + + + 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', + }} + /> + + + 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', + }} + /> + + + 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', + }} + /> + + + 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', + }} + /> + + + 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', + }} + /> + + + 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', + }} + /> + + + 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', + }} + /> +
+
+
+ +
+ + {/* Action buttons */} +
+
+ + + +
+
+ + {purchaseOrderFormErrorMessage && ( +
+ + {purchaseOrderFormErrorMessage} +
+ )} +
+
+ ); +}; + +export default PurchaseOrderAcceptApprovalForm; diff --git a/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts new file mode 100644 index 00000000..96836bc6 --- /dev/null +++ b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts @@ -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 = + 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() + .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() + .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 = + Yup.object({ + notes: Yup.string().nullable().default(null), + }); + +const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema = + 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() + .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() + .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() + .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 = + 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 = + 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 = + PurchaseManagerApprovalObjectSchema; + +export const PurchaseRequestManagerApprovalFormDefaultValues = ( + purchase?: Purchase +): PurchaseRequestManagerApprovalFormSchemaType => { + return { + notes: purchase?.notes ?? null, + }; +}; + +export type PurchaseRequestManagerApprovalFormValues = Yup.InferType< + typeof PurchaseRequestManagerApprovalFormSchema +>; + +export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema = + 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 +>; diff --git a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx new file mode 100644 index 00000000..63756ad9 --- /dev/null +++ b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx @@ -0,0 +1,1174 @@ +'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 useSWR from 'swr'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { useModal } from '@/components/Modal'; +import { SupplierApi } from '@/services/api/master-data'; +import { SupplierProducts } from '@/types/api/master-data/supplier'; +import { isResponseSuccess } from '@/lib/api-helper'; + +import { + PurchaseRequestStaffApprovalFormDefaultValues, + PurchaseRequestStaffApprovalFormInitialValues, + PurchaseRequestStaffApprovalFormSchema, + PurchaseStaffApprovalItemSchema, +} from './PurchaseOrderForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { formatNumber } from '@/lib/helper'; +import { PurchaseApi } from '@/services/api/purchase'; +import { + CreateStaffApprovalRequestPayload, + UpdateStaffApprovalRequestPayload, + Purchase, +} from '@/types/api/purchase/purchase'; +import { BaseApproval, BaseGroupedApproval } from '@/types/api/api-general'; +import { useRouter } from 'next/navigation'; + +interface PurchaseOrderStaffApprovalFormProps { + type?: 'add' | 'edit'; + initialValues?: Purchase; + onCancel?: () => void; + refreshApprovals?: () => void; + onModalClose?: () => void; + onRefetchData?: () => void; + rawDataApprovals?: BaseApproval[] | BaseGroupedApproval[]; +} + +const PurchaseOrderStaffApprovalForm = ({ + type: propType, + initialValues, + onCancel, + refreshApprovals, + onModalClose, + onRefetchData, + rawDataApprovals, +}: PurchaseOrderStaffApprovalFormProps) => { + const type = useMemo(() => { + if (propType && (propType === 'add' || propType === 'edit')) { + return propType; + } + + if (!rawDataApprovals || rawDataApprovals.length === 0) { + return 'add'; + } + + const currentStep = initialValues?.approval?.step_number || 1; + + switch (currentStep) { + case 1: + // Step 1 (Pengajuan) - Ini dari @PurchaseRequestForm + return 'add'; + case 2: + // Step 2 (Staff Purchase) - Staff approval pertama kali + return 'add'; + case 3: + // Step 3 (Manager Purchase) - Manager approval, boleh edit/delete/add items + return 'edit'; + default: + // Step 4+ (Penerimaan Barang dan selesai), tidak boleh edit kalau sudah disetujui + return 'edit'; + } + }, [rawDataApprovals, propType, initialValues?.approval?.step_number]); + + const router = useRouter(); + const searchParams = useSearchParams(); + const deleteModal = useModal(); + const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = + useState(''); + const [selectedItemForDelete, setSelectedItemForDelete] = useState< + number | null + >(null); + const [selectedItemIndex, setSelectedItemIndex] = useState( + null + ); + + // ===== UTILITY FUNCTIONS ===== + const canUpdatePurchaseItems = useMemo(() => { + if (!initialValues?.approval) return false; + + const currentStep = initialValues.approval.step_number; + return currentStep >= 3; + }, [initialValues?.approval]); + + const canShowDeleteAddButtons = useMemo(() => { + if (!initialValues?.approval) return false; + + const currentStep = initialValues.approval.step_number; + + // Step 2 (Staff Purchase) dengan mode 'add' tidak boleh add/delete items + // User hanya boleh input harga dan total harga untuk items yang sudah ada + if (currentStep === 2 && type === 'add') { + return false; + } + + // Step 3 (Manager Purchase) boleh add/delete items + return currentStep === 3; + }, [initialValues?.approval, type]); + + const isRepeaterInputError = ( + idx: number, + field: 'price' | 'total_price' | 'product_id' | 'warehouse_id' + ): { isError: boolean; errorMessage: string } => { + const touchedItem = formik.touched.items?.[idx]; + const errorItem = formik.errors.items?.[idx] as + | Record + | undefined; + + if (!touchedItem) { + return { isError: false, errorMessage: '' }; + } + + const isTouched = (touchedItem as Record)?.[field]; + const errorMessage = errorItem?.[field] || ''; + + return { + isError: Boolean(isTouched && errorMessage), + errorMessage: isTouched && errorMessage ? errorMessage : '', + }; + }; + + const isNewItem = (item: PurchaseStaffApprovalItemSchema) => { + return !item.purchase_item_id || item.purchase_item_id === 0; + }; + + // ===== SUBMISSION HANDLERS ===== + const createStaffApprovalHandler = useCallback( + async (payload: CreateStaffApprovalRequestPayload) => { + 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.staffApproval.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 updateStaffApprovalHandler = useCallback( + async (purchaseId: number, payload: UpdateStaffApprovalRequestPayload) => { + const res = await PurchaseApi.staffApproval.update(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] + ); + + // ===== DELETE HANDLER ===== + const deleteItemsHandler = useCallback(async () => { + const purchaseRequestId = searchParams.get('purchaseId') + ? parseInt(searchParams.get('purchaseId')!) + : initialValues?.id || 1; + + if (!purchaseRequestId) { + toast.error('Purchase Request ID is required'); + return; + } + + const itemIdsToDelete = selectedItemForDelete + ? [selectedItemForDelete] + : []; + + if (itemIdsToDelete.length === 0) { + toast.error('Tidak ada item yang dipilih untuk dihapus'); + return; + } + + try { + const res = await PurchaseApi.items.delete(purchaseRequestId, { + item_ids: itemIdsToDelete, + }); + + if (isResponseError(res)) { + toast.error(res.message || 'Gagal menghapus item pembelian'); + return; + } + + const successMessage = 'Item pembelian berhasil dihapus'; + toast.success(successMessage); + + refreshApprovals?.(); + onRefetchData?.(); + deleteModal.closeModal(); + setSelectedItemForDelete(null); + setSelectedItemIndex(null); + + if (selectedItemIndex !== null) { + const updatedPurchaseItems = formik.values.items?.filter( + (_, i) => i !== selectedItemIndex + ); + formik.setFieldValue('items', updatedPurchaseItems); + } + } catch (error) { + toast.error('Terjadi kesalahan saat menghapus item pembelian'); + console.error('Delete item error:', error); + } + }, [ + initialValues?.id, + searchParams, + selectedItemForDelete, + selectedItemIndex, + refreshApprovals, + onRefetchData, + deleteModal, + ]); + + // ===== API DATA FETCHING FOR SUPPLIER PRODUCTS ===== + const { data: supplierData, isLoading: isLoadingSupplierProducts } = useSWR( + initialValues?.supplier?.id + ? SupplierApi.basePath + '/' + initialValues.supplier.id + : null, + (url: string) => SupplierApi.getSingle(Number(url.split('/').pop())) + ); + + const baseSupplierProductOptions: OptionType[] = useMemo(() => { + if (!supplierData || !isResponseSuccess(supplierData)) { + return []; + } + + const supplier = supplierData.data as SupplierProducts; + const products = supplier.products || []; + + if (type === 'edit' && initialValues?.items) { + const currentProductIds = initialValues.items.map( + (item) => item.product_id + ); + return products + .filter((product) => currentProductIds.includes(product.id)) + .map((product) => ({ + value: product.id, + label: product.name, + })); + } + + return products.map((product) => ({ + value: product.id, + label: product.name, + })); + }, [supplierData, type, initialValues?.items]); + + // ===== FORM CONFIGURATION ===== + const formikInitialValues = useMemo(() => { + return initialValues + ? PurchaseRequestStaffApprovalFormDefaultValues(initialValues) + : PurchaseRequestStaffApprovalFormInitialValues; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: PurchaseRequestStaffApprovalFormSchema, + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + if (type === 'edit' && !canUpdatePurchaseItems) { + setPurchaseOrderFormErrorMessage( + 'Tidak bisa diupdate. Harus melewati step 3 dahulu (Approval dari Manager Purchasing).' + ); + return; + } + + const itemsPayload = (values.items || []).map((formItem) => { + const isNewItemForm = + !formItem.purchase_item_id || formItem.purchase_item_id === 0; + + let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; + + if (isNewItemForm) { + cleanPayload = { + product_id: Number(formItem.product_id) || 0, + warehouse_id: Number(formItem.warehouse_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } else { + cleanPayload = { + purchase_item_id: Number(formItem.purchase_item_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } + return cleanPayload; + }); + + const payload: UpdateStaffApprovalRequestPayload = { + action: values.action || 'APPROVED', + notes: values.notes || null, + items: itemsPayload, + }; + + if (type === 'add') { + await createStaffApprovalHandler( + payload as CreateStaffApprovalRequestPayload + ); + } else if (type === 'edit') { + const updateItemsPayload = (values.items || []).map((formItem) => { + const isNewItemForm = + !formItem.purchase_item_id || formItem.purchase_item_id === 0; + + let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; + + if (isNewItemForm) { + cleanPayload = { + product_id: Number(formItem.product_id) || 0, + warehouse_id: Number(formItem.warehouse_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } else { + cleanPayload = { + purchase_item_id: Number(formItem.purchase_item_id) || 0, + qty: Number(formItem.qty) || 0, + price: + typeof formItem.price === 'string' + ? parseFloat(formItem.price) || 0 + : Number(formItem.price) || 0, + total_price: + typeof formItem.total_price === 'string' + ? parseFloat(formItem.total_price) || 0 + : Number(formItem.total_price) || 0, + }; + } + + return cleanPayload; + }); + + const updatePayload: UpdateStaffApprovalRequestPayload = { + action: values.action || 'APPROVED', + notes: values.notes || null, + items: updateItemsPayload, + }; + + await updateStaffApprovalHandler( + initialValues?.id as number, + updatePayload + ); + } + }, + }); + + const supplierProductOptions = baseSupplierProductOptions; + + // ===== 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 || item.qty || 0, + product_id: item.product_id, + warehouse_id: item.warehouse.id, + product: { + name: item.product.name, + product_category: item.product.product_category, + uom: item.product.uom, + }, + warehouse: { + name: item.warehouse?.name || '', + }, + })); + } + + return []; + }, [initialValues?.items]); + + const groupedPurchaseItems = useMemo(() => { + if (!purchaseItems.length) return []; + + const warehouseGroups = purchaseItems.reduce( + (acc, item) => { + const warehouseId = item.warehouse_id; + if (!acc[warehouseId]) { + acc[warehouseId] = { + warehouseId, + warehouseName: item.warehouse.name, + items: [], + }; + } + acc[warehouseId].items.push(item); + return acc; + }, + {} as Record< + number, + { + warehouseId: number; + warehouseName: string; + items: typeof purchaseItems; + } + > + ); + + return Object.values(warehouseGroups); + }, [purchaseItems]); + + useEffect(() => { + if (purchaseItems.length > 0 && initialValues?.items) { + const updatedItems = purchaseItems.map((purchaseItem) => { + const originalItem = initialValues.items?.find( + (item) => item.id === purchaseItem.id + ); + const itemData = { + purchase_item_id: purchaseItem.id, + product_id: purchaseItem.product_id || 0, + product: { + value: purchaseItem.product_id || 0, + label: purchaseItem.product?.name || '', + }, + warehouse_id: purchaseItem.warehouse_id || 0, + qty: originalItem?.qty || purchaseItem.quantity || 0, + price: type === 'edit' && originalItem ? originalItem.price : '', + total_price: + type === 'edit' && originalItem ? originalItem.total_price : '', + }; + return itemData; + }); + formik.setFieldValue('items', updatedItems); + } + }, [purchaseItems, type, initialValues]); + + // ===== PURCHASE ITEM OPERATIONS ===== + const addPurchaseItem = () => { + const existingWarehouseId = + formik.values.items?.find((item) => (item.warehouse_id || 0) > 0) + ?.warehouse_id || + groupedPurchaseItems[0]?.warehouseId || + 0; + + const warehouseObject = initialValues?.items?.find( + (item) => item.warehouse.id === existingWarehouseId + )?.warehouse; + + const newItem = { + product_id: 0, + product: null, + warehouse_id: existingWarehouseId, + warehouse: warehouseObject + ? { + value: warehouseObject.id, + label: warehouseObject.name, + } + : null, + qty: 0, + price: '', + total_price: '', + }; + const newItems = [...(formik.values.items || []), newItem]; + formik.setFieldValue('items', newItems); + }; + + const removePurchaseItem = (idx: number) => { + const itemToRemove = formik.values.items?.[idx]; + + if ( + !itemToRemove?.purchase_item_id || + itemToRemove.purchase_item_id === 0 + ) { + const updatedPurchaseItems = formik.values.items?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('items', updatedPurchaseItems); + return; + } + setSelectedItemForDelete(itemToRemove.purchase_item_id); + setSelectedItemIndex(idx); + deleteModal.openModal(); + }; + + const handleProductChange = ( + idx: number, + val: OptionType | OptionType[] | null + ) => { + const product = val as OptionType | null; + const productId = (product as OptionType)?.value || 0; + + if (Number(productId) > 0) { + const currentItem = formik.values.items?.[idx]; + const warehouseId = currentItem?.warehouse_id || 0; + + const isDuplicate = formik.values.items?.some( + (item, itemIdx) => + itemIdx !== idx && + item.product_id === productId && + item.warehouse_id === warehouseId + ); + + if (isDuplicate) { + const existingItem = formik.values.items?.find( + (item, itemIdx) => + itemIdx !== idx && + item.product_id === productId && + item.warehouse_id === warehouseId + ); + const existingProductName = + existingItem?.product?.label || 'produk ini'; + + toast.error( + `Tidak bisa menambahkan item yang sama. ${existingProductName} sudah ada di gudang ini.` + ); + return; + } + } + + 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); + }; + + const handlePurchaseItemChange = ( + idx: number, + field: 'price' | 'total_price' | 'qty', + value: string | number + ) => { + const formItem = formik.values.items?.[idx]; + + if (!formItem) { + return; + } + + if (field === 'qty') { + const numValue = + typeof value === 'string' + ? value === '' + ? '' + : parseFloat(value) || 0 + : value; + + formik.setFieldValue(`items.${idx}.qty`, numValue); + + formik.setFieldValue(`items.${idx}.price`, ''); + formik.setFieldValue(`items.${idx}.total_price`, ''); + } + + if (field === 'price' || field === 'total_price') { + const numValue = + typeof value === 'string' + ? value === '' + ? '' + : parseFloat(value) || 0 + : value; + + formik.setFieldValue(`items.${idx}.${field}`, numValue); + + if ( + field === 'price' && + formItem.qty > 0 && + numValue !== '' && + numValue >= 0 + ) { + const calculatedTotal = numValue * formItem.qty; + formik.setFieldValue(`items.${idx}.total_price`, calculatedTotal); + } + + if ( + field === 'total_price' && + formItem.qty > 0 && + numValue !== '' && + numValue >= 0 + ) { + const calculatedPrice = numValue / formItem.qty; + formik.setFieldValue(`items.${idx}.price`, calculatedPrice); + } + } + }; + + return ( + <> +
+
+

+ {type === 'add' + ? 'Konfirmasi Item Pembelian' + : 'Edit Item Pembelian'} +

+
+ {groupedPurchaseItems.length > 0 ? ( +
+ {groupedPurchaseItems.map((warehouseData, index) => ( +
+
+ {/* Warehouse Header */} +
+ {index + 1}. {warehouseData.warehouseName.toUpperCase()} +
+ + {/* Items Table */} +
+ + + + + + + + + + {canShowDeleteAddButtons && type === 'edit' && ( + + )} + + + + {/* Existing Items */} + {warehouseData.items.map((purchaseItem) => { + const formItem = formik.values.items?.find( + (item) => + item.purchase_item_id === purchaseItem.id + ); + const formItemIndex = + formik.values.items?.findIndex( + (item) => + item.purchase_item_id === purchaseItem.id + ); + + if ( + !formItem?.purchase_item_id || + formItem.purchase_item_id === 0 + ) + return null; + + return ( + + + + + + + + + + ); + })} + + {/* New Items */} + {formik.values.items?.map((formItem, idx) => { + const isNewItemForm = isNewItem(formItem); + if (!isNewItemForm) return null; + + return ( + + + + + + + + + + ); + })} + +
ProdukJenis ProdukJumlahSatuan + Harga Satuan + * + + Total (Rp.) + * + Action
+ + handleProductChange(formItemIndex, val) + } + options={supplierProductOptions} + placeholder='Pilih produk' + isClearable={false} + isSearchable={false} + isDisabled={type === 'add'} + className={{ + wrapper: + 'min-w-52 md:min-w-72 lg:min-w-80', + }} + bottomLabel={ + 'Previous: ' + purchaseItem.product.name + } + /> + + + + {type === 'edit' ? ( + + handlePurchaseItemChange( + formItemIndex, + 'qty', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan jumlah' + allowNegative={false} + decimalScale={0} + bottomLabel={`Previous: ${formatNumber(purchaseItem.quantity)}`} + className={{ + wrapper: 'min-w-32', + }} + /> + ) : ( + + )} + + + + + handlePurchaseItemChange( + formItemIndex, + 'price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan harga satuan' + allowNegative={false} + decimalScale={2} + thousandSeparator=',' + decimalSeparator='.' + inputPrefix={'Rp'} + bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}`} + isError={ + isRepeaterInputError( + formItemIndex, + 'price' + ).isError + } + errorMessage={ + isRepeaterInputError( + formItemIndex, + 'price' + ).errorMessage + } + className={{ + wrapper: + 'min-w-48 md:min-w-64 lg:min-w-72', + }} + /> + + + handlePurchaseItemChange( + formItemIndex, + 'total_price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan total harga' + allowNegative={false} + decimalScale={2} + thousandSeparator=',' + decimalSeparator='.' + inputPrefix={'Rp'} + bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}`} + isError={ + isRepeaterInputError( + formItemIndex, + 'total_price' + ).isError + } + errorMessage={ + isRepeaterInputError( + formItemIndex, + 'total_price' + ).errorMessage + } + className={{ + wrapper: + 'min-w-48 md:min-w-64 lg:min-w-72', + }} + /> + +
+ {canUpdatePurchaseItems && + canShowDeleteAddButtons && ( + + )} +
+
+ + handleProductChange(idx, val) + } + options={supplierProductOptions} + isLoading={isLoadingSupplierProducts} + isError={ + isRepeaterInputError(idx, 'product_id') + .isError + } + errorMessage={ + isRepeaterInputError(idx, 'product_id') + .errorMessage + } + placeholder='Pilih Produk' + className={{ + wrapper: + 'min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + + handlePurchaseItemChange( + idx, + 'qty', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan jumlah' + allowNegative={false} + decimalScale={0} + className={{ + wrapper: 'min-w-24', + }} + /> + + + + + handlePurchaseItemChange( + idx, + 'price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan harga satuan' + allowNegative={false} + decimalScale={2} + thousandSeparator=',' + decimalSeparator='.' + inputPrefix={'Rp'} + isError={ + isRepeaterInputError(idx, 'price') + .isError + } + errorMessage={ + isRepeaterInputError(idx, 'price') + .errorMessage + } + className={{ + wrapper: + 'min-w-48 md:min-w-64 lg:min-w-72', + }} + /> + + + handlePurchaseItemChange( + idx, + 'total_price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan total harga' + allowNegative={false} + decimalScale={2} + thousandSeparator=',' + decimalSeparator='.' + inputPrefix={'Rp'} + isError={ + isRepeaterInputError(idx, 'total_price') + .isError + } + errorMessage={ + isRepeaterInputError(idx, 'total_price') + .errorMessage + } + className={{ + wrapper: + 'min-w-48 md:min-w-64 lg:min-w-72', + }} + /> + +
+ +
+
+
+
+ + {/* Add Item Button */} + {canShowDeleteAddButtons && ( +
+ +
+ )} + + {/* Add divider after table except for last item */} + {index < groupedPurchaseItems.length - 1 && ( +
+ )} +
+ ))} +
+ ) : ( +
+ Tidak ada data item pembelian +
+ )} +
+
+ +
+ + {/* Action buttons */} +
+
+ + + +
+
+ + {purchaseOrderFormErrorMessage && ( +
+ + {purchaseOrderFormErrorMessage} +
+ )} +
+
+ + {/* Delete Confirmation Modal */} + + + ); +}; + +export default PurchaseOrderStaffApprovalForm; diff --git a/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts b/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts new file mode 100644 index 00000000..414371c3 --- /dev/null +++ b/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts @@ -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 = + 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 = + 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, + }, + ], +}); diff --git a/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx b/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx new file mode 100644 index 00000000..7100b134 --- /dev/null +++ b/src/components/pages/purchase/form/request/PurchaseRequestForm.tsx @@ -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( + [] + ); + 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 + | 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(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( + () => getPurchaseRequestFormInitialValues(initialValues), + [initialValues] + ); + + const formik = useFormik({ + 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[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) => { + const value = e.target.value; + + formik.setFieldTouched('credit_term', true); + formik.setFieldValue('credit_term', value); + }, + [] + ); + + const handleCreditTermBlur = useCallback( + (e: React.FocusEvent) => { + 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 ( + <> +
+
+ +

+ {type === 'add' && 'Tambah Purchase Request'} + {type === 'edit' && 'Edit Purchase Request'} + {type === 'detail' && 'Detail Purchase Request'} +

+
+
+ {/* Basic Info Card */} + +
+ + + + + + + + +
+ +
+
+
+ + {/* Purchase Items Table */} + +
+ + + + {type !== 'detail' && ( + + )} + + + + + + {type !== 'detail' && } + + + + {formik.values.items?.map((item, idx) => ( + + {type !== 'detail' && ( + + )} + + + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedPurchaseItems( + formik.values.items?.map((_, idx) => idx) ?? [] + ); + } else { + setSelectedPurchaseItems([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Gudang + * + + Item + * + + Jumlah + * + Estimasi HargaSatuanAction
+ + ) => { + if (e.target.checked) { + setSelectedPurchaseItems([ + ...selectedPurchaseItems, + idx, + ]); + } else { + setSelectedPurchaseItems( + selectedPurchaseItems.filter((i) => i !== idx) + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + 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', + }} + /> + + { + 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', + }} + /> + + + 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', + }} + /> + + {}} + 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 + )}` + : '' + } + /> + + + +
+ +
+
+
+ {type !== 'detail' && ( +
+ {selectedPurchaseItems.length > 0 && ( + + )} + +
+ )} +
+ + {/* Action buttons */} +
+ {type !== 'detail' && ( +
+ + + +
+ )} + {type === 'detail' && ( +
+ + + +
+ )} +
+ + {purchaseRequestFormErrorMessage && ( +
+ + {purchaseRequestFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default PurchaseRequestForm; diff --git a/src/components/pages/purchase/order/PurchaseOrderDetail.tsx b/src/components/pages/purchase/order/PurchaseOrderDetail.tsx new file mode 100644 index 00000000..2f3bbfb0 --- /dev/null +++ b/src/components/pages/purchase/order/PurchaseOrderDetail.tsx @@ -0,0 +1,1070 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { + ColumnDef, + SortingState, + Row, + Table as TableType, +} from '@tanstack/react-table'; + +import ApprovalSteps, { + useApprovalSteps, +} from '@/components/pages/ApprovalSteps'; +import Table from '@/components/Table'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import { useModal } from '@/components/Modal'; +import CheckboxInput from '@/components/input/CheckboxInput'; +import Modal from '@/components/Modal'; +import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; +import PurchaseOrderStaffApprovalForm from '@/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm'; +import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm'; +import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice'; + +import Card from '@/components/Card'; +import { + CreateManagerApprovalRequestPayload, + CreateStaffApprovalRequestPayload, + Purchase, + PurchaseItem, +} from '@/types/api/purchase/purchase'; +import { PurchaseApi } from '@/services/api/purchase'; +import { isResponseError } from '@/lib/api-helper'; +import { toast } from 'react-hot-toast'; +import { useSearchParams } from 'next/navigation'; +import { formatCurrency, formatNumber, formatDate } from '@/lib/helper'; +import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line'; + +const ItemPembelianDropdown = ({ onEdit }: { onEdit: () => void }) => { + return ( + + + + ); +}; + +const PenerimaanBarangDropdown = ({ onEdit }: { onEdit: () => void }) => { + return ( + + + + ); +}; + +interface PurchaseOrderDetailProps { + type?: 'detail' | 'edit'; + initialValues?: Purchase; + refetchData?: () => void; +} + +const PurchaseOrderDetail = ({ + type = 'detail', + initialValues, + refetchData, +}: PurchaseOrderDetailProps) => { + // ===== MODAL HOOKS ===== + const searchParams = useSearchParams(); + const confirmationModalWithNotes = useModal(); + const staffApprovalModal = useModal(); + const staffRejectionModal = useModal(); + const acceptApprovalModal = useModal(); + const editModal = useModal(); + const penerimaanBarangModal = useModal(); + const deleteModal = useModal(); + + // ===== STATE MANAGEMENT ===== + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + + const selectedRowIds = Object.keys(rowSelection).map((item) => + parseInt(item) + ); + + // ===== COMPUTED VALUES ===== + const purchaseOrderItems = useMemo( + () => initialValues?.items || [], + [initialValues?.items] + ); + + const goodsReceiptItems = useMemo(() => { + return purchaseOrderItems.filter((item) => item.received_date); + }, [purchaseOrderItems]); + + const groupedGoodsReceiptItems = useMemo(() => { + const uniqueProducts = Array.from( + new Map( + goodsReceiptItems.map((item) => [item.product?.id, item]) + ).values() + ); + + return uniqueProducts.map((item, index) => { + const productGroupItems = goodsReceiptItems.filter( + (groupItem) => groupItem.product?.id === item.product?.id + ); + + const totalQty = productGroupItems.reduce( + (sum, item) => sum + (item.total_qty || 0), + 0 + ); + const receivedQty = productGroupItems.reduce( + (sum, item) => sum + (item.sub_qty || 0), + 0 + ); + const unreceivedQty = totalQty - receivedQty; + const nominalReceived = productGroupItems.reduce( + (sum, item) => sum + (item.sub_qty || 0) * (item.price || 0), + 0 + ); + const nominalUnreceived = productGroupItems.reduce( + (sum, item) => sum + unreceivedQty * (item.price || 0), + 0 + ); + + return { + productIndex: index + 1, + productName: item.product?.name || 'Unknown Product', + productGroupItems, + totalQty, + receivedQty, + unreceivedQty, + nominalReceived, + nominalUnreceived, + }; + }); + }, [goodsReceiptItems]); + + const approvalStep = useMemo(() => { + if (!initialValues?.approval) return null; + return initialValues.approval.step_number; + }, [initialValues?.approval]); + + const { + approvals, + isLoading: approvalsLoading, + rawDataApprovals, + refresh: refreshApprovals, + } = useApprovalSteps({ + latestApproval: initialValues?.approval, + approvalLines: PURCHASE_ORDER_APPROVAL_LINE, + moduleName: 'PURCHASES', + moduleId: initialValues?.id?.toString() ?? '', + params: { + limit: 100, + group_step_number: true, + }, + }); + + const showApprovalButton = + approvalStep !== null && approvalStep >= 1 && approvalStep <= 3; + + const canDeleteItems = useMemo(() => { + if (!initialValues?.approval) return false; + + const currentStep = initialValues.approval.step_number; + + const hasReachedStep5 = rawDataApprovals?.some( + (approval) => approval.step_number === 5 + ); + + return currentStep === 3 && !hasReachedStep5; + }, [initialValues?.approval, rawDataApprovals]); + + const handleApprovalClick = () => { + if (!approvalStep) return; + + switch (approvalStep) { + case 1: + staffApprovalModal.openModal(); + break; + case 2: + confirmationModalWithNotes.openModal(); + break; + case 3: + acceptApprovalModal.openModal(); + break; + default: + break; + } + }; + + const handleRejectionClick = () => { + if (!approvalStep) return; + + switch (approvalStep) { + case 1: + staffRejectionModal.openModal(); + break; + default: + break; + } + }; + + const canShowPurchaseOrderInvoice = useMemo(() => { + if (!initialValues?.approval) return false; + + const currentStep = initialValues.approval.step_number; + return currentStep >= 3; + }, [initialValues?.approval]); + + const canShowPenerimaanBarang = useMemo(() => { + if (!initialValues?.approval) return false; + + const currentStep = initialValues.approval.step_number; + return currentStep === 5; + }, [initialValues?.approval]); + + const totalBeforeTax = useMemo(() => { + return purchaseOrderItems.reduce( + (sum, item) => sum + (item.total_price || 0), + 0 + ); + }, [purchaseOrderItems]); + + // ===== SUBMISSION HANDLER ===== + const createStaffApprovalHandler = useCallback( + async (payload: CreateStaffApprovalRequestPayload) => { + const purchaseRequestId = searchParams.get('purchaseId') + ? parseInt(searchParams.get('purchaseId')!) + : initialValues?.id || 1; + + if (!purchaseRequestId) { + toast.error('Purchase Request ID is required'); + return; + } + + const res = await PurchaseApi.staffApproval.create( + purchaseRequestId, + payload + ); + + if (isResponseError(res)) { + toast.error(res.message); + return; + } + toast.success(res?.message as string); + refreshApprovals(); + refetchData?.(); + }, + [initialValues?.id, searchParams, refreshApprovals, refetchData] + ); + + const createManagerApprovalHandler = useCallback( + async (payload: CreateManagerApprovalRequestPayload) => { + const purchaseRequestId = searchParams.get('purchaseId') + ? parseInt(searchParams.get('purchaseId')!) + : initialValues?.id || 1; + + if (!purchaseRequestId) { + toast.error('Purchase Request ID is required'); + return; + } + + const res = await PurchaseApi.managerApproval.create( + purchaseRequestId, + payload + ); + + if (isResponseError(res)) { + toast.error(res.message); + return; + } + toast.success(res?.message as string); + refetchData?.(); + }, + [initialValues?.id, searchParams, refetchData] + ); + + // ===== MODAL HANDLERS ===== + const handleStaffApprovalModalClose = useCallback(() => { + refreshApprovals(); + refetchData?.(); + staffApprovalModal.closeModal(); + }, [refreshApprovals, refetchData]); + + const handleEditModalClose = useCallback(() => { + refreshApprovals(); + refetchData?.(); + editModal.closeModal(); + }, [refreshApprovals, refetchData]); + + // ===== DELETE HANDLER ===== + const deleteItemsHandler = useCallback(async () => { + const purchaseRequestId = searchParams.get('purchaseId') + ? parseInt(searchParams.get('purchaseId')!) + : initialValues?.id || 1; + + if (!purchaseRequestId) { + toast.error('Purchase Request ID is required'); + return; + } + + const itemIdsToDelete = selectedItem ? [selectedItem.id] : selectedRowIds; + + if (itemIdsToDelete.length === 0) { + toast.error('Pilih minimal 1 item untuk dihapus'); + return; + } + + setIsDeleteLoading(true); + + try { + const res = await PurchaseApi.items.delete(purchaseRequestId, { + item_ids: itemIdsToDelete, + }); + + if (isResponseError(res)) { + toast.error(res.message || 'Gagal menghapus item pembelian'); + return; + } + + const successMessage = selectedItem + ? 'Berhasil menghapus item pembelian' + : `Berhasil menghapus ${itemIdsToDelete.length} item pembelian`; + + toast.success(successMessage); + refreshApprovals(); + refetchData?.(); + deleteModal.closeModal(); + setSelectedItem(null); + setRowSelection({}); + } catch (error) { + toast.error('Terjadi kesalahan saat menghapus item pembelian'); + } finally { + setIsDeleteLoading(false); + } + }, [ + initialValues?.id, + searchParams, + selectedItem, + selectedRowIds, + refetchData, + ]); + + if (!initialValues) { + return null; + } + + const purchaseData = initialValues; + + const purchaseOrderColumns: ColumnDef[] = [ + ...(canDeleteItems + ? [ + { + id: 'select', + header: ({ table }: { table: TableType }) => ( +
+ +
+ ), + cell: ({ row }: { row: Row }) => { + return ( +
+ +
+ ); + }, + }, + ] + : []), + { + header: 'No', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'product.name', + header: 'Produk', + cell: (props) => props.row.original.product?.name || '-', + }, + { + accessorKey: 'product.product_category', + header: 'Jenis Produk', + cell: (props) => { + const category = props.row.original.product?.product_category; + if (typeof category === 'string') { + return category; + } + return category?.name || '-'; + }, + }, + { + accessorKey: 'sub_qty', + header: 'Jumlah', + cell: (props) => formatNumber(props.getValue() as number), + }, + { + accessorKey: 'product.uom.name', + header: 'Satuan', + cell: (props) => { + const uom = props.row.original.product?.uom; + if (uom && typeof uom === 'object' && uom.name) { + return uom.name; + } + return uom || '-'; + }, + }, + { + accessorKey: 'price', + header: 'Harga Satuan', + cell: (props) => formatCurrency(props.getValue() as number), + }, + { + accessorKey: 'total_price', + header: 'Total (Rp.)', + cell: (props) => formatCurrency(props.getValue() as number), + }, + ...(canDeleteItems + ? [ + { + header: 'Aksi', + cell: (props: { row: Row }) => { + const deleteClickHandler = () => { + setSelectedItem(props.row.original); + setRowSelection({}); + deleteModal.openModal(); + }; + + return ( + + ); + }, + }, + ] + : []), + ]; + + const goodsReceiptColumns: ColumnDef[] = [ + { + header: 'Tanggal Penerimaan', + accessorKey: 'received_date', + cell: (props) => + props.row.original.received_date + ? formatDate(props.row.original.received_date, 'DD MMM YYYY') + : '-', + }, + { + header: 'Gudang Tujuan', + accessorKey: 'warehouse.name', + cell: (props) => { + const warehouse = props.row.original.warehouse; + return warehouse?.name || '-'; + }, + }, + { + header: 'No. Surat Jalan', + accessorKey: 'travel_number', + cell: (props) => props.row.original.travel_number || '-', + }, + { + header: 'Dokumen Surat Jalan', + accessorKey: 'travel_document_path', + cell: (props) => { + const documentPath = props.row.original.travel_document_path; + return documentPath ? ( + + ) : ( + '-' + ); + }, + }, + { + header: 'No. Armada Pengangkut', + accessorKey: 'vehicle_number', + cell: (props) => props.row.original.vehicle_number || '-', + }, + { + header: 'Jumlah Total', + accessorKey: 'sub_qty', + cell: (props) => formatNumber(props.getValue() as number), + }, + { + header: 'Jumlah Diterima', + accessorKey: 'total_qty', + cell: (props) => formatNumber(props.getValue() as number), + }, + { + header: 'Ekspedisi', + accessorKey: 'expedition_name', + cell: (props) => '-', + }, + { + header: 'Transport /Item', + accessorKey: 'transport_per_item', + cell: (props) => formatCurrency(props.getValue() as number), + }, + { + header: 'Transport Total', + accessorKey: 'transport_total', + cell: (props) => formatCurrency(props.getValue() as number), + }, + ]; + + const summaryData = [ + { + label: 'Total Sebelum Pajak', + value: totalBeforeTax, + }, + { + label: 'Total Pembayaran', + value: totalBeforeTax, + }, + ]; + + const summaryColumns: ColumnDef<(typeof summaryData)[0]>[] = [ + { + accessorKey: 'label', + header: '', + cell: (props) => ( + + {props.getValue() as string} + + ), + }, + { + accessorKey: 'value', + header: '', + cell: (props) => ( + + {formatCurrency(props.getValue() as number)} + + ), + }, + ]; + + return ( +
+ {/* Approval and Action Buttons */} +
+ + + {showApprovalButton && ( +
+ + + +
+ )} +
+ + {/* Steps */} + {approvals && !approvalsLoading && ( +
+ +
+ )} + + {/* Detail Purchase Order */} + + {/* Order Information */} +
+

+ Informasi Pesanan +

+
+ {/* Kolom 1 */} +
+
+
+ + Area + + + : {purchaseData.items?.[0]?.warehouse?.area?.name || '-'} + +
+
+
+
+ + Lokasi + + + :{' '} + {purchaseData.items?.[0]?.warehouse?.type === 'LOKASI' && + purchaseData.items?.[0]?.warehouse?.location?.name + ? purchaseData.items[0].warehouse.location.name + : '-'} + +
+
+
+
+ + Gudang + + + : {purchaseData.items?.[0]?.warehouse?.name || '-'} + +
+
+
+ + {/* Kolom 2 */} +
+
+
+ + Nama Vendor + + + : {purchaseData.supplier?.name || '-'} ( + {purchaseData.supplier?.alias || ''}) + +
+
+
+
+ + Kategori Vendor + + + : {purchaseData.supplier?.category || '-'} + +
+
+
+
+ + Tgl. Jatuh Tempo + + + : {formatDate(purchaseData.due_date, 'D MMM YYYY')} ( + {purchaseData.credit_term} hari) + +
+
+
+
+ + Nomor + + + : {purchaseData.pr_number} + +
+
+
+
+ + Nomor PO + +
+ {canShowPurchaseOrderInvoice && + purchaseData.po_number && + purchaseData.po_number !== 'Belum dibuat' ? ( + + ) : ( + <> + : Belum dibuat + + )} +
+
+
+
+
+
+ + {/* Item Pembelian Section */} +
+
+

+ Item Pembelian +

+ {canShowPurchaseOrderInvoice && ( + + + + )} +
+
+ {/* Product Table */} +
+ + data={purchaseOrderItems} + columns={purchaseOrderColumns} + isLoading={false} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + enableRowSelection={() => canDeleteItems} + className={{ + containerClassName: 'm-0', + tableWrapperClassName: 'overflow-x-auto', + tableClassName: 'w-full table-auto', + headerRowClassName: 'bg-gray-50 border-b border-gray-200', + headerColumnClassName: + 'px-6 py-4 text-sm font-semibold text-gray-700 text-left', + bodyRowClassName: + 'border-b border-gray-100 hover:bg-gray-50 transition-colors', + bodyColumnClassName: 'px-6 py-4 text-sm text-gray-900', + paginationClassName: 'hidden', + }} + /> +
+ + {/* Bulk Action Buttons */} + {selectedRowIds.length > 0 && canDeleteItems && ( +
+ +
+ )} + + {/* Bottom Section - Catatan dan Total */} +
+ {/* Catatan Section */} +
+

Catatan

+
+ {purchaseData.notes || 'Tidak ada catatan'} +
+
+ + {/* Summary Section */} +
+ + + + + + + + {/* Penerimaan Barang */} + + {/* Detail Penerimaan Barang Section */} +
+
+

+ Informasi Penerimaan Barang +

+ {canShowPenerimaanBarang && ( + + + + )} +
+
+ {groupedGoodsReceiptItems.length > 0 ? ( +
+ {groupedGoodsReceiptItems.map((productData, index) => ( +
+
+ {/* Product Header */} +
+ {productData.productIndex}.{' '} + {productData.productName.toUpperCase()} +
+ + {/* Product Table */} + + data={productData.productGroupItems} + columns={goodsReceiptColumns} + isLoading={false} + className={{ + containerClassName: 'm-0', + tableWrapperClassName: 'overflow-x-auto', + tableClassName: 'w-full table-auto', + headerRowClassName: + 'bg-gray-50 border-b border-gray-200', + headerColumnClassName: + 'px-4 py-3 text-sm font-semibold text-gray-700 text-left whitespace-nowrap', + bodyRowClassName: + 'border-b border-gray-100 hover:bg-gray-50 transition-colors', + bodyColumnClassName: + 'px-4 py-3 text-sm text-gray-900 whitespace-nowrap', + paginationClassName: 'hidden', + }} + /> +
+ + {/* Add divider after table except for last item */} + {index < groupedGoodsReceiptItems.length - 1 && ( +
+ )} +
+ ))} +
+ ) : ( +
+ Tidak ada data penerimaan barang +
+ )} +
+
+
+ + {/* Confirmation Modal with Notes */} + { + const payload: CreateManagerApprovalRequestPayload = { + notes: notes || null, + }; + + await createManagerApprovalHandler(payload); + await refreshApprovals(); + await refetchData?.(); + confirmationModalWithNotes.closeModal(); + }, + }} + secondaryButton={{ + text: 'Batal', + }} + /> + + {/* Staff Approval Modal */} + + + + + {/* Accept Approval Modal */} + + + + + {/* Edit Modal */} + + + + + {/* Penerimaan Barang Modal */} + + + + + {/* Staff Rejection Modal */} + { + const payload: CreateStaffApprovalRequestPayload = { + action: 'REJECTED', + notes: notes || null, + items: [], + }; + + await createStaffApprovalHandler(payload); + await refetchData?.(); + staffRejectionModal.closeModal(); + }, + }} + secondaryButton={{ + text: 'Batal', + }} + /> + + {/* Delete Confirmation Modal */} + { + await deleteItemsHandler(); + }, + }} + secondaryButton={{ + text: 'Batal', + }} + /> + + ); +}; + +export default PurchaseOrderDetail; diff --git a/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx b/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx new file mode 100644 index 00000000..d7497d7e --- /dev/null +++ b/src/components/pages/purchase/order/PurchaseOrderInvoice.tsx @@ -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 = () => ( + + + {/* Header Section */} + + + + PT LUMBUNG TELUR INDONESIA + + + SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. + Cipedes, Kec. Sukajadi, Kota Bandung 40162 + + + + + {/* Purchase Order Title */} + + PURCHASE ORDER + + PO Number: {purchaseData?.po_number || '-'} + + Date:{' '} + {purchaseData?.po_date + ? formatDate(purchaseData.po_date, 'DD MMM YYYY') + : formatDate(new Date(), 'DD MMM YYYY')} + + + + + {/* Vendor and Ship To Table */} + + + + Vendor + + + Ship To + + + + + + {purchaseData?.supplier?.name || '-'} ( + {purchaseData?.supplier?.alias || ''}) + + {purchaseData?.supplier?.category || '-'} + + Credit Term: {purchaseData?.credit_term || 0} hari + + + Due Date:{' '} + {purchaseData?.due_date + ? formatDate(purchaseData.due_date, 'DD MMM YYYY') + : '-'} + + + + + PT LUMBUNG TELUR INDONESIA + + + {purchaseData?.items?.[0]?.warehouse.type === 'LOKASI' + ? purchaseData.items[0].warehouse.location.name + : '-'} + + + {purchaseData?.items?.[0]?.warehouse.type === 'LOKASI' + ? purchaseData.items[0].warehouse.location.address + : '-'} + + + + + + {/* Item Description Table */} + + + + + Item Description + + + Unit Price + + + Quantity + + + Total Amount + + + {purchaseData?.items?.map((item, index) => { + const isLastItem = + index === (purchaseData?.items?.length || 0) - 1; + return ( + + + {item.product?.name || '-'} + + + Rp{formatNumber(item.price || 0)} + + + {formatNumber(item.sub_qty || 0)} + + + Rp{formatNumber(item.total_price || 0)} + + + ); + }) || []} + + {/* Grand Total Row inside table */} + + + + + + + + + Grand Total + + + Rp{formatNumber(grandTotal)} + + + + + + {/* Product Allocation Section */} + + Product Allocation + + + + Warehouse Name + + + Area + + + Location Address + + + Product Allocation + + + {purchaseData?.items?.map((item, itemIndex) => ( + + + {item.warehouse?.name || '-'} + + + {item.warehouse?.area?.name || '-'} + + + + {item.warehouse?.type === 'LOKASI' + ? item.warehouse.location.address + : '-'} + + + + {/* Inner table for product allocation */} + + {/* Header for inner table */} + + Item + + Quantity + + + {/* Data row */} + + + {item.product?.name || '-'} + + + {formatNumber(item.total_qty || 0)} of{' '} + {formatNumber(item.sub_qty || 0)} + + + + + + )) || []} + + + + {/* Footer with Special Instructions */} + + + + + Notes + + + + + {purchaseData?.notes || '-'} + + + + + PT LUMBUNG TELUR INDONESIA + + + + + ); + + const blob = await pdf().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 ( +
+
No purchase order data available
+
+ ); + } + + return purchaseData?.po_number && + purchaseData.po_number !== 'Belum dibuat' ? ( + + ) : null; +}; + +export default PurchaseOrderInvoice; diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 98579a4f..d3a2f19e 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -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, diff --git a/src/config/constant.ts b/src/config/constant.ts index 76322200..dc36025b 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -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 = [ diff --git a/src/services/api/purchase.ts b/src/services/api/purchase.ts new file mode 100644 index 00000000..d0438e88 --- /dev/null +++ b/src/services/api/purchase.ts @@ -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 | undefined> => { + return await basePurchaseApi.customRequest< + BaseApiResponse<{ message: string }> + >(`${purchaseRequestId}/approvals/staff`, { + method: 'POST', + payload, + }); + }, + + update: async ( + purchaseRequestId: number, + payload: UpdateStaffApprovalRequestPayload + ): Promise | undefined> => { + return await basePurchaseApi.customRequest< + BaseApiResponse<{ message: string }> + >(`${purchaseRequestId}/approvals/staff`, { + method: 'POST', + payload, + }); + }, + }, + + managerApproval: { + create: async ( + purchaseRequestId: number, + payload: CreateManagerApprovalRequestPayload + ): Promise | undefined> => { + return await basePurchaseApi.customRequest< + BaseApiResponse<{ message: string }> + >(`${purchaseRequestId}/approvals/manager`, { + method: 'POST', + payload, + }); + }, + }, + + acceptApproval: { + create: async ( + purchaseRequestId: number, + payload: CreateAcceptApprovalRequestPayload + ): Promise | undefined> => { + return await basePurchaseApi.customRequest< + BaseApiResponse<{ message: string }> + >(`${purchaseRequestId}/receipts`, { + method: 'POST', + payload, + }); + }, + }, + + items: { + delete: async ( + purchaseRequestId: number, + payload: DeletePurchaseRequestItemPayload + ): Promise | undefined> => { + return await basePurchaseApi.customRequest< + BaseApiResponse<{ message: string }> + >(`${purchaseRequestId}/items`, { + method: 'DELETE', + payload, + }); + }, + }, +}; diff --git a/src/types/api/master-data/supplier.d.ts b/src/types/api/master-data/supplier.d.ts index 3782cddb..81d41771 100644 --- a/src/types/api/master-data/supplier.d.ts +++ b/src/types/api/master-data/supplier.d.ts @@ -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; diff --git a/src/types/api/purchase/purchase.d.ts b/src/types/api/purchase/purchase.d.ts new file mode 100644 index 00000000..56cbd810 --- /dev/null +++ b/src/types/api/purchase/purchase.d.ts @@ -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;