Files
lti-web-client/src/components/pages/purchase/order/PurchaseOrderDetail.tsx
T

843 lines
27 KiB
TypeScript

'use client';
import { useCallback, useMemo, useState } from 'react';
import { ColumnDef, SortingState } 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,
Purchase,
PurchaseItem,
} from '@/types/api/purchase/purchase';
import {
ManagerApprovalApi,
PurchaseDeleteItemsApi,
} 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 (
<RowOptionsMenuWrapper type='dropdown'>
<Button
onClick={onEdit}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RowOptionsMenuWrapper>
);
};
const PenerimaanBarangDropdown = ({ onEdit }: { onEdit: () => void }) => {
return (
<RowOptionsMenuWrapper type='dropdown'>
<Button
onClick={onEdit}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RowOptionsMenuWrapper>
);
};
interface PurchaseOrderDetailProps {
type?: 'detail' | 'edit';
initialValues?: Purchase;
}
const PurchaseOrderDetail = ({
type = 'detail',
initialValues,
}: PurchaseOrderDetailProps) => {
// ===== MODAL HOOKS =====
const searchParams = useSearchParams();
const confirmationModalWithNotes = useModal();
const staffApprovalModal = useModal();
const acceptApprovalModal = useModal();
const editModal = useModal();
const penerimaanBarangModal = useModal();
const deleteModal = useModal();
// ===== STATE MANAGEMENT =====
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(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 canUpdatePurchaseItems = useMemo(() => {
if (!initialValues?.approval) return false;
const currentStep = initialValues.approval.step_number;
return currentStep >= 4;
}, [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 totalBeforeTax = useMemo(() => {
return purchaseOrderItems.reduce(
(sum, item) => sum + (item.total_price || 0),
0
);
}, [purchaseOrderItems]);
// ===== SUBMISSION HANDLER =====
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 ManagerApprovalApi.createManagerApproval(
purchaseRequestId,
payload
);
if (isResponseError(res)) {
toast.error(res.message);
return;
}
toast.success(res?.message as string);
},
[initialValues?.id, searchParams]
);
// ===== 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 PurchaseDeleteItemsApi.deleteItems(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);
deleteModal.closeModal();
setSelectedItem(null);
setRowSelection({});
} catch (error) {
toast.error('Terjadi kesalahan saat menghapus item pembelian');
} finally {
setIsDeleteLoading(false);
}
}, [initialValues?.id, searchParams, selectedItem, selectedRowIds]);
if (!initialValues) {
return null;
}
const purchaseData = initialValues;
const purchaseOrderColumns: ColumnDef<PurchaseItem>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => {
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
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),
},
{
header: 'Aksi',
cell: (props) => {
const deleteClickHandler = () => {
setSelectedItem(props.row.original);
setRowSelection({});
deleteModal.openModal();
};
return (
<Button
type='button'
color='error'
className='text-sm'
onClick={deleteClickHandler}
>
<Icon icon='mdi:trash-can' width={16} height={16} />
</Button>
);
},
},
];
const goodsReceiptColumns: ColumnDef<PurchaseItem>[] = [
{
header: 'Header Placeholder untuk tiap Produk Penerimaan Barang',
columns: [
{
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'received_date',
header: 'Tanggal Penerimaan',
cell: (props) =>
props.row.original.received_date
? formatDate(props.row.original.received_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'warehouse.name',
header: 'Gudang Tujuan',
cell: (props) => {
const warehouse = props.row.original.warehouse;
return warehouse?.name || '-';
},
},
{
accessorKey: 'travel_number',
header: 'No. Surat Jalan',
cell: (props) => props.row.original.travel_number || '-',
},
{
accessorKey: 'travel_document_path',
header: 'Dokumen Surat Jalan',
cell: (props) => {
const documentPath = props.row.original.travel_document_path;
return documentPath ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm'
href={documentPath}
target='_blank'
rel='noopener noreferrer'
>
<Icon
icon='material-symbols:file-open-outline'
width={16}
height={16}
/>
Lihat Dokumen
</Button>
) : (
'-'
);
},
},
{
accessorKey: 'vehicle_number',
header: 'No. Armada',
cell: (props) => props.row.original.vehicle_number || '-',
},
{
accessorKey: 'total_qty',
header: 'Jumlah Total',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'sub_qty',
header: 'Jumlah Diterima',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'price',
header: 'Transport /Item',
cell: (props) => formatCurrency(props.getValue() as number),
},
{
accessorKey: 'total_price',
header: '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) => (
<span className='font-semibold text-gray-700 text-sm'>
{props.getValue() as string}
</span>
),
},
{
accessorKey: 'value',
header: '',
cell: (props) => (
<span className='font-semibold text-gray-900 text-sm text-right'>
{formatCurrency(props.getValue() as number)}
</span>
),
},
];
return (
<section className='w-full'>
{/* Approval and Action Buttons */}
<div className='flex flex-wrap gap-3 my-6'>
<Button
onClick={() => staffApprovalModal.openModal()}
variant='outline'
color='info'
className='w-full sm:w-fit'
>
<Icon icon='mdi:account-check-outline' width={20} height={20} />
Staff Approval
</Button>
<Button
onClick={() => confirmationModalWithNotes.openModal()}
variant='outline'
color='warning'
className='w-full sm:w-fit'
>
<Icon icon='mdi:note-edit-outline' width={20} height={20} />
Manager Approval
</Button>
<Button
onClick={() => acceptApprovalModal.openModal()}
variant='outline'
color='success'
className='w-full sm:w-fit'
>
<Icon
icon='mdi:package-variant-closed-check'
width={20}
height={20}
/>
Accept Approval
</Button>
</div>
{/* Steps */}
{approvals && !approvalsLoading && (
<div className='my-8'>
<ApprovalSteps approvals={approvals} />
</div>
)}
{/* Detail Purchase Order */}
<Card
collapsible
title='Detail Purchase Order'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
{/* Order Information */}
<div className='my-8'>
<h3 className='text-lg font-semibold text-gray-800 mb-6 pb-3 border-b border-gray-200'>
Informasi Pesanan
</h3>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-4'>
{/* Kolom 1 */}
<div className='space-y-4'>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Area
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.items?.[0]?.warehouse?.area?.name || '-'}
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Lokasi
</span>
<span className='text-gray-900 ml-3 break-all'>
:{' '}
{purchaseData.items?.[0]?.warehouse?.type === 'LOKASI' &&
purchaseData.items?.[0]?.warehouse?.location?.name
? purchaseData.items[0].warehouse.location.name
: '-'}
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Gudang
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.items?.[0]?.warehouse?.name || '-'}
</span>
</div>
</div>
</div>
{/* Kolom 2 */}
<div className='space-y-4'>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Nama Vendor
</span>
<span className='text-gray-900 font-medium ml-3 break-all'>
: {purchaseData.supplier?.name || '-'} (
{purchaseData.supplier?.alias || ''})
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Kategori Vendor
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.supplier?.category || '-'}
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Tgl. Jatuh Tempo
</span>
<span className='text-gray-900 ml-3 break-all'>
: {formatDate(purchaseData.due_date, 'D MMM YYYY')} (
{purchaseData.credit_term} hari)
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Nomor
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.pr_number}
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Nomor PO
</span>
<div className='ml-3'>
{canUpdatePurchaseItems &&
purchaseData.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<PurchaseOrderInvoice data={purchaseData} />
) : (
<>
: <i className='text-gray-400'>Belum dibuat</i>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Item Pembelian Section */}
<div className='mb-8'>
<div className='flex items-center justify-between mb-4 pb-2 border-b border-gray-200'>
<h3 className='text-lg font-semibold text-gray-800'>
Item Pembelian
</h3>
{canUpdatePurchaseItems && (
<RowDropdownOptions isLast2Rows>
<ItemPembelianDropdown onEdit={editModal.openModal} />
</RowDropdownOptions>
)}
</div>
<div className='rounded-lg border border-gray-200 overflow-hidden'>
{/* Product Table */}
<div className='overflow-x-auto'>
<Table<PurchaseItem>
data={purchaseOrderItems}
columns={purchaseOrderColumns}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={() => true}
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',
}}
/>
</div>
{/* Bulk Action Buttons */}
{selectedRowIds.length > 0 && (
<div className='flex justify-center items-center mt-4 gap-4 px-6 py-4 bg-gray-50 border-t border-gray-200'>
<Button
type='button'
color='error'
onClick={() => {
setSelectedItem(null);
deleteModal.openModal();
}}
disabled={selectedRowIds.length === 0}
className='w-fit text-sm'
>
<Icon icon='mdi:trash-can' width={16} height={16} />
Hapus Terpilih ({selectedRowIds.length})
</Button>
</div>
)}
{/* Bottom Section - Catatan dan Total */}
<div className='border-t border-gray-200 grid grid-cols-1 lg:grid-cols-5 divide-x divide-gray-200'>
{/* Catatan Section */}
<div className='lg:col-span-3 p-6 border-r border-gray-200'>
<h4 className='font-medium text-gray-700 mb-3'>Catatan</h4>
<div className='text-gray-900 min-h-[60px] italic text-sm'>
{purchaseData.notes || 'Tidak ada catatan'}
</div>
</div>
{/* Summary Section */}
<div className='lg:col-span-2 p-6'>
<Table
data={summaryData}
columns={summaryColumns}
isLoading={false}
className={{
containerClassName: 'm-0',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto',
headerRowClassName: 'hidden',
headerColumnClassName: 'hidden',
paginationClassName: 'hidden',
bodyRowClassName:
'border-b border-gray-100 hover:bg-gray-50 transition-colors',
bodyColumnClassName: 'px-6 py-4 text-sm text-gray-900',
}}
/>
</div>
</div>
</div>
</div>
</Card>
{/* Penerimaan Barang */}
<Card
collapsible
title='Penerimaan Barang'
variant='bordered'
className={{
wrapper: 'w-full mt-8',
}}
>
{/* Detail Penerimaan Barang Section */}
<div className='my-8'>
<div className='flex items-center justify-between mb-4 pb-2 border-b border-gray-200'>
<h3 className='text-lg font-semibold text-gray-800'>
Informasi Penerimaan Barang
</h3>
<RowDropdownOptions isLast2Rows>
<PenerimaanBarangDropdown
onEdit={penerimaanBarangModal.openModal}
/>
</RowDropdownOptions>
</div>
<div className='rounded-lg border border-gray-200 overflow-hidden'>
<div className='overflow-x-auto'>
<Table<PurchaseItem>
data={goodsReceiptItems}
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',
}}
/>
</div>
</div>
</div>
</Card>
{/* Confirmation Modal with Notes */}
<ConfirmationModalWithNotes
ref={confirmationModalWithNotes.ref}
type='success'
text='Apakah Anda yakin ingin melanjutkan approval ini?'
placeholder='(Opsional) Tambahkan catatan untuk approval ini...'
rows={4}
closeOnBackdrop
primaryButton={{
text: 'Ya, Lanjutkan',
color: 'success',
onClick: async (notes) => {
const payload: CreateManagerApprovalRequestPayload = {
notes: notes || null,
};
await createManagerApprovalHandler(payload);
confirmationModalWithNotes.closeModal();
},
}}
secondaryButton={{
text: 'Batal',
}}
/>
{/* Staff Approval Modal */}
<Modal
ref={staffApprovalModal.ref}
closeOnBackdrop
className={{
modalBox: 'w-full max-w-screen-2xl max-h-[90vh] overflow-y-auto',
}}
>
<PurchaseOrderStaffApprovalForm
type='add'
initialValues={purchaseData}
onCancel={staffApprovalModal.closeModal}
/>
</Modal>
{/* Accept Approval Modal */}
<Modal
ref={acceptApprovalModal.ref}
closeOnBackdrop
className={{
modalBox: 'w-full max-w-screen-2xl max-h-[90vh] overflow-y-auto',
}}
>
<PurchaseOrderAcceptApprovalForm
type='add'
initialValues={purchaseData}
onCancel={acceptApprovalModal.closeModal}
/>
</Modal>
{/* Edit Modal */}
<Modal
ref={editModal.ref}
closeOnBackdrop
className={{
modalBox: 'w-full max-w-screen-2xl max-h-[90vh] overflow-y-auto',
}}
>
<PurchaseOrderStaffApprovalForm
type='edit'
initialValues={purchaseData}
onCancel={editModal.closeModal}
/>
</Modal>
{/* Penerimaan Barang Modal */}
<Modal
ref={penerimaanBarangModal.ref}
closeOnBackdrop
className={{
modalBox: 'w-full max-w-screen-2xl max-h-[90vh] overflow-y-auto',
}}
>
<PurchaseOrderAcceptApprovalForm
type='edit'
initialValues={purchaseData}
onCancel={penerimaanBarangModal.closeModal}
/>
</Modal>
{/* Delete Confirmation Modal */}
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah Anda yakin ingin menghapus ${
selectedItem
? 'item pembelian ini?'
: `${selectedRowIds.length} item pembelian yang dipilih?`
}`}
closeOnBackdrop
primaryButton={{
text: 'Ya, Hapus',
color: 'error',
isLoading: isDeleteLoading,
onClick: async () => {
await deleteItemsHandler();
},
}}
secondaryButton={{
text: 'Batal',
}}
/>
</section>
);
};
export default PurchaseOrderDetail;