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

904 lines
27 KiB
TypeScript

'use client';
import { useCallback, useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import ApprovalSteps, {
formatGroupedApprovalsToApprovalSteps,
} 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 Modal from '@/components/Modal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
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 { BaseGroupedApproval } from '@/types/api/api-general';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
import Card from '@/components/Card';
import {
CreateManagerApprovalRequestPayload,
Purchase,
PurchaseItem,
} from '@/types/api/purchase/purchase';
import {
createdUser,
dummyAreas,
dummyLocations,
dummyProductWarehouses,
dummyWarehouses,
} from '@/dummy/marketing.dummy';
import { ManagerApprovalApi } from '@/services/api/purchase';
import { isResponseError } from '@/lib/api-helper';
import { toast } from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
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>
);
};
interface PurchaseOrderDetailProps {
type?: 'detail' | 'edit';
data?: Purchase;
}
const dummyPurchaseData: Purchase = {
id: 1,
pr_number: 'PR-MBU-01837',
po_number: 'PO-MBU-01837',
po_document_path: '/documents/po-mbu-01837.pdf',
po_date: '2025-01-10T00:00:00Z',
supplier: {
id: 1,
name: 'PT. CHAROEN POKPHAND JAYA FARM',
alias: 'CP JAYA FARM',
pic: 'Budi Santoso',
type: 'Supplier',
category: 'Feed',
hatchery: 'Jawa Barat',
phone: '+62-22-7563850',
email: 'info@cp.co.id',
address:
'Jl. Raya Bandung - Sumedang Km. 28, Desa Cisantana, Kec. Cigendel, Kabupaten Sumedang, Jawa Barat 45363',
npwp: '01.938.451.6-433.000',
account_number: '123-456-7890',
due_date: 30,
balance: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
created_user: createdUser,
},
credit_term: 30,
due_date: '2025-11-13T00:00:00Z',
grand_total: 65000000,
notes: null,
area: dummyAreas[0],
location: dummyLocations[0],
items: [
{
id: 1,
purchase_id: 1,
product: {
id: 1,
name: 'CP Vaksin',
brand: '',
sku: '',
product_price: 0,
selling_price: 0,
tax: 0,
expiry_period: 0,
uom: {
id: 1,
name: 'Ekor',
created_user: {
id: 1,
id_user: 1,
email: 'hello@gmail.com',
name: 'Admin',
},
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
product_category: {
id: 1,
code: 'DOC',
name: 'DOC',
created_user: {
id: 1,
id_user: 1,
email: 'hello@gmail.com',
name: 'Admin',
},
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
suppliers: [],
flags: [],
created_user: {
id: 1,
id_user: 1,
email: 'hello@gmail.com',
name: 'Admin',
},
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
product_warehouse: dummyProductWarehouses[0],
quantity: 10000,
sub_qty: 10000,
total_qty: 10000,
total_used: 0,
price: 6500,
total_price: 65000000,
received_date: null,
travel_number: null,
travel_number_docs: null,
vehicle_number: null,
warehouse: dummyWarehouses[0],
},
],
created_at: '2025-01-10T00:00:00Z',
updated_at: '2025-01-10T00:00:00Z',
created_by: 1,
deleted_at: null,
created_user: createdUser,
warehouse: dummyWarehouses[0],
};
// Goods Receipt data - using items from PurchaseItem with received data
const dummyGoodsReceiptItems: PurchaseItem[] = [
{
id: 1,
purchase_id: 1,
product: {
id: 1,
product_category: {
id: 1,
code: 'DOC',
name: 'DOC',
created_user: createdUser,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
name: 'CP Vaksin',
brand: '',
sku: '',
product_price: 0,
selling_price: 0,
tax: 0,
expiry_period: 0,
uom: {
id: 1,
name: 'Ekor',
created_user: createdUser,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
suppliers: [],
flags: [],
created_user: {
id: 1,
id_user: 1,
email: 'hello@gmail.com',
name: 'Admin',
},
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
product_warehouse: dummyProductWarehouses[0],
quantity: 10000,
sub_qty: 10000,
total_qty: 10000,
total_used: 0,
price: 6500,
total_price: 65000000,
received_date: '2025-01-15T00:00:00Z',
travel_number: 'NSJ-1',
travel_number_docs: '/documents/nsj-1.pdf',
vehicle_number: 'NAP-1',
warehouse: dummyWarehouses[0],
},
];
const dummyGroupedApprovals: BaseGroupedApproval[] = [
{
step_number: 1,
step_name: 'Pengajuan',
approvals: [
{
step_number: 1,
step_name: 'Pengajuan',
action: 'submit',
notes: 'Pengajuan purchase order dibuat',
action_by: {
id: 1,
id_user: 1,
email: 'user@company.com',
name: 'User Pengajuan',
},
action_at: '2025-01-10T08:00:00Z',
},
],
},
{
step_number: 2,
step_name: 'Approval Purchasing',
approvals: [
{
step_number: 2,
step_name: 'Approval Purchasing',
action: 'approve',
notes: 'Purchase order disetujui oleh purchasing',
action_by: {
id: 2,
id_user: 2,
email: 'purchasing@company.com',
name: 'Staff Purchasing',
},
action_at: '2025-01-10T10:30:00Z',
},
],
},
{
step_number: 3,
step_name: 'Approval Manager Purchasing',
approvals: [
{
step_number: 3,
step_name: 'Approval Manager Purchasing',
action: 'approve',
notes: 'Purchase order disetujui oleh manager purchasing',
action_by: {
id: 3,
id_user: 3,
email: 'manager.purchasing@company.com',
name: 'Manager Purchasing',
},
action_at: '2025-01-10T14:15:00Z',
},
],
},
{
step_number: 4,
step_name: 'Produk Diterima',
approvals: [
{
step_number: 4,
step_name: 'Produk Diterima',
action: 'receive',
notes: 'Produk telah diterima sesuai pesanan',
action_by: {
id: 4,
id_user: 4,
email: 'user@company.com',
name: 'User Pengajuan',
},
action_at: '2025-01-12T09:00:00Z',
},
],
},
{
step_number: 5,
step_name: 'Selesai',
approvals: [
{
step_number: 5,
step_name: 'Selesai',
action: 'complete',
notes: 'Purchase order telah selesai diproses',
action_by: {
id: 5,
id_user: 5,
email: 'user@company.com',
name: 'User Pengajuan',
},
action_at: '2025-01-12T16:00:00Z',
},
],
},
];
const PurchaseOrderDetail = ({
type = 'detail',
data,
}: PurchaseOrderDetailProps) => {
// ===== MODAL HOOKS =====
const searchParams = useSearchParams();
const confirmationModalWithNotes = useModal();
const staffApprovalModal = useModal();
const acceptApprovalModal = useModal();
const editModal = useModal();
// ===== STATIC DATA =====
const purchaseData = data || dummyPurchaseData;
const purchaseOrderItems = purchaseData.items || [];
const goodsReceiptItems = dummyGoodsReceiptItems;
const groupedApprovals = dummyGroupedApprovals;
const latestApproval =
groupedApprovals[groupedApprovals.length - 1]?.approvals[0];
// ===== SUBMISSION HANDLER =====
const createManagerApprovalHandler = useCallback(
async (payload: CreateManagerApprovalRequestPayload) => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: purchaseData?.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);
},
[purchaseData?.id, searchParams]
);
// ===== COMPUTED VALUES =====
const approvalSteps = useMemo(() => {
if (!groupedApprovals.length || !latestApproval) return [];
try {
return formatGroupedApprovalsToApprovalSteps(
PURCHASE_ORDER_APPROVAL_LINE,
groupedApprovals,
latestApproval
);
} catch (error) {
console.error('Error formatting approval steps:', error);
return [];
}
}, [groupedApprovals, latestApproval]);
const totalBeforeTax = useMemo(() => {
return purchaseOrderItems.reduce(
(sum, item) => sum + (item.total_price || 0),
0
);
}, [purchaseOrderItems]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2,
}).format(value);
};
const formatNumber = (value: number) => {
return new Intl.NumberFormat('id-ID').format(value);
};
const purchaseOrderColumns: ColumnDef<PurchaseItem>[] = [
{
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'product.name',
header: 'Produk',
cell: (props) => props.row.original.product?.name || '-',
},
{
accessorKey: 'product.product_category.name',
header: 'Jenis Produk',
cell: (props) =>
props.row.original.product?.product_category?.name || '-',
},
{
accessorKey: 'quantity',
header: 'Jumlah',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'product.uom.name',
header: 'Satuan',
cell: (props) => props.row.original.product?.uom?.name || '-',
},
{
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),
},
];
const goodsReceiptColumns: ColumnDef<PurchaseItem>[] = [
{
accessorKey: 'received_date',
header: 'Tanggal Penerimaan',
cell: (props) =>
props.row.original.received_date
? new Date(props.row.original.received_date).toLocaleDateString(
'id-ID'
)
: '-',
},
{
accessorKey: 'product_warehouse.warehouse.name',
header: 'Gudang Tujuan',
cell: (props) =>
props.row.original.product_warehouse?.warehouse?.name || '-',
},
{
accessorKey: 'travel_number',
header: 'No. Surat Jalan',
cell: (props) => props.row.original.travel_number || '-',
},
{
accessorKey: 'travel_number_docs',
header: 'Dokumen Surat Jalan',
cell: (props) => {
const documentPath = props.row.original.travel_number_docs;
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: 'pengangkut',
header: 'Pengangkut',
cell: (props) => props.row.original.product?.name || '-',
},
{
accessorKey: 'quantity',
header: 'Jumlah Total',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'sub_qty',
header: 'Jumlah Diterima',
cell: (props) => formatNumber(props.getValue() as number),
},
{
accessorKey: 'ekspedisi',
header: 'Ekspedisi',
cell: (props) => 'Ekspedisi 1',
},
{
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 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 */}
{approvalSteps.length > 0 ? (
<div className='my-8'>
<ApprovalSteps approvals={approvalSteps} />
</div>
) : (
<div className='text-center py-8'>
<p className='text-gray-500'>Status approval tidak tersedia</p>
</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.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.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.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 Supplier
</span>
<span className='text-gray-900 font-medium ml-3 break-all'>
: {purchaseData.supplier?.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'>
Alamat Supplier
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.supplier?.address || '-'}
</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'>
:{' '}
{new Date(purchaseData.due_date).toLocaleDateString(
'id-ID',
{
day: 'numeric',
month: 'short',
year: 'numeric',
}
)}{' '}
({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'>
{purchaseData.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<PurchaseOrderInvoice data={purchaseData} />
) : (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
disabled
>
PO-MBU-01837
</Button>
)}
</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>
<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}
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>
{/* 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'>
<h3 className='text-lg font-semibold text-gray-800 mb-4 pb-2 border-b border-gray-200'>
Informasi Penerimaan Barang
</h3>
<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'
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'
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>
</section>
);
};
export default PurchaseOrderDetail;