mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-21 13:55:45 +00:00
Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy
This commit is contained in:
@@ -52,11 +52,11 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'penjualan',
|
||||
label: 'Penjualan',
|
||||
content: <SalesReportTable initialValues={salesData} />,
|
||||
},
|
||||
// {
|
||||
// id: 'penjualan',
|
||||
// label: 'Penjualan',
|
||||
// content: <SalesReportTable initialValues={salesData} />,
|
||||
// },
|
||||
{
|
||||
id: 'overhead',
|
||||
label: 'Overhead',
|
||||
|
||||
@@ -23,6 +23,14 @@ type HppTableRow =
|
||||
type?: never;
|
||||
budgeting?: never;
|
||||
realization?: never;
|
||||
}
|
||||
| {
|
||||
type: string;
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: false;
|
||||
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
|
||||
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
|
||||
};
|
||||
|
||||
type ProfitLossTableRow =
|
||||
@@ -52,25 +60,117 @@ const ClosingFinanceTable = ({
|
||||
() => ClosingApi.getFinance(projectFlockId)
|
||||
);
|
||||
|
||||
const hppTableData: HppTableRow[] = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [
|
||||
// Group header row
|
||||
{
|
||||
group_name: hpp.group_name,
|
||||
group_index: groupIndex,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
// Data rows
|
||||
...hpp.data.map((item) => ({
|
||||
group_name: hpp.group_name,
|
||||
group_index: groupIndex,
|
||||
type: item.type,
|
||||
budgeting: item.budgeting,
|
||||
realization: item.realization,
|
||||
const staticHppRows: Array<{
|
||||
group_name: string;
|
||||
type: string;
|
||||
group_index: number;
|
||||
}> = [
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian PAKAN',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian STARTER',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian DOC',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian PULLET',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian LAYER',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
type: 'Pengeluaran Overhead',
|
||||
group_index: 1,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
type: 'Beban Ekspedisi',
|
||||
group_index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const hppTableData: HppTableRow[] = [
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
group_index: 0,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
...staticHppRows
|
||||
.filter((row) => row.group_index === 0)
|
||||
.map((staticRow) => {
|
||||
const apiData = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp
|
||||
.find((g) => g.group_name === staticRow.group_name)
|
||||
?.data.find((d) => d.type === staticRow.type)
|
||||
: null;
|
||||
|
||||
return {
|
||||
group_name: staticRow.group_name,
|
||||
group_index: staticRow.group_index,
|
||||
type: staticRow.type,
|
||||
budgeting: apiData?.budgeting || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
realization: apiData?.realization || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
isGroupHeader: false as const,
|
||||
})),
|
||||
])
|
||||
: [];
|
||||
};
|
||||
}),
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
group_index: 1,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
...staticHppRows
|
||||
.filter((row) => row.group_index === 1)
|
||||
.map((staticRow) => {
|
||||
const apiData = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp
|
||||
.find((g) => g.group_name === staticRow.group_name)
|
||||
?.data.find((d) => d.type === staticRow.type)
|
||||
: null;
|
||||
|
||||
return {
|
||||
group_name: staticRow.group_name,
|
||||
group_index: staticRow.group_index,
|
||||
type: staticRow.type,
|
||||
budgeting: apiData?.budgeting || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
realization: apiData?.realization || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
isGroupHeader: false as const,
|
||||
};
|
||||
}),
|
||||
{
|
||||
group_name: 'HPP',
|
||||
group_index: 2,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
];
|
||||
|
||||
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
|
||||
? [
|
||||
@@ -217,8 +317,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'budgeting_rp_per_bird' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp.budgeting
|
||||
.rp_per_bird || 0
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.rp_per_bird || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -233,8 +333,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'budgeting_rp_per_kg' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp.budgeting
|
||||
.rp_per_kg || 0
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.rp_per_kg || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -249,8 +349,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'budgeting_amount' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp.budgeting
|
||||
.amount || 0
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.amount || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -271,8 +371,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'realization_rp_per_bird' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp.realization
|
||||
.rp_per_bird || 0
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.rp_per_bird || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -287,8 +387,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'realization_rp_per_kg' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp.realization
|
||||
.rp_per_kg || 0
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.rp_per_kg || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -303,8 +403,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'realization_amount' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp.realization
|
||||
.amount || 0
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.amount || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
|
||||
@@ -85,7 +85,10 @@ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
product_id: Yup.number().required('Produk wajib diisi!'),
|
||||
product_id: Yup.number()
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.typeError('Produk wajib diisi!'),
|
||||
product_qty: Yup.number()
|
||||
.required('Qty wajib diisi!')
|
||||
.min(1, 'Qty minimal 1!')
|
||||
@@ -97,7 +100,10 @@ const DeliveryProductObjectSchema = Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
product_id: Yup.number().required('Produk wajib diisi!'),
|
||||
product_id: Yup.number()
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.typeError('Produk wajib diisi!'),
|
||||
product_qty: Yup.number()
|
||||
.required('Qty wajib diisi!')
|
||||
.min(1, 'Qty minimal 1!')
|
||||
@@ -127,13 +133,13 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
|
||||
(delivery_cost !== undefined && delivery_cost > 0)
|
||||
);
|
||||
}),
|
||||
document_path: Yup.string().optional(),
|
||||
document_path: Yup.string().nullable().optional(),
|
||||
document_index: Yup.number().optional(),
|
||||
document: Yup.mixed<File | MovementDocument>()
|
||||
.nullable()
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => {
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
}),
|
||||
driver_name: Yup.string().required('Nama sopir wajib diisi!'),
|
||||
@@ -142,7 +148,10 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
supplier_id: Yup.number().required('Supplier wajib diisi!'),
|
||||
supplier_id: Yup.number()
|
||||
.required('Supplier wajib diisi!')
|
||||
.min(1, 'Supplier wajib diisi!')
|
||||
.typeError('Supplier wajib diisi!'),
|
||||
products: Yup.array()
|
||||
.of(DeliveryProductObjectSchema)
|
||||
.min(1, 'Minimal harus ada 1 produk!')
|
||||
@@ -161,6 +170,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
|
||||
}).nullable(),
|
||||
source_warehouse_id: Yup.number()
|
||||
.required('Gudang asal wajib diisi!')
|
||||
.min(1, 'Gudang asal wajib diisi!')
|
||||
.typeError('Gudang asal wajib diisi!'),
|
||||
destination_warehouse: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
@@ -170,6 +180,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
|
||||
}).nullable(),
|
||||
destination_warehouse_id: Yup.number()
|
||||
.required('Gudang tujuan wajib diisi!')
|
||||
.min(1, 'Gudang tujuan wajib diisi!')
|
||||
.typeError('Gudang tujuan wajib diisi!')
|
||||
.test(
|
||||
'different-warehouse',
|
||||
@@ -226,41 +237,62 @@ export const getMovementFormInitialValues = (
|
||||
}
|
||||
: null,
|
||||
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
|
||||
products:
|
||||
initialValues?.details?.map((detail) => ({
|
||||
product: {
|
||||
value: detail.product.id,
|
||||
label: detail.product.name,
|
||||
},
|
||||
product_id: detail.product.id,
|
||||
product_qty: detail.quantity,
|
||||
})) ?? [],
|
||||
deliveries:
|
||||
initialValues?.deliveries?.map((d) => ({
|
||||
delivery_cost: d.shipping_cost_total ?? undefined,
|
||||
delivery_cost_per_item: d.shipping_cost_item ?? undefined,
|
||||
document_number: d.document_number ?? '',
|
||||
document: d.document ?? null,
|
||||
document_path: d.document_path ?? null,
|
||||
driver_name: d.driver_name ?? '',
|
||||
vehicle_plate: d.vehicle_plate ?? '',
|
||||
supplier: d.supplier
|
||||
? { value: d.supplier.id, label: d.supplier.name }
|
||||
: null,
|
||||
supplier_id: d.supplier?.id ?? 0,
|
||||
products:
|
||||
d.items?.map((item) => {
|
||||
const productData = detailIdToProductId.get(
|
||||
item.stock_transfer_detail_id
|
||||
);
|
||||
return {
|
||||
product: productData
|
||||
? { value: productData.id, label: productData.name }
|
||||
: null,
|
||||
product_id: productData?.id ?? 0,
|
||||
product_qty: item.quantity,
|
||||
};
|
||||
}) ?? [],
|
||||
})) ?? [],
|
||||
products: initialValues?.details?.map((detail) => ({
|
||||
product: {
|
||||
value: detail.product.id,
|
||||
label: detail.product.name,
|
||||
},
|
||||
product_id: detail.product.id,
|
||||
product_qty: detail.quantity,
|
||||
})) ?? [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
deliveries: initialValues?.deliveries?.map((d) => ({
|
||||
delivery_cost: d.shipping_cost_total ?? undefined,
|
||||
delivery_cost_per_item: d.shipping_cost_item ?? undefined,
|
||||
document: d.document ?? null,
|
||||
document_path: d.document_path ?? null,
|
||||
driver_name: d.driver_name ?? '',
|
||||
vehicle_plate: d.vehicle_plate ?? '',
|
||||
supplier: d.supplier
|
||||
? { value: d.supplier.id, label: d.supplier.name }
|
||||
: null,
|
||||
supplier_id: d.supplier?.id ?? 0,
|
||||
products:
|
||||
d.items?.map((item) => {
|
||||
const productData = detailIdToProductId.get(
|
||||
item.stock_transfer_detail_id
|
||||
);
|
||||
return {
|
||||
product: productData
|
||||
? { value: productData.id, label: productData.name }
|
||||
: null,
|
||||
product_id: productData?.id ?? 0,
|
||||
product_qty: item.quantity,
|
||||
};
|
||||
}) ?? [],
|
||||
})) ?? [
|
||||
{
|
||||
delivery_cost: undefined,
|
||||
delivery_cost_per_item: undefined,
|
||||
document: null,
|
||||
document_path: null,
|
||||
driver_name: '',
|
||||
vehicle_plate: '',
|
||||
supplier: null,
|
||||
supplier_id: 0,
|
||||
products: [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import Badge from '@/components/Badge';
|
||||
import Card from '@/components/Card';
|
||||
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
interface MovementFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -53,6 +55,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
] = useState('');
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
// ===== FORM HANDLERS =====
|
||||
const createMovementHandler = useCallback(
|
||||
@@ -186,12 +189,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
return;
|
||||
}
|
||||
const documents: File[] = [];
|
||||
const documentNameToIndex = new Map<string, number>();
|
||||
let sequentialDocumentIndex = 0;
|
||||
|
||||
const deliveriesPayload = values.deliveries.map((d) => {
|
||||
let documentIndex = 0;
|
||||
let documentIndex = -1;
|
||||
|
||||
if (d.document && d.document instanceof File) {
|
||||
documents.push(d.document);
|
||||
documentIndex = documents.length - 1;
|
||||
const fileName = d.document.name;
|
||||
|
||||
if (documentNameToIndex.has(fileName)) {
|
||||
documentIndex = documentNameToIndex.get(fileName)!;
|
||||
} else {
|
||||
documents.push(d.document);
|
||||
documentIndex = sequentialDocumentIndex;
|
||||
documentNameToIndex.set(fileName, documentIndex);
|
||||
sequentialDocumentIndex++;
|
||||
}
|
||||
} else if (d.document_path) {
|
||||
const pathFileName =
|
||||
d.document_path.split('/').pop() || d.document_path;
|
||||
|
||||
if (documentNameToIndex.has(pathFileName)) {
|
||||
documentIndex = documentNameToIndex.get(pathFileName)!;
|
||||
} else {
|
||||
documentIndex = sequentialDocumentIndex;
|
||||
documentNameToIndex.set(pathFileName, documentIndex);
|
||||
sequentialDocumentIndex++;
|
||||
}
|
||||
} else if (d.document && !(d.document instanceof File)) {
|
||||
const existingDocFileName =
|
||||
d.document.path.split('/').pop() || d.document.path;
|
||||
|
||||
if (documentNameToIndex.has(existingDocFileName)) {
|
||||
documentIndex = documentNameToIndex.get(existingDocFileName)!;
|
||||
} else {
|
||||
documentIndex = sequentialDocumentIndex;
|
||||
documentNameToIndex.set(existingDocFileName, documentIndex);
|
||||
sequentialDocumentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -199,7 +235,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
delivery_cost_per_item:
|
||||
parseInt((d.delivery_cost_per_item || '').toString()) || 0,
|
||||
document_index: documentIndex,
|
||||
document_path: d.document_path,
|
||||
driver_name: d.driver_name,
|
||||
vehicle_plate: d.vehicle_plate,
|
||||
supplier_id: d.supplier_id,
|
||||
@@ -761,8 +796,36 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
type !== 'edit' &&
|
||||
type !== 'detail'
|
||||
) {
|
||||
formik.setFieldValue('products', []);
|
||||
formik.setFieldValue('deliveries', []);
|
||||
if (formik.values.products.length === 0) {
|
||||
formik.setFieldValue('products', [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (formik.values.deliveries.length === 0) {
|
||||
formik.setFieldValue('deliveries', [
|
||||
{
|
||||
delivery_cost: undefined,
|
||||
delivery_cost_per_item: undefined,
|
||||
document: null,
|
||||
document_path: null,
|
||||
driver_name: '',
|
||||
vehicle_plate: '',
|
||||
supplier: null,
|
||||
supplier_id: 0,
|
||||
products: [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [formik.values.source_warehouse_id]);
|
||||
|
||||
@@ -791,6 +854,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
formik.errors.destination_warehouse_id,
|
||||
]);
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full'>
|
||||
@@ -810,10 +889,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
</h1>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{movementFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{movementFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top card - Movement details */}
|
||||
<Card
|
||||
title='Detail Movement'
|
||||
@@ -1097,7 +1195,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Produk
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1106,7 +1204,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Qty
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1119,7 +1217,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
{formik.values.products?.map((product, idx) => (
|
||||
<tr key={`product-row-${idx}-${product.product_id}`}>
|
||||
{type !== 'detail' && (
|
||||
<td className='!align-middle'>
|
||||
<td className='align-middle!'>
|
||||
<CheckboxInput
|
||||
name={`product-${idx}`}
|
||||
checked={selectedProducts.includes(idx)}
|
||||
@@ -1311,7 +1409,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Produk
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1320,7 +1418,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Qty
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1329,7 +1427,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Supplier
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1338,7 +1436,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Plat Nomor
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1348,7 +1446,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Biaya Pengiriman (Rp.)
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1357,7 +1455,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Biaya Per Item (Rp.)
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1366,7 +1464,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<th>
|
||||
Nama Sopir
|
||||
<span
|
||||
className='tooltip tooltip-error tooltip-bottom z-[9999]'
|
||||
className='tooltip tooltip-error tooltip-bottom z-9999'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'>*</span>
|
||||
@@ -1379,7 +1477,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
{formik.values.deliveries?.map((delivery, idx) => (
|
||||
<tr key={`delivery-row-${idx}`}>
|
||||
{type !== 'detail' && (
|
||||
<td className='!align-middle'>
|
||||
<td className='align-middle!'>
|
||||
<CheckboxInput
|
||||
name={`delivery-${idx}`}
|
||||
checked={selectedDeliveries.includes(idx)}
|
||||
@@ -1589,8 +1687,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Ukuran dokumen maksimal 2 MB!');
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Ukuran dokumen maksimal 5 MB!');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -1747,7 +1845,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
disabled={
|
||||
hasInvalidQty ||
|
||||
hasExceededStock ||
|
||||
!formik.isValid ||
|
||||
formik.isSubmitting ||
|
||||
(formik.values.source_warehouse_id ===
|
||||
formik.values.destination_warehouse_id &&
|
||||
@@ -1760,17 +1857,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{movementFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{movementFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,8 @@ import TextInput from '@/components/input/TextInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
ProductCategoryFormSchema,
|
||||
@@ -39,6 +41,7 @@ const ProductCategoryForm = ({
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [formErrorMessage, setFormErrorMessage] = useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createProductCategoryHandler = useCallback(
|
||||
@@ -129,6 +132,22 @@ const ProductCategoryForm = ({
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-2xl'>
|
||||
@@ -150,10 +169,29 @@ const ProductCategoryForm = ({
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{formErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{formErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
@@ -236,7 +274,7 @@ const ProductCategoryForm = ({
|
||||
type='submit'
|
||||
color='primary'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
className='px-4'
|
||||
>
|
||||
Submit
|
||||
@@ -244,17 +282,6 @@ const ProductCategoryForm = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{formErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -29,36 +29,38 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
|
||||
sku: Yup.string().required('SKU wajib diisi!'),
|
||||
|
||||
uom: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.required('Satuan wajib diisi!'),
|
||||
value: Yup.number()
|
||||
.min(1, 'Satuan wajib dipilih!')
|
||||
.required('Satuan wajib dipilih!'),
|
||||
label: Yup.string().required('Satuan wajib dipilih!'),
|
||||
}).nullable(),
|
||||
|
||||
uom_id: Yup.number()
|
||||
.required('Satuan wajib diisi!')
|
||||
.typeError('Satuan wajib diisi!'),
|
||||
.min(1, 'Satuan wajib dipilih!')
|
||||
.required('Satuan wajib dipilih!')
|
||||
.typeError('Satuan wajib dipilih!'),
|
||||
|
||||
product_category: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.required('Kategori produk wajib diisi!'),
|
||||
value: Yup.number()
|
||||
.min(1, 'Kategori produk wajib dipilih!')
|
||||
.required('Kategori produk wajib dipilih!'),
|
||||
label: Yup.string().required('Kategori produk wajib dipilih!'),
|
||||
}).nullable(),
|
||||
|
||||
product_category_id: Yup.number()
|
||||
.required('Kategori produk wajib diisi!')
|
||||
.typeError('Kategori produk wajib diisi!'),
|
||||
.min(1, 'Kategori produk wajib dipilih!')
|
||||
.required('Kategori produk wajib dipilih!')
|
||||
.typeError('Kategori produk wajib dipilih!'),
|
||||
|
||||
product_price: Yup.number()
|
||||
.required('Harga produk wajib diisi!')
|
||||
.typeError('Harga produk wajib diisi!')
|
||||
.min(0, 'Harga produk tidak boleh kurang dari 0!'),
|
||||
.min(1, 'Harga produk tidak boleh kurang dari 1!'),
|
||||
|
||||
selling_price: Yup.number()
|
||||
.required('Harga jual wajib diisi!')
|
||||
.typeError('Harga jual wajib diisi!')
|
||||
.min(0, 'Harga jual tidak boleh kurang dari 0!'),
|
||||
.min(1, 'Harga jual tidak boleh kurang dari 1!'),
|
||||
|
||||
tax: Yup.number()
|
||||
.required('Pajak wajib diisi!')
|
||||
@@ -69,7 +71,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
|
||||
expiry_period: Yup.number()
|
||||
.required('Periode kadaluarsa wajib diisi!')
|
||||
.typeError('Periode kadaluarsa wajib diisi!')
|
||||
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
|
||||
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
|
||||
|
||||
supplier_ids: Yup.array()
|
||||
.of(Yup.number().required().typeError('Supplier tidak valid!'))
|
||||
|
||||
@@ -17,6 +17,8 @@ import SelectInput, {
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
ProductFormSchema,
|
||||
@@ -48,6 +50,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createProductHandler = useCallback(
|
||||
@@ -201,6 +204,22 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-2xl'>
|
||||
@@ -220,11 +239,30 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
</h1>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{productFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{productFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
label='Nama'
|
||||
@@ -237,179 +275,193 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
errorMessage={formik.errors.name}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Merek'
|
||||
name='brand'
|
||||
placeholder='Masukkan merek...'
|
||||
value={formik.values.brand}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.brand && Boolean(formik.errors.brand)}
|
||||
errorMessage={formik.errors.brand}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='SKU'
|
||||
name='sku'
|
||||
placeholder='Masukkan SKU...'
|
||||
value={formik.values.sku}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.sku && Boolean(formik.errors.sku)}
|
||||
errorMessage={formik.errors.sku}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Satuan'
|
||||
placeholder='Pilih satuan...'
|
||||
value={formik.values.uom ?? undefined}
|
||||
onChange={uomChangeHandler}
|
||||
options={uomOptions}
|
||||
onInputChange={setUomSelectInputValue}
|
||||
isLoading={isLoadingUoms}
|
||||
isError={formik.touched.uom_id && Boolean(formik.errors.uom_id)}
|
||||
errorMessage={formik.errors.uom_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih kategori produk...'
|
||||
value={formik.values.product_category ?? undefined}
|
||||
onChange={categoryChangeHandler}
|
||||
options={categoryOptions}
|
||||
onInputChange={setCategorySelectInputValue}
|
||||
isLoading={isLoadingCategories}
|
||||
isError={
|
||||
formik.touched.product_category_id &&
|
||||
Boolean(formik.errors.product_category_id)
|
||||
}
|
||||
errorMessage={formik.errors.product_category_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Produk'
|
||||
name='product_price'
|
||||
placeholder='Masukkan harga produk...'
|
||||
value={formik.values.product_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.product_price &&
|
||||
Boolean(formik.errors.product_price)
|
||||
}
|
||||
errorMessage={formik.errors.product_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Jual'
|
||||
name='selling_price'
|
||||
placeholder='Masukkan harga jual...'
|
||||
value={formik.values.selling_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.selling_price &&
|
||||
Boolean(formik.errors.selling_price)
|
||||
}
|
||||
errorMessage={formik.errors.selling_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Pajak (%)'
|
||||
name='tax'
|
||||
placeholder='Masukkan pajak...'
|
||||
value={formik.values.tax}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='%'
|
||||
isError={formik.touched.tax && Boolean(formik.errors.tax)}
|
||||
errorMessage={formik.errors.tax as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Periode Kadaluarsa (hari)'
|
||||
name='expiry_period'
|
||||
placeholder='Masukkan periode kadaluarsa...'
|
||||
value={formik.values.expiry_period}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={0}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='hari'
|
||||
isError={
|
||||
formik.touched.expiry_period &&
|
||||
Boolean(formik.errors.expiry_period)
|
||||
}
|
||||
errorMessage={formik.errors.expiry_period as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Supplier'
|
||||
placeholder='Pilih supplier...'
|
||||
isMulti
|
||||
value={supplierOptions.filter((opt) =>
|
||||
(formik.values.supplier_ids || []).includes(opt.value)
|
||||
)}
|
||||
onChange={supplierChangeHandler}
|
||||
options={supplierOptions}
|
||||
onInputChange={setSupplierSelectInputValue}
|
||||
isLoading={isLoadingSuppliers}
|
||||
isError={
|
||||
formik.touched.supplier_ids &&
|
||||
Boolean(formik.errors.supplier_ids)
|
||||
}
|
||||
errorMessage={formik.errors.supplier_ids as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Flags'
|
||||
placeholder='Pilih flags...'
|
||||
isMulti
|
||||
value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
|
||||
(formik.values.flags || []).includes(opt.value)
|
||||
)}
|
||||
onChange={(val) => {
|
||||
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||
formik.setFieldValue(
|
||||
'flags',
|
||||
arr.map((v) => (v as OptionType).value)
|
||||
);
|
||||
}}
|
||||
options={PRODUCT_FLAG_OPTIONS}
|
||||
isError={formik.touched.flags && Boolean(formik.errors.flags)}
|
||||
errorMessage={formik.errors.flags as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
label='Merek'
|
||||
name='brand'
|
||||
placeholder='Masukkan merek...'
|
||||
value={formik.values.brand}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.brand && Boolean(formik.errors.brand)}
|
||||
errorMessage={formik.errors.brand}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='SKU'
|
||||
name='sku'
|
||||
placeholder='Masukkan SKU...'
|
||||
value={formik.values.sku}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.sku && Boolean(formik.errors.sku)}
|
||||
errorMessage={formik.errors.sku}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<SelectInput
|
||||
required
|
||||
label='Satuan'
|
||||
placeholder='Pilih satuan...'
|
||||
value={formik.values.uom ?? undefined}
|
||||
onChange={uomChangeHandler}
|
||||
options={uomOptions}
|
||||
onInputChange={setUomSelectInputValue}
|
||||
isLoading={isLoadingUoms}
|
||||
isError={
|
||||
(formik.touched.uom || formik.touched.uom_id) &&
|
||||
Boolean(formik.errors.uom_id)
|
||||
}
|
||||
errorMessage={formik.errors.uom_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih kategori produk...'
|
||||
value={formik.values.product_category ?? undefined}
|
||||
onChange={categoryChangeHandler}
|
||||
options={categoryOptions}
|
||||
onInputChange={setCategorySelectInputValue}
|
||||
isLoading={isLoadingCategories}
|
||||
isError={
|
||||
(formik.touched.product_category ||
|
||||
formik.touched.product_category_id) &&
|
||||
Boolean(formik.errors.product_category_id)
|
||||
}
|
||||
errorMessage={formik.errors.product_category_id as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Produk'
|
||||
name='product_price'
|
||||
placeholder='Masukkan harga produk...'
|
||||
value={formik.values.product_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.product_price &&
|
||||
Boolean(formik.errors.product_price)
|
||||
}
|
||||
errorMessage={formik.errors.product_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Jual'
|
||||
name='selling_price'
|
||||
placeholder='Masukkan harga jual...'
|
||||
value={formik.values.selling_price}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix='Rp '
|
||||
isError={
|
||||
formik.touched.selling_price &&
|
||||
Boolean(formik.errors.selling_price)
|
||||
}
|
||||
errorMessage={formik.errors.selling_price as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Pajak (%)'
|
||||
name='tax'
|
||||
placeholder='Masukkan pajak...'
|
||||
value={formik.values.tax}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={2}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='%'
|
||||
isError={formik.touched.tax && Boolean(formik.errors.tax)}
|
||||
errorMessage={formik.errors.tax as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Periode Kadaluarsa (hari)'
|
||||
name='expiry_period'
|
||||
placeholder='Masukkan periode kadaluarsa...'
|
||||
value={formik.values.expiry_period}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={0}
|
||||
allowNegative={false}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputSuffix='hari'
|
||||
isError={
|
||||
formik.touched.expiry_period &&
|
||||
Boolean(formik.errors.expiry_period)
|
||||
}
|
||||
errorMessage={formik.errors.expiry_period as string}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<SelectInput
|
||||
required
|
||||
label='Supplier'
|
||||
placeholder='Pilih supplier...'
|
||||
isMulti
|
||||
value={supplierOptions.filter((opt) =>
|
||||
(formik.values.supplier_ids || []).includes(opt.value)
|
||||
)}
|
||||
onChange={supplierChangeHandler}
|
||||
options={supplierOptions}
|
||||
onInputChange={setSupplierSelectInputValue}
|
||||
isLoading={isLoadingSuppliers}
|
||||
isError={
|
||||
formik.touched.supplier_ids &&
|
||||
Boolean(formik.errors.supplier_ids)
|
||||
}
|
||||
errorMessage={formik.errors.supplier_ids as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
label='Flags'
|
||||
placeholder='Pilih flags...'
|
||||
isMulti
|
||||
value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
|
||||
(formik.values.flags || []).includes(opt.value)
|
||||
)}
|
||||
onChange={(val) => {
|
||||
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||
formik.setFieldValue(
|
||||
'flags',
|
||||
arr.map((v) => (v as OptionType).value)
|
||||
);
|
||||
}}
|
||||
options={PRODUCT_FLAG_OPTIONS}
|
||||
isError={formik.touched.flags && Boolean(formik.errors.flags)}
|
||||
errorMessage={formik.errors.flags as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{type !== 'add' && (
|
||||
@@ -463,7 +515,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
type='submit'
|
||||
color='primary'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
className='px-4'
|
||||
>
|
||||
Submit
|
||||
@@ -471,16 +523,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{productFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{productFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
{type !== 'add' && (
|
||||
|
||||
@@ -17,6 +17,7 @@ import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
ProjectFlockKandangApi,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
|
||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import toast from 'react-hot-toast';
|
||||
import ApprovalSteps, {
|
||||
useApprovalSteps,
|
||||
@@ -60,7 +62,6 @@ import {
|
||||
GROWING_RECORDING_APPROVAL_LINE,
|
||||
LAYING_RECORDING_APPROVAL_LINE,
|
||||
} from '@/config/approval-line';
|
||||
import Table from '@/components/Table';
|
||||
|
||||
interface RecordingFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -92,6 +93,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const [, setApprovalNotes] = useState('');
|
||||
const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [, setNewRecordingData] = useState<Recording | null>(null);
|
||||
const [nextDayRecording, setNextDayRecording] =
|
||||
@@ -758,6 +760,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
useCallback((): OptionType | null => {
|
||||
if (
|
||||
@@ -1323,9 +1341,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{recordingFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{recordingFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Basic Info Card */}
|
||||
{(type === 'add' || type === 'edit') && (
|
||||
<Card
|
||||
@@ -2507,9 +2544,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={
|
||||
hasExceededStock || !formik.isValid || formik.isSubmitting
|
||||
}
|
||||
disabled={hasExceededStock || formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
@@ -2534,9 +2569,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={
|
||||
hasExceededStock || !formik.isValid || formik.isSubmitting
|
||||
}
|
||||
disabled={hasExceededStock || formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
@@ -2544,16 +2577,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recordingFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{recordingFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,93 +1,104 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import Card from '@/components/Card';
|
||||
import UniformityBarChart from '@/components/pages/production/uniformity/chart/UniformityBarChart';
|
||||
import UniformityGaugeChart from '@/components/pages/production/uniformity/chart/UniformityGaugeChart';
|
||||
import UniformityBarChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityBarChartSkeleton';
|
||||
import UniformityGaugeChartSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityGaugeChartSkeleton';
|
||||
import {
|
||||
UniformityDetailItem,
|
||||
Uniformity,
|
||||
} from '@/types/api/production/uniformity';
|
||||
import { Uniformity, type ChartData } from '@/types/api/production/uniformity';
|
||||
|
||||
interface UniformityChartProps {
|
||||
uniformityData?: Uniformity | null;
|
||||
uniformityDetails?: UniformityDetailItem[];
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
const UniformityChart = ({
|
||||
uniformityData,
|
||||
uniformityDetails,
|
||||
isFiltered = false,
|
||||
}: UniformityChartProps) => {
|
||||
const defaultUniformityDetails: UniformityDetailItem[] = [
|
||||
{ id: 1, weight: 61, range: 'Ideal' },
|
||||
{ id: 2, weight: 62, range: 'Ideal' },
|
||||
{ id: 3, weight: 63, range: 'Ideal' },
|
||||
{ id: 4, weight: 64, range: 'Ideal' },
|
||||
{ id: 5, weight: 65, range: 'Ideal' },
|
||||
{ id: 6, weight: 66, range: 'Ideal' },
|
||||
{ id: 7, weight: 67, range: 'Ideal' },
|
||||
];
|
||||
const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
|
||||
|
||||
const detailsToUse = uniformityDetails || defaultUniformityDetails;
|
||||
const chartData = useMemo((): ChartData | undefined => {
|
||||
if (!uniformityData?.chart_data) return undefined;
|
||||
return uniformityData.chart_data;
|
||||
}, [uniformityData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uniformityData?.chart_data?.gauge_chart?.week_info) {
|
||||
const { current_week_index } =
|
||||
uniformityData.chart_data.gauge_chart.week_info;
|
||||
setCurrentWeekIndex(current_week_index);
|
||||
}
|
||||
}, [uniformityData]);
|
||||
|
||||
const barChartData = useMemo(() => {
|
||||
if (!uniformityData) {
|
||||
if (!chartData?.bar_chart || !chartData?.gauge_chart) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!detailsToUse || detailsToUse.length === 0) {
|
||||
const { bar_chart, gauge_chart } = chartData;
|
||||
const currentWeekData = gauge_chart.available_weeks[currentWeekIndex];
|
||||
|
||||
if (!currentWeekData || !currentWeekData.has_data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const weights = detailsToUse.map((d) => d.weight);
|
||||
const minWeight = Math.floor(Math.min(...weights) / 5) * 5;
|
||||
const maxWeight = Math.ceil(Math.max(...weights) / 5) * 5;
|
||||
const currentWeekStr = String(currentWeekData.week);
|
||||
const weekData = bar_chart.all_weeks[currentWeekStr];
|
||||
|
||||
const rangeSize = maxWeight - minWeight < 11 ? 4 : 5;
|
||||
const ranges: string[] = [];
|
||||
|
||||
for (let start = minWeight; start <= maxWeight; start += rangeSize) {
|
||||
const end = start + rangeSize;
|
||||
ranges.push(`${start}-${end}`);
|
||||
if (!weekData || !weekData.has_data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const totalIdealCount = detailsToUse.filter(
|
||||
(d) => d.range === 'Ideal'
|
||||
).length;
|
||||
|
||||
return ranges.map((range) => {
|
||||
const [minStr, maxStr] = range.split('-').map(Number);
|
||||
const min = minStr;
|
||||
const max = maxStr;
|
||||
|
||||
const birdsInRange = detailsToUse.filter(
|
||||
(d) => d.weight >= min && d.weight < max
|
||||
).length;
|
||||
|
||||
const hasIdeal = detailsToUse.some(
|
||||
(d) => d.range === 'Ideal' && d.weight >= min && d.weight < max
|
||||
);
|
||||
|
||||
return {
|
||||
name: range,
|
||||
uv: birdsInRange,
|
||||
isIdeal: hasIdeal,
|
||||
idealCount: hasIdeal ? totalIdealCount : undefined,
|
||||
};
|
||||
});
|
||||
}, [uniformityData, detailsToUse]);
|
||||
return weekData.weight_distribution.map((range) => ({
|
||||
name: range.range,
|
||||
uv: range.bird_count,
|
||||
isIdeal: range.is_ideal_range,
|
||||
idealRange: range.ideal_range,
|
||||
outsideRange: range.outside_range,
|
||||
}));
|
||||
}, [chartData, currentWeekIndex]);
|
||||
|
||||
const gaugeChartData = useMemo(() => {
|
||||
if (!uniformityData) return undefined;
|
||||
if (!chartData?.gauge_chart || !uniformityData) return undefined;
|
||||
|
||||
const { gauge_chart } = chartData;
|
||||
const currentWeekData = gauge_chart.available_weeks[currentWeekIndex];
|
||||
|
||||
if (!currentWeekData || !currentWeekData.has_data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hasPrevWeek = currentWeekIndex > 0;
|
||||
const hasNextWeek =
|
||||
currentWeekIndex < gauge_chart.available_weeks.length - 1;
|
||||
|
||||
return {
|
||||
value: uniformityData.uniformity,
|
||||
value: currentWeekData.uniformity_percentage,
|
||||
label: 'Uniformity',
|
||||
week: `Week ${uniformityData.week}`,
|
||||
currentValue: uniformityData.uniform_qty,
|
||||
totalValue: uniformityData.chick_qty_of_weight,
|
||||
week: `Week ${currentWeekData.week}`,
|
||||
currentValue: currentWeekData.ideal_count,
|
||||
totalValue: currentWeekData.total_count,
|
||||
hasPrevWeek,
|
||||
hasNextWeek,
|
||||
};
|
||||
}, [uniformityData]);
|
||||
}, [chartData, currentWeekIndex, uniformityData]);
|
||||
|
||||
const handleWeekChange = (direction: 'prev' | 'next') => {
|
||||
if (!chartData?.gauge_chart) return;
|
||||
|
||||
const { available_weeks } = chartData.gauge_chart;
|
||||
|
||||
if (direction === 'prev' && currentWeekIndex > 0) {
|
||||
setCurrentWeekIndex((prev) => prev - 1);
|
||||
} else if (
|
||||
direction === 'next' &&
|
||||
currentWeekIndex < available_weeks.length - 1
|
||||
) {
|
||||
setCurrentWeekIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowEmptyState = !isFiltered;
|
||||
|
||||
return (
|
||||
<section className='w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-4'>
|
||||
@@ -100,14 +111,16 @@ const UniformityChart = ({
|
||||
}}
|
||||
>
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
{!uniformityData || barChartData.length === 0 ? (
|
||||
{shouldShowEmptyState ||
|
||||
!uniformityData ||
|
||||
barChartData.length === 0 ? (
|
||||
<UniformityBarChartSkeleton />
|
||||
) : (
|
||||
<UniformityBarChart data={barChartData} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{!uniformityData || !gaugeChartData ? (
|
||||
{shouldShowEmptyState || !uniformityData || !gaugeChartData ? (
|
||||
<Card
|
||||
variant='bordered'
|
||||
title='Weekly Performance ⓘ'
|
||||
@@ -133,6 +146,9 @@ const UniformityChart = ({
|
||||
week={gaugeChartData.week}
|
||||
currentValue={gaugeChartData.currentValue}
|
||||
totalValue={gaugeChartData.totalValue}
|
||||
onWeekChange={handleWeekChange}
|
||||
hasPrevWeek={gaugeChartData.hasPrevWeek}
|
||||
hasNextWeek={gaugeChartData.hasNextWeek}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -151,8 +151,10 @@ const UniformityConfirmationPreview = ({
|
||||
|
||||
const UniformityChartWrapper = ({
|
||||
uniformitySwrKey,
|
||||
isFiltered,
|
||||
}: {
|
||||
uniformitySwrKey: string;
|
||||
isFiltered: boolean;
|
||||
}) => {
|
||||
const { data: uniformities } = useSWR(
|
||||
uniformitySwrKey,
|
||||
@@ -166,31 +168,8 @@ const UniformityChartWrapper = ({
|
||||
return null;
|
||||
}, [uniformities]);
|
||||
|
||||
const shouldFetchDetails = !!uniformityData;
|
||||
const uniformityDetailSwrKey = useMemo(() => {
|
||||
if (!uniformityData) return null;
|
||||
return `${UniformityApi.basePath}/${uniformityData.id}?with_details=true`;
|
||||
}, [uniformityData]);
|
||||
|
||||
const { data: uniformityDetailResponse } = useSWR(
|
||||
uniformityDetailSwrKey,
|
||||
shouldFetchDetails ? UniformityApi.getAllFetcher : null
|
||||
);
|
||||
|
||||
const uniformityDetails = useMemo(() => {
|
||||
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
|
||||
const detailData =
|
||||
uniformityDetailResponse.data as unknown as UniformityDetail;
|
||||
return detailData.uniformity_details;
|
||||
}
|
||||
return undefined;
|
||||
}, [shouldFetchDetails, uniformityDetailResponse]);
|
||||
|
||||
return (
|
||||
<UniformityChart
|
||||
uniformityData={uniformityData}
|
||||
uniformityDetails={uniformityDetails}
|
||||
/>
|
||||
<UniformityChart uniformityData={uniformityData} isFiltered={isFiltered} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -251,12 +230,15 @@ const UniformityTable = () => {
|
||||
const [filterStartDate, setFilterStartDate] = useState('');
|
||||
const [filterEndDate, setFilterEndDate] = useState('');
|
||||
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
|
||||
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
setInputValue: setFilterLocationInputValue,
|
||||
options: filterLocationOptions,
|
||||
isLoadingOptions: isLoadingFilterLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
// ===== FETCH PROJECT FLOCKS DATA FOR FILTER =====
|
||||
const filterProjectFlocksUrl = useMemo(() => {
|
||||
@@ -328,6 +310,7 @@ const UniformityTable = () => {
|
||||
project_flock_id: filterProjectFlock.value.toString(),
|
||||
kandang_id: filterKandang.value.toString(),
|
||||
withpopulation: Boolean(true).toString(),
|
||||
limit: '100',
|
||||
});
|
||||
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
|
||||
}, [filterProjectFlock, filterKandang]);
|
||||
@@ -374,6 +357,7 @@ const UniformityTable = () => {
|
||||
if (filterEndDate) {
|
||||
queryParams.append('end_date', filterEndDate);
|
||||
}
|
||||
queryParams.append('with_chart', 'true');
|
||||
}
|
||||
|
||||
const tableQueryString = getTableFilterQueryString();
|
||||
@@ -433,6 +417,7 @@ const UniformityTable = () => {
|
||||
);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setIsSubmitted(false);
|
||||
setFilterLocation(null);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
@@ -442,9 +427,38 @@ const UniformityTable = () => {
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = useCallback(() => {
|
||||
setIsSubmitted(true);
|
||||
filterModal.closeModal();
|
||||
}, [filterModal]);
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!filterStartDate) {
|
||||
errors.start_date = 'Tanggal mulai wajib diisi';
|
||||
}
|
||||
if (!filterEndDate) {
|
||||
errors.end_date = 'Tanggal akhir wajib diisi';
|
||||
}
|
||||
if (!filterLocation) {
|
||||
errors.location = 'Lokasi wajib dipilih';
|
||||
}
|
||||
if (!filterProjectFlock) {
|
||||
errors.project_flock = 'Project Flock wajib dipilih';
|
||||
}
|
||||
if (!filterKandang) {
|
||||
errors.kandang = 'Kandang wajib dipilih';
|
||||
}
|
||||
|
||||
setFilterErrors(errors);
|
||||
|
||||
if (Object.keys(errors).length === 0) {
|
||||
setIsSubmitted(true);
|
||||
filterModal.closeModal();
|
||||
}
|
||||
}, [
|
||||
filterModal,
|
||||
filterStartDate,
|
||||
filterEndDate,
|
||||
filterLocation,
|
||||
filterProjectFlock,
|
||||
filterKandang,
|
||||
]);
|
||||
|
||||
const selectedRowIds = useMemo(() => {
|
||||
return Object.keys(rowSelection)
|
||||
@@ -633,7 +647,7 @@ const UniformityTable = () => {
|
||||
if (filterEndDate) {
|
||||
queryParams.append('end_date', filterEndDate);
|
||||
}
|
||||
queryParams.append('limit', '10000');
|
||||
queryParams.append('limit', '100');
|
||||
queryParams.append('page', '1');
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
@@ -896,7 +910,10 @@ const UniformityTable = () => {
|
||||
<div className='my-4 divider'></div>
|
||||
|
||||
<section>
|
||||
<UniformityChartWrapper uniformitySwrKey={uniformitySwrKey} />
|
||||
<UniformityChartWrapper
|
||||
uniformitySwrKey={uniformitySwrKey}
|
||||
isFiltered={isSubmitted}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card
|
||||
@@ -1140,58 +1157,105 @@ const UniformityTable = () => {
|
||||
</div>
|
||||
<div className='space-y-4 px-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
|
||||
<DateInput
|
||||
label='Tanggal'
|
||||
name='start_date'
|
||||
value={filterStartDate}
|
||||
onChange={(e) => setFilterStartDate(e.target.value)}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<div>
|
||||
<DateInput
|
||||
label='Tanggal'
|
||||
name='start_date'
|
||||
value={filterStartDate}
|
||||
onChange={(e) => {
|
||||
setFilterStartDate(e.target.value);
|
||||
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
|
||||
}}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.start_date && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.start_date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DateInput
|
||||
label=' '
|
||||
name='end_date'
|
||||
value={filterEndDate}
|
||||
onChange={(e) => setFilterEndDate(e.target.value)}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<div>
|
||||
<DateInput
|
||||
label=' '
|
||||
name='end_date'
|
||||
value={filterEndDate}
|
||||
onChange={(e) => {
|
||||
setFilterEndDate(e.target.value);
|
||||
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
|
||||
}}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.end_date && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.end_date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi...'
|
||||
value={filterLocation}
|
||||
onChange={handleFilterLocationChange}
|
||||
options={filterLocationOptions}
|
||||
onInputChange={setFilterLocationInputValue}
|
||||
isLoading={isLoadingFilterLocations}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<div>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi...'
|
||||
value={filterLocation}
|
||||
onChange={(value) => {
|
||||
handleFilterLocationChange(value);
|
||||
setFilterErrors((prev) => ({ ...prev, location: '' }));
|
||||
}}
|
||||
options={filterLocationOptions}
|
||||
onInputChange={setFilterLocationInputValue}
|
||||
isLoading={isLoadingFilterLocations}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.location && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock...'
|
||||
value={filterProjectFlock}
|
||||
onChange={handleFilterProjectFlockChange}
|
||||
options={filterProjectFlockOptions}
|
||||
onInputChange={setProjectFlockSearchValue}
|
||||
isLoading={isLoadingFilterProjectFlocks}
|
||||
isDisabled={!filterLocation}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<div>
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock...'
|
||||
value={filterProjectFlock}
|
||||
onChange={(value) => {
|
||||
handleFilterProjectFlockChange(value);
|
||||
setFilterErrors((prev) => ({ ...prev, project_flock: '' }));
|
||||
}}
|
||||
options={filterProjectFlockOptions}
|
||||
onInputChange={setProjectFlockSearchValue}
|
||||
isLoading={isLoadingFilterProjectFlocks}
|
||||
isDisabled={!filterLocation}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.project_flock && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.project_flock}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang...'
|
||||
value={filterKandang}
|
||||
onChange={handleFilterKandangChange}
|
||||
options={filterKandangOptions}
|
||||
isDisabled={!filterProjectFlock}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
<div>
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang...'
|
||||
value={filterKandang}
|
||||
onChange={(value) => {
|
||||
handleFilterKandangChange(value);
|
||||
setFilterErrors((prev) => ({ ...prev, kandang: '' }));
|
||||
}}
|
||||
options={filterKandangOptions}
|
||||
isDisabled={!filterProjectFlock}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.kandang && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.kandang}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
||||
@@ -27,7 +27,8 @@ interface BarChartData {
|
||||
name: string;
|
||||
uv: number;
|
||||
isIdeal?: boolean;
|
||||
idealCount?: number;
|
||||
idealRange?: string;
|
||||
outsideRange?: string;
|
||||
}
|
||||
|
||||
interface UniformityBarChartProps {
|
||||
@@ -40,30 +41,117 @@ function CustomTooltip({ payload, label, active }: CustomTooltipProps) {
|
||||
const chartData = data.payload as BarChartData;
|
||||
const labelStr = String(label);
|
||||
|
||||
if (chartData.isIdeal && chartData.idealCount !== undefined) {
|
||||
// If the range has both ideal and outside ranges (like 340-344)
|
||||
if (chartData.idealRange && chartData.outsideRange) {
|
||||
return (
|
||||
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
|
||||
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
|
||||
<div className='flex flex-col gap-2 mt-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div>
|
||||
<span className='text-sm'>Ideal</span>
|
||||
</div>
|
||||
<span className='text-sm font-medium'>
|
||||
{chartData.idealRange}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-5 h-5 bg-[#EF4444] rounded-md'></div>
|
||||
<span className='text-sm'>Outside</span>
|
||||
</div>
|
||||
<span className='text-sm font-medium'>
|
||||
{chartData.outsideRange}
|
||||
</span>
|
||||
</div>
|
||||
<div className='border-t border-white/20 pt-2 mt-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-white/70 text-sm'>Total Birds:</span>
|
||||
<span className='font-semibold'>{payload[0].value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center text-xs text-white/50'>{labelStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If the range has only ideal range
|
||||
if (chartData.idealRange) {
|
||||
return (
|
||||
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
|
||||
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
|
||||
<div className='flex items-center gap-2 mt-2 justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div>
|
||||
{chartData.idealCount} of Birds
|
||||
<span className='text-sm'>Ideal</span>
|
||||
</div>
|
||||
<span>{labelStr}</span>
|
||||
<span className='text-sm font-medium'>{chartData.idealRange}</span>
|
||||
</div>
|
||||
<div className='border-t border-white/20 pt-2 mt-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-white/70 text-sm'>Birds:</span>
|
||||
<span className='font-semibold'>{payload[0].value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center text-xs text-white/50 mt-1'>
|
||||
{labelStr}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If the range has only outside range
|
||||
if (chartData.outsideRange) {
|
||||
return (
|
||||
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
|
||||
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
|
||||
<div className='flex items-center gap-2 mt-2 justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-5 h-5 bg-[#EF4444] rounded-md'></div>
|
||||
<span className='text-sm'>Outside</span>
|
||||
</div>
|
||||
<span className='text-sm font-medium'>
|
||||
{chartData.outsideRange}
|
||||
</span>
|
||||
</div>
|
||||
<div className='border-t border-white/20 pt-2 mt-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-white/70 text-sm'>Birds:</span>
|
||||
<span className='font-semibold'>{payload[0].value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center text-xs text-white/50 mt-1'>
|
||||
{labelStr}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for backward compatibility
|
||||
return (
|
||||
<div className='bg-[#18181B] p-2.5 shadow-sm text-white rounded-2xl rounded-bl-none'>
|
||||
<p className='m-0 font-bold text-white/50'>Uniformity 2025</p>
|
||||
<div className='flex items-center gap-2 mt-2 justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-5 h-5 bg-[#0069E0] rounded-md'></div>
|
||||
{payload[0].value} of Birds
|
||||
<div
|
||||
className='w-5 h-5 rounded-md'
|
||||
style={{
|
||||
backgroundColor: chartData.isIdeal ? '#0069E0' : '#EF4444',
|
||||
}}
|
||||
></div>
|
||||
<span className='text-sm'>
|
||||
{chartData.isIdeal ? 'Ideal' : 'Outside'}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-sm font-medium'>{labelStr}</span>
|
||||
</div>
|
||||
<div className='border-t border-white/20 pt-2 mt-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-white/70 text-sm'>Birds:</span>
|
||||
<span className='font-semibold'>{payload[0].value}</span>
|
||||
</div>
|
||||
<span>{labelStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
@@ -11,9 +11,12 @@ import Badge from '@/components/Badge';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { UniformityDetail as UniformityDetailType } from '@/types/api/production/uniformity';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import UniformityDetailsPreview from '@/components/pages/production/uniformity/detail/UniformityDetailsPreview';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import useSWR from 'swr';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIndicatorColor,
|
||||
@@ -33,6 +36,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
const setExpandedDrawerContent = useUiStore(
|
||||
(s) => s.setExpandedDrawerContent
|
||||
);
|
||||
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
|
||||
const [hasFetchedDetails, setHasFetchedDetails] = useState(false);
|
||||
|
||||
const { data: uniformityDetailResponse, isLoading } = useSWR(
|
||||
shouldFetchDetails
|
||||
? `uniformity-detail-${initialValues.id}-with-details`
|
||||
: null,
|
||||
() => UniformityApi.getUniformityDetail(initialValues.id, true)
|
||||
);
|
||||
|
||||
const uniformity_details = useMemo(() => {
|
||||
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
|
||||
return uniformityDetailResponse.data.uniformity_details;
|
||||
}
|
||||
return initialValues.uniformity_details;
|
||||
}, [shouldFetchDetails, uniformityDetailResponse, initialValues]);
|
||||
|
||||
const handleApprove = () => {
|
||||
router.push(`/production/uniformity?action=approve&id=${initialValues.id}`);
|
||||
@@ -43,12 +62,15 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
};
|
||||
|
||||
const handleViewUniformityDetails = () => {
|
||||
if (!uniformity_details || uniformity_details.length === 0) {
|
||||
setShouldFetchDetails(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDrawerContent(
|
||||
<UniformityDetailsPreview
|
||||
info_umum={initialValues.info_umum}
|
||||
uniformity_details={initialValues.uniformity_details}
|
||||
sampling={initialValues.sampling}
|
||||
result={initialValues.result}
|
||||
uniformity_details={uniformity_details}
|
||||
uniformityId={initialValues.id}
|
||||
/>
|
||||
);
|
||||
@@ -58,6 +80,28 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldFetchDetails &&
|
||||
uniformity_details &&
|
||||
uniformity_details.length > 0 &&
|
||||
!hasFetchedDetails
|
||||
) {
|
||||
setExpandedDrawerContent(
|
||||
<UniformityDetailsPreview
|
||||
info_umum={initialValues.info_umum}
|
||||
uniformity_details={uniformity_details}
|
||||
uniformityId={initialValues.id}
|
||||
/>
|
||||
);
|
||||
|
||||
setHasFetchedDetails(true);
|
||||
setTimeout(() => {
|
||||
setExpandedDrawerOpen(true);
|
||||
}, 0);
|
||||
}
|
||||
}, [shouldFetchDetails, uniformity_details, hasFetchedDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setExpandedDrawerOpen(false);
|
||||
@@ -154,12 +198,22 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{valueMap[id]}</span>
|
||||
<Tooltip content='Lihat Detail'>
|
||||
<Tooltip content='Lihat Detail' position='left'>
|
||||
<button
|
||||
className='p-1 hover:bg-gray-100 rounded cursor-pointer'
|
||||
className='p-1 hover:bg-gray-100 rounded cursor-pointer disabled:opacity-50'
|
||||
onClick={handleViewUniformityDetails}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={18} height={18} />
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon='mdi:loading'
|
||||
width={18}
|
||||
height={18}
|
||||
className='animate-spin'
|
||||
/>
|
||||
) : (
|
||||
<Icon icon='mdi:eye-outline' width={18} height={18} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -173,6 +227,92 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
[initialValues]
|
||||
);
|
||||
|
||||
const samplingTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!initialValues.sampling) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'sampling-size',
|
||||
label: 'Sampling size',
|
||||
value: `${formatNumber(initialValues.sampling.chick_qty_of_weight)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'mean-weight',
|
||||
label: 'Mean Weight',
|
||||
value: `${initialValues.sampling.mean_weight} g`,
|
||||
},
|
||||
{
|
||||
id: 'min-limit',
|
||||
label: 'Min Limit (-10%)',
|
||||
value: `${initialValues.sampling.mean_down} g`,
|
||||
},
|
||||
{
|
||||
id: 'max-limit',
|
||||
label: 'Max Limit (+10%)',
|
||||
value: `${initialValues.sampling.mean_up} g`,
|
||||
},
|
||||
];
|
||||
}, [initialValues.sampling]);
|
||||
|
||||
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const resultTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!initialValues.result) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'ideal-birds',
|
||||
label: 'Ideal Birds',
|
||||
value: `${formatNumber(initialValues.result.uniform_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'outside-range',
|
||||
label: 'Outside Range',
|
||||
value: `${formatNumber(initialValues.result.outside_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'uniformity',
|
||||
label: 'Uniformity',
|
||||
value: `${initialValues.result.uniformity} %`,
|
||||
},
|
||||
{
|
||||
id: 'cv',
|
||||
label: 'CV',
|
||||
value: `${initialValues.result.cv} %`,
|
||||
},
|
||||
];
|
||||
}, [initialValues.result]);
|
||||
|
||||
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className='w-full h-full bg-white border-l border-gray-200'>
|
||||
{/* Header */}
|
||||
@@ -185,7 +325,7 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
|
||||
{/* Form Section */}
|
||||
<div className='divider mt-3.5'></div>
|
||||
<section className='w-full px-6'>
|
||||
<section className='w-full px-6 mb-6'>
|
||||
{initialValues ? (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Info Umum */}
|
||||
@@ -200,23 +340,55 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Approve/Reject Buttons */}
|
||||
{initialValues.result &&
|
||||
initialValues.latest_approval?.step_name === 'CREATED' ? (
|
||||
<>
|
||||
<div className='divider my-3.5' />
|
||||
<RequirePermission permissions='lti.production.uniformity.approve'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
|
||||
<Button variant='outline' onClick={handleReject}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button onClick={handleApprove}>Approve</Button>
|
||||
</div>
|
||||
</RequirePermission>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Sampling and Range */}
|
||||
{initialValues.sampling && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
|
||||
<Table<DetailOptionType>
|
||||
data={samplingTableData}
|
||||
columns={columnsSampling}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{initialValues.result && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Result</p>
|
||||
<Table<DetailOptionType>
|
||||
data={resultTableData}
|
||||
columns={resultColumns}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approve/Reject Buttons */}
|
||||
{initialValues.result &&
|
||||
initialValues.latest_approval?.step_name === 'CREATED' ? (
|
||||
<>
|
||||
<div className='divider my-3.5' />
|
||||
<RequirePermission permissions='lti.production.uniformity.approve'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 [&_button]:rounded-lg'>
|
||||
<Button variant='outline' onClick={handleReject}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button onClick={handleApprove}>Approve</Button>
|
||||
</div>
|
||||
</RequirePermission>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
|
||||
|
||||
@@ -1,152 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
UniformityDetailItem,
|
||||
UniformitySampling,
|
||||
UniformityResult,
|
||||
UniformityInfoUmum,
|
||||
} from '@/types/api/production/uniformity';
|
||||
import Table from '@/components/Table';
|
||||
import Badge from '@/components/Badge';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { DetailOptionType } from '@/types/api/production/uniformity';
|
||||
import {
|
||||
getWeightStatusColor,
|
||||
getWeightStatusIndicatorColor,
|
||||
getWeightStatusText,
|
||||
} from '@/components/pages/production/uniformity/uniformity-utils';
|
||||
import { BodyWeightData } from '@/types/api/production/uniformity';
|
||||
import Button from '@/components/Button';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import useSWR from 'swr';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
interface UniformityDetailsPreviewProps {
|
||||
info_umum: UniformityInfoUmum;
|
||||
sampling: UniformitySampling;
|
||||
result: UniformityResult;
|
||||
uniformity_details?: UniformityDetailItem[];
|
||||
uniformityId: number;
|
||||
}
|
||||
|
||||
const UniformityDetailsPreview = ({
|
||||
info_umum,
|
||||
uniformity_details: initialUniformityDetails,
|
||||
sampling,
|
||||
result,
|
||||
uniformityId,
|
||||
uniformity_details,
|
||||
}: UniformityDetailsPreviewProps) => {
|
||||
const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen);
|
||||
const [shouldFetchDetails, setShouldFetchDetails] = useState(false);
|
||||
|
||||
const { data: uniformityDetailResponse, isLoading } = useSWR(
|
||||
shouldFetchDetails
|
||||
? `uniformity-detail-${uniformityId}-with-details`
|
||||
: null,
|
||||
() => UniformityApi.getUniformityDetail(uniformityId, true)
|
||||
);
|
||||
|
||||
const uniformity_details = useMemo(() => {
|
||||
if (shouldFetchDetails && isResponseSuccess(uniformityDetailResponse)) {
|
||||
return uniformityDetailResponse.data.uniformity_details;
|
||||
}
|
||||
return initialUniformityDetails;
|
||||
}, [shouldFetchDetails, uniformityDetailResponse, initialUniformityDetails]);
|
||||
|
||||
const handleClose = () => {
|
||||
setExpandedDrawerOpen(false);
|
||||
};
|
||||
|
||||
const fetchWeightData = () => {
|
||||
setShouldFetchDetails(true);
|
||||
};
|
||||
|
||||
const samplingTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!sampling) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'sampling-size',
|
||||
label: 'Sampling size',
|
||||
value: `${formatNumber(sampling.chick_qty_of_weight)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'mean-weight',
|
||||
label: 'Mean Weight',
|
||||
value: `${sampling.mean_weight} g`,
|
||||
},
|
||||
{
|
||||
id: 'min-limit',
|
||||
label: 'Min Limit (-10%)',
|
||||
value: `${sampling.mean_down} g`,
|
||||
},
|
||||
{
|
||||
id: 'max-limit',
|
||||
label: 'Max Limit (+10%)',
|
||||
value: `${sampling.mean_up} g`,
|
||||
},
|
||||
];
|
||||
}, [sampling]);
|
||||
|
||||
const columnsSampling: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const resultTableData: DetailOptionType[] = useMemo(() => {
|
||||
if (!result) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'ideal-birds',
|
||||
label: 'Ideal Birds',
|
||||
value: `${formatNumber(result.uniform_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'outside-range',
|
||||
label: 'Outside Range',
|
||||
value: `${formatNumber(result.outside_qty)} of Birds`,
|
||||
},
|
||||
{
|
||||
id: 'uniformity',
|
||||
label: 'Uniformity',
|
||||
value: `${result.uniformity} %`,
|
||||
},
|
||||
];
|
||||
}, [result]);
|
||||
|
||||
const resultColumns: ColumnDef<DetailOptionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: 'Label',
|
||||
cell: (props) => props.row.original.label,
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Value',
|
||||
cell: (props) => <span>{props.row.original.value}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!uniformity_details) return [];
|
||||
|
||||
@@ -229,55 +116,10 @@ const UniformityDetailsPreview = ({
|
||||
{/* Form Section */}
|
||||
<div className='divider mt-3.5'></div>
|
||||
<section className='w-full px-6'>
|
||||
{info_umum || sampling || result ? (
|
||||
{info_umum ? (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Sampling and Range */}
|
||||
{sampling && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Sampling and Range</p>
|
||||
<Table<DetailOptionType>
|
||||
data={samplingTableData}
|
||||
columns={columnsSampling}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className=''>
|
||||
<p className='text-sm font-medium mb-5'>Result</p>
|
||||
<Table<DetailOptionType>
|
||||
data={resultTableData}
|
||||
columns={resultColumns}
|
||||
pageSize={4}
|
||||
className={{
|
||||
containerClassName: 'mb-0',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!uniformity_details || uniformity_details.length === 0 ? (
|
||||
<div className='mt-4'>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={fetchWeightData}
|
||||
disabled={isLoading}
|
||||
className='w-full'
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Show Body Weight Details'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Body Weight Details */}
|
||||
{uniformity_details && uniformity_details.length > 0 && (
|
||||
{uniformity_details && uniformity_details.length > 0 ? (
|
||||
<div className='mt-4'>
|
||||
<Table<BodyWeightData>
|
||||
data={tableData}
|
||||
@@ -286,6 +128,17 @@ const UniformityDetailsPreview = ({
|
||||
className={{ containerClassName: 'mb-5' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-10 text-gray-400'>
|
||||
<Icon
|
||||
icon='mdi:file-document-outline'
|
||||
width={64}
|
||||
height={64}
|
||||
className='mb-4'
|
||||
/>
|
||||
<p className='text-lg'>No data available</p>
|
||||
<p className='text-sm'>Body weight details not found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -24,9 +24,9 @@ type UniformityFormSchemaType = {
|
||||
};
|
||||
|
||||
const FileSchema = Yup.mixed<File>()
|
||||
.test('documentSize', 'Ukuran file maksimal 2 MB', (value): boolean => {
|
||||
.test('documentSize', 'Ukuran file maksimal 5 MB', (value): boolean => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return false;
|
||||
})
|
||||
.test('documentType', 'Format file harus Excel', (value): boolean => {
|
||||
|
||||
@@ -43,7 +43,9 @@ import UniformityResultForm from '@/components/pages/production/uniformity/form/
|
||||
import { generateUniformityTemplate } from '@/components/pages/production/uniformity/export/UniformityTemplate';
|
||||
import useSWR from 'swr';
|
||||
import { cn, formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
interface UniformityFormProps {
|
||||
formType?: 'add' | 'edit';
|
||||
@@ -77,6 +79,7 @@ const UniformityForm = ({
|
||||
|
||||
const [uniformityFormErrorMessage, setUniformityFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -282,6 +285,22 @@ const UniformityForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== FORM HANDLERS =====
|
||||
const handleLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
@@ -339,8 +358,8 @@ const UniformityForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.size > 2 * 1024 * 1024) {
|
||||
toast.error(`Ukuran file ${document.name} maksimal 2 MB!`);
|
||||
if (document.size > 5 * 1024 * 1024) {
|
||||
toast.error(`Ukuran file ${document.name} maksimal 5 MB!`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -454,7 +473,7 @@ const UniformityForm = ({
|
||||
<section className='w-full px-6 mb-6'>
|
||||
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className='flex flex-col gap-6'>
|
||||
<form onSubmit={handleFormSubmit} className='flex flex-col gap-6'>
|
||||
{uniformityFormErrorMessage && (
|
||||
<div className='alert alert-error' role='alert'>
|
||||
<Icon
|
||||
@@ -466,6 +485,14 @@ const UniformityForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateInput
|
||||
required
|
||||
label='Tanggal'
|
||||
@@ -693,7 +720,7 @@ const UniformityForm = ({
|
||||
type='submit'
|
||||
color='primary'
|
||||
className='w-full'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? (
|
||||
<span className='loading loading-spinner'></span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -14,6 +14,7 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
PurchaseRequestAcceptApprovalFormDefaultValues,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '@/types/api/purchase/purchase';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
|
||||
@@ -52,7 +54,9 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
const searchParams = useSearchParams();
|
||||
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [key, setKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
|
||||
|
||||
@@ -67,7 +71,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
| 'purchase_item_id'
|
||||
| 'received_date'
|
||||
| 'travel_number'
|
||||
| 'travel_document_path'
|
||||
| 'vehicle_number'
|
||||
| 'expedition_vendor_id'
|
||||
| 'received_qty'
|
||||
@@ -180,7 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
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:
|
||||
@@ -210,6 +212,22 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
const purchaseItems = useMemo(() => {
|
||||
if (initialValues?.items) {
|
||||
@@ -235,6 +253,9 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
useEffect(() => {
|
||||
if (purchaseItems.length > 0 && initialValues?.items) {
|
||||
const updatedItems = initialValues.items.map((item) => {
|
||||
const expeditionVendorId =
|
||||
item.expedition_vendor_id || item.expedition_vendor?.id || 0;
|
||||
|
||||
return {
|
||||
purchase_item: null,
|
||||
purchase_item_id: item.id,
|
||||
@@ -242,7 +263,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
? 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: item.expedition_vendor
|
||||
? {
|
||||
@@ -250,7 +270,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
label: item.expedition_vendor.name,
|
||||
}
|
||||
: null,
|
||||
expedition_vendor_id: item.expedition_vendor_id || 0,
|
||||
expedition_vendor_id: expeditionVendorId,
|
||||
received_qty: item.total_qty || '',
|
||||
transport_per_item: item.transport_per_item || '',
|
||||
};
|
||||
@@ -259,20 +279,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
}
|
||||
}, [purchaseItems, initialValues, key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
formik.values.travel_documents &&
|
||||
formik.values.travel_documents.length > 0
|
||||
) {
|
||||
const fileNames = formik.values.travel_documents
|
||||
.map((file) => file.name)
|
||||
.join(', ');
|
||||
formik.values.items?.forEach((item, idx) => {
|
||||
formik.setFieldValue(`items.${idx}.travel_document_path`, fileNames);
|
||||
});
|
||||
}
|
||||
}, [formik.values.travel_documents]);
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
const getQuantityExceededError = useCallback(
|
||||
(idx: number, receivedQty: number) => {
|
||||
@@ -349,7 +355,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
return (
|
||||
<form
|
||||
key={key}
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
className='w-full flex flex-col gap-6'
|
||||
>
|
||||
<div className='w-full'>
|
||||
@@ -358,6 +364,24 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
? 'Konfirmasi Penerimaan Produk'
|
||||
: 'Edit Penerimaan Produk'}
|
||||
</h2>
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table'>
|
||||
@@ -510,33 +534,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className='hidden'>
|
||||
<TextInput
|
||||
required
|
||||
name={`items.${idx}.travel_document_path`}
|
||||
type='text'
|
||||
value={formItem?.travel_document_path || ''}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue(
|
||||
`items.${idx}.travel_document_path`,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
isRepeaterInputError(idx, 'travel_document_path')
|
||||
.isError
|
||||
}
|
||||
errorMessage={
|
||||
isRepeaterInputError(idx, 'travel_document_path')
|
||||
.errorMessage
|
||||
}
|
||||
placeholder='Masukkan path dokumen'
|
||||
className={{
|
||||
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
required
|
||||
@@ -687,14 +684,15 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
name='travel_documents'
|
||||
label='Dokumen Surat Jalan'
|
||||
accept='.pdf,.jpg,.jpeg,.png'
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const invalidFiles = files.filter(
|
||||
(file) => file.size > 2 * 1024 * 1024
|
||||
(file) => file.size > 5 * 1024 * 1024
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Ukuran dokumen maksimal 2 MB!');
|
||||
toast.error('Ukuran dokumen maksimal 5 MB!');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -726,6 +724,10 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
onClick={() => {
|
||||
if (type === 'add') {
|
||||
formik.resetForm();
|
||||
formik.setFieldValue('travel_documents', []);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
setPurchaseOrderFormErrorMessage('');
|
||||
onCancel?.();
|
||||
@@ -741,27 +743,13 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={
|
||||
!formik.isValid ||
|
||||
formik.isSubmitting ||
|
||||
hasQuantityExceededErrors ||
|
||||
isRejected
|
||||
formik.isSubmitting || hasQuantityExceededErrors || isRejected
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
travel_document_path: string;
|
||||
vehicle_number: string;
|
||||
expedition_vendor?: {
|
||||
value: number;
|
||||
@@ -76,7 +75,6 @@ export type PurchaseAcceptApprovalItemSchema = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
travel_document_path: string;
|
||||
vehicle_number: string;
|
||||
expedition_vendor?: {
|
||||
value: number;
|
||||
@@ -185,9 +183,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
|
||||
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!'),
|
||||
@@ -395,9 +390,9 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
|
||||
.of(
|
||||
Yup.mixed<File>()
|
||||
.required('Dokumen surat jalan wajib diupload!')
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => {
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 2 * 1024 * 1024;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
@@ -415,7 +410,6 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
|
||||
purchase_item_id: 0,
|
||||
received_date: '',
|
||||
travel_number: '',
|
||||
travel_document_path: '',
|
||||
vehicle_number: '',
|
||||
expedition_vendor_id: 0,
|
||||
received_qty: '',
|
||||
@@ -436,7 +430,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
|
||||
purchase_item_id: item.id,
|
||||
received_date: '',
|
||||
travel_number: '',
|
||||
travel_document_path: '',
|
||||
vehicle_number: '',
|
||||
expedition_vendor_id: 0,
|
||||
received_qty: '',
|
||||
@@ -447,7 +440,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
|
||||
purchase_item_id: 0,
|
||||
received_date: '',
|
||||
travel_number: '',
|
||||
travel_document_path: '',
|
||||
vehicle_number: '',
|
||||
expedition_vendor_id: 0,
|
||||
received_qty: '',
|
||||
|
||||
@@ -12,10 +12,12 @@ 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 AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { SupplierProducts } from '@/types/api/master-data/supplier';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import {
|
||||
PurchaseRequestStaffApprovalFormDefaultValues,
|
||||
PurchaseRequestStaffApprovalFormInitialValues,
|
||||
@@ -87,6 +89,7 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
const deleteModal = useModal();
|
||||
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
const [selectedItemForDelete, setSelectedItemForDelete] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
@@ -415,6 +418,22 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
const supplierProductOptions = baseSupplierProductOptions;
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
@@ -652,16 +671,32 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className='w-full flex flex-col gap-6'
|
||||
>
|
||||
<form onSubmit={handleFormSubmit} className='w-full flex flex-col gap-6'>
|
||||
<div className='w-full'>
|
||||
<h2 className='text-lg font-semibold mb-4'>
|
||||
{type === 'add'
|
||||
? 'Konfirmasi Item Pembelian'
|
||||
: 'Edit Item Pembelian'}
|
||||
</h2>
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
{groupedPurchaseItems.length > 0 ? (
|
||||
<div>
|
||||
@@ -1164,23 +1199,12 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting || isRejected}
|
||||
disabled={formik.isSubmitting || isRejected}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{purchaseOrderFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseOrderFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import SelectInput, {
|
||||
} from '@/components/input/SelectInput';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
|
||||
import {
|
||||
PurchaseRequestFormSchema,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
|
||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
import { PurchaseApi } from '@/services/api/purchase';
|
||||
|
||||
import Card from '@/components/Card';
|
||||
@@ -59,6 +61,7 @@ const PurchaseRequestForm = ({
|
||||
);
|
||||
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
// ===== TYPE DEFINITIONS =====
|
||||
interface ProductOptionType {
|
||||
@@ -211,6 +214,22 @@ const PurchaseRequestForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidateForm = async () => {
|
||||
const errors = await formik.validateForm();
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMessages = getUniqueFormikErrors(errors);
|
||||
setFormErrorList(errorMessages);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
handleValidateForm();
|
||||
formik.handleSubmit(e);
|
||||
};
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
const { data: supplierData, isLoading: isLoadingProducts } = useSWR(
|
||||
formik.values.supplier_id && Number(formik.values.supplier_id) > 0
|
||||
@@ -487,10 +506,29 @@ const PurchaseRequestForm = ({
|
||||
</h1>
|
||||
</header>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{purchaseRequestFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseRequestFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error List Alert */}
|
||||
{formErrorList.length > 0 && (
|
||||
<AlertErrorList
|
||||
formErrorList={formErrorList}
|
||||
onClose={() => setFormErrorList([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Basic Info Card */}
|
||||
<Card
|
||||
title='Informasi Purchase Request'
|
||||
@@ -896,7 +934,7 @@ const PurchaseRequestForm = ({
|
||||
color='primary'
|
||||
className='px-4'
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
@@ -935,17 +973,6 @@ const PurchaseRequestForm = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{purchaseRequestFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{purchaseRequestFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import Tabs from '@/components/Tabs';
|
||||
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
|
||||
|
||||
const FinanceTabs = () => {
|
||||
const tabs = [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Kontrol Pembayaran Customer',
|
||||
|
||||
content: <CustomerPaymentTab />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<Tabs tabs={tabs} variant='lifted' />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceTabs;
|
||||
@@ -0,0 +1,425 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
Document,
|
||||
StyleSheet,
|
||||
Font,
|
||||
pdf,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
|
||||
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
src: 'helvetica',
|
||||
});
|
||||
|
||||
const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
fontSize: 10,
|
||||
fontFamily: 'Helvetica',
|
||||
padding: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
titleSection: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 5,
|
||||
color: '#1f74bf',
|
||||
},
|
||||
supplierTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1f74bf',
|
||||
},
|
||||
supplierInfo: {
|
||||
fontSize: 9,
|
||||
marginBottom: 5,
|
||||
color: '#333333',
|
||||
},
|
||||
table: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#000000',
|
||||
marginBottom: 15,
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tableHeader: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
tableCell: {
|
||||
flex: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#000000',
|
||||
borderRightStyle: 'solid',
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
textAlign: 'left',
|
||||
},
|
||||
tableCellNo: {
|
||||
flex: 0.5,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#000000',
|
||||
borderRightStyle: 'solid',
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tableCellLast: {
|
||||
flex: 1,
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
},
|
||||
tableCellHeader: {
|
||||
flex: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#000000',
|
||||
borderRightStyle: 'solid',
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#000000',
|
||||
borderBottomStyle: 'solid',
|
||||
paddingVertical: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tableCellHeaderRight: {
|
||||
flex: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#000000',
|
||||
borderRightStyle: 'solid',
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: '#F5F5F5',
|
||||
textAlign: 'right',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#000000',
|
||||
borderBottomStyle: 'solid',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
tableCellRight: {
|
||||
flex: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#000000',
|
||||
borderRightStyle: 'solid',
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
textAlign: 'right',
|
||||
},
|
||||
tableCellCenter: {
|
||||
flex: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#000000',
|
||||
borderRightStyle: 'solid',
|
||||
padding: 4,
|
||||
fontSize: 7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tableBorderBottom: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#000000',
|
||||
borderBottomStyle: 'solid',
|
||||
},
|
||||
summaryRow: {
|
||||
backgroundColor: '#F0F0F0',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
interface CustomerPaymentExportPDFParams {
|
||||
data: CustomerPaymentReport[];
|
||||
}
|
||||
|
||||
const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
|
||||
return (
|
||||
<Document>
|
||||
{params.data.map((customerReport, customerIndex) => (
|
||||
<Page
|
||||
key={customerIndex}
|
||||
size='A4'
|
||||
orientation='landscape'
|
||||
style={pdfStyles.page}
|
||||
>
|
||||
{/* Title and Customer Info */}
|
||||
<View style={pdfStyles.titleSection}>
|
||||
<Text style={pdfStyles.mainTitle}>
|
||||
Laporan > Kontrol Pembayaran Customer
|
||||
</Text>
|
||||
<Text style={pdfStyles.supplierTitle}>
|
||||
{customerReport.customer.name}
|
||||
</Text>
|
||||
<Text style={pdfStyles.supplierInfo}>
|
||||
{customerReport.customer_address || ''}
|
||||
</Text>
|
||||
<Text style={pdfStyles.supplierInfo}>
|
||||
NPWP: {customerReport.customer_npwp || '-'}
|
||||
</Text>
|
||||
{customerReport.summary && (
|
||||
<Text style={pdfStyles.supplierInfo}>
|
||||
Total Saldo Piutang:{' '}
|
||||
{formatCurrency(
|
||||
customerReport.summary.total_accounts_receivable
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Table */}
|
||||
<View style={pdfStyles.table}>
|
||||
{/* Table Header */}
|
||||
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
|
||||
<Text>No</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
|
||||
<Text>Tgl DO/Bayar</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
|
||||
<Text>Tgl Realisasi</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
|
||||
<Text>Aging</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
|
||||
<Text>Referensi</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
|
||||
<Text>No. Polisi</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
|
||||
<Text>Qty</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
|
||||
<Text>Berat (Kg)</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
|
||||
<Text>AVG</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Harga Awal</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
|
||||
<Text>CN</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Harga Akhir</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
|
||||
<Text>PPN (%)</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Total</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Pembayaran</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
|
||||
<Text>Saldo Piutang</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
|
||||
<Text>Ket</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
|
||||
<Text>Pengambilan</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
|
||||
<Text>Sales</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Table Body */}
|
||||
{customerReport.rows.map((item, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
pdfStyles.tableRow,
|
||||
index < customerReport.rows.length - 1
|
||||
? pdfStyles.tableBorderBottom
|
||||
: {},
|
||||
]}
|
||||
>
|
||||
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
|
||||
<Text>{index + 1}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{item.do_date ? formatDate(item.do_date, 'DD MMM YY') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{item.realization_date
|
||||
? formatDate(item.realization_date, 'DD MMM YY')
|
||||
: '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellCenter, { flex: 0.8 }]}>
|
||||
<Text>{formatNumber(item.aging)} hari</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
|
||||
<Text>{item.reference || '-'}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
|
||||
<Text>{item.vehicle_plate || '-'}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||
<Text>{formatNumber(item.qty)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
|
||||
<Text>{formatNumber(item.weight)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||
<Text>{formatNumber(item.average_weight)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.price)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
|
||||
<Text>{formatCurrency(item.credit_note)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.final_price)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||
<Text>{formatNumber(item.ppn)}%</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.total)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.payment)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>{formatCurrency(item.accounts_receivable)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
|
||||
<Text>{item.notes || '-'}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
|
||||
<Text>{item.pickup_info || '-'}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
|
||||
<Text>{item.sales_marketing || '-'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Summary Row */}
|
||||
{customerReport.summary && (
|
||||
<View style={[pdfStyles.tableRow, pdfStyles.summaryRow]}>
|
||||
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
|
||||
<Text>Total</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 0.8 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||
<Text>{formatNumber(customerReport.summary.total_qty)}</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
|
||||
<Text>
|
||||
{formatNumber(customerReport.summary.total_weight)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{formatCurrency(
|
||||
customerReport.summary.total_initial_amount
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
|
||||
<Text>
|
||||
{formatCurrency(customerReport.summary.total_credit_note)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{formatCurrency(customerReport.summary.total_final_amount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{formatCurrency(customerReport.summary.total_grand_amount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{formatCurrency(customerReport.summary.total_payment)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
|
||||
<Text>
|
||||
{formatCurrency(
|
||||
customerReport.summary.total_accounts_receivable
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
<View style={[pdfStyles.tableCellLast, { flex: 1.5 }]}>
|
||||
<Text></Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
))}
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateCustomerPaymentPDF = async (
|
||||
params: CustomerPaymentExportPDFParams
|
||||
): Promise<void> => {
|
||||
const PDFDocument = createPDFDocument(params);
|
||||
|
||||
try {
|
||||
const blob = await pdf(PDFDocument).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `laporan-kontrol-pembayaran-customer-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
|
||||
|
||||
interface CustomerPaymentExportExcelParams {
|
||||
data: CustomerPaymentReport[];
|
||||
}
|
||||
|
||||
export const generateCustomerPaymentExcel = (
|
||||
params: CustomerPaymentExportExcelParams
|
||||
): void => {
|
||||
if (!params.data || params.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
params.data.forEach((customerReport) => {
|
||||
const customerData = customerReport.rows;
|
||||
const customerName = customerReport.customer.name || 'Unknown Customer';
|
||||
|
||||
const excelData: { [key: string]: string | number }[] = customerData.map(
|
||||
(item, index) => ({
|
||||
No: index + 1,
|
||||
'Tanggal DO/Bayar': item.do_date
|
||||
? formatDate(item.do_date, 'DD MMM YYYY')
|
||||
: '',
|
||||
'Tanggal Realisasi': item.realization_date
|
||||
? formatDate(item.realization_date, 'DD MMM YYYY')
|
||||
: '',
|
||||
Aging: formatNumber(item.aging || 0),
|
||||
Referensi: item.reference || '',
|
||||
'Nomor Polisi': item.vehicle_plate || '',
|
||||
'Ekor/Qty': formatNumber(item.qty || 0),
|
||||
'Berat (Kg)': formatNumber(item.weight || 0),
|
||||
AVG: formatNumber(item.average_weight || 0),
|
||||
'Harga Awal': formatCurrency(item.price || 0),
|
||||
CN: formatCurrency(item.credit_note || 0),
|
||||
'Harga Akhir': formatCurrency(item.final_price || 0),
|
||||
'PPN (%)': formatNumber(item.ppn || 0),
|
||||
Total: formatCurrency(item.total || 0),
|
||||
Pembayaran: formatCurrency(item.payment || 0),
|
||||
'Saldo Piutang': formatCurrency(item.accounts_receivable || 0),
|
||||
Keterangan: item.notes || '',
|
||||
Pengambilan: item.pickup_info || '',
|
||||
'Sales/Marketing': item.sales_marketing || '',
|
||||
})
|
||||
);
|
||||
|
||||
if (customerReport.summary) {
|
||||
excelData.push({
|
||||
No: 'Total',
|
||||
'Tanggal DO/Bayar': '',
|
||||
'Tanggal Realisasi': '',
|
||||
Aging: '',
|
||||
Referensi: '',
|
||||
'Nomor Polisi': '',
|
||||
'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0),
|
||||
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
|
||||
AVG: '',
|
||||
'Harga Awal': formatCurrency(
|
||||
customerReport.summary.total_initial_amount || 0
|
||||
),
|
||||
CN: formatCurrency(customerReport.summary.total_credit_note || 0),
|
||||
'Harga Akhir': formatCurrency(
|
||||
customerReport.summary.total_final_amount || 0
|
||||
),
|
||||
'PPN (%)': '',
|
||||
Total: formatCurrency(customerReport.summary.total_grand_amount || 0),
|
||||
Pembayaran: formatCurrency(customerReport.summary.total_payment || 0),
|
||||
'Saldo Piutang': formatCurrency(
|
||||
customerReport.summary.total_accounts_receivable || 0
|
||||
),
|
||||
Keterangan: '',
|
||||
Pengambilan: '',
|
||||
'Sales/Marketing': '',
|
||||
});
|
||||
}
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||
|
||||
const colWidths = [
|
||||
{ wch: 5 }, // No
|
||||
{ wch: 15 }, // Tanggal DO/Bayar
|
||||
{ wch: 15 }, // Tanggal Realisasi
|
||||
{ wch: 8 }, // Aging
|
||||
{ wch: 12 }, // Referensi
|
||||
{ wch: 15 }, // Nomor Polisi
|
||||
{ wch: 10 }, // Ekor/Qty
|
||||
{ wch: 12 }, // Berat
|
||||
{ wch: 10 }, // AVG
|
||||
{ wch: 15 }, // Harga Awal
|
||||
{ wch: 10 }, // CN
|
||||
{ wch: 15 }, // Harga Akhir
|
||||
{ wch: 10 }, // PPN
|
||||
{ wch: 15 }, // Total
|
||||
{ wch: 15 }, // Pembayaran
|
||||
{ wch: 15 }, // Saldo Piutang
|
||||
{ wch: 20 }, // Keterangan
|
||||
{ wch: 15 }, // Pengambilan
|
||||
{ wch: 20 }, // Sales/Marketing
|
||||
];
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
const sheetName =
|
||||
customerName.length > 31 ? customerName.substring(0, 31) : customerName;
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
});
|
||||
|
||||
const filename = `laporan-kontrol-pembayaran-customer-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
|
||||
|
||||
XLSX.writeFile(workbook, filename);
|
||||
};
|
||||
@@ -0,0 +1,717 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Card from '@/components/Card';
|
||||
import SelectInput, {
|
||||
useSelect,
|
||||
OptionType,
|
||||
} from '@/components/input/SelectInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { FinanceApi } from '@/services/api/report/finance-report';
|
||||
import Table from '@/components/Table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import {
|
||||
CustomerPaymentReport,
|
||||
CustomerPaymentSummary,
|
||||
} from '@/types/api/report/customer-payment';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import Button from '@/components/Button';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
|
||||
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
|
||||
|
||||
const CustomerPaymentTab = () => {
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||
|
||||
// ===== PAGINATION STATE =====
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// ===== SUBMISSION STATE =====
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// ===== FILTER STATE =====
|
||||
const [filterCustomer, setFilterCustomer] = useState<OptionType[]>([]);
|
||||
const [filterSales, setFilterSales] = useState<OptionType[]>([]);
|
||||
const [filterStartDate, setFilterStartDate] = useState('');
|
||||
const [filterEndDate, setFilterEndDate] = useState('');
|
||||
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const filterModal = useModal();
|
||||
|
||||
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
|
||||
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const salesOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'Sales A', label: 'Sales A' },
|
||||
{ value: 'Sales B', label: 'Sales B' },
|
||||
{ value: 'Sales C', label: 'Sales C' },
|
||||
// TODO: Fetch sales options from API
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const dataTypeOptions = useMemo(
|
||||
() => [{ value: 'do_date', label: 'Tanggal Jual' }],
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setIsSubmitted(false);
|
||||
setFilterCustomer([]);
|
||||
setFilterSales([]);
|
||||
setFilterStartDate('');
|
||||
setFilterEndDate('');
|
||||
setFilterErrors({});
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = useCallback(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!filterStartDate) {
|
||||
errors.start_date = 'Tanggal mulai wajib diisi';
|
||||
}
|
||||
if (!filterEndDate) {
|
||||
errors.end_date = 'Tanggal akhir wajib diisi';
|
||||
}
|
||||
|
||||
setFilterErrors(errors);
|
||||
|
||||
if (Object.keys(errors).length === 0) {
|
||||
setIsSubmitted(true);
|
||||
setCurrentPage(1);
|
||||
filterModal.closeModal();
|
||||
}
|
||||
}, [filterModal, filterStartDate, filterEndDate]);
|
||||
|
||||
// ===== DATA FETCHING =====
|
||||
const { data: customerPayment, isLoading } = useSWR(
|
||||
isSubmitted
|
||||
? () => {
|
||||
const params = {
|
||||
customer_id:
|
||||
filterCustomer.length > 0
|
||||
? filterCustomer.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
sales:
|
||||
filterSales.length > 0
|
||||
? filterSales.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
filter_by: 'do_date' as const,
|
||||
start_date: filterStartDate || undefined,
|
||||
end_date: filterEndDate || undefined,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
return ['customer-payment-report', params];
|
||||
}
|
||||
: null,
|
||||
([, params]) =>
|
||||
FinanceApi.getCustomerPaymentReport(
|
||||
params.customer_id,
|
||||
params.sales,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date,
|
||||
params.page,
|
||||
params.limit
|
||||
)
|
||||
);
|
||||
|
||||
const data: CustomerPaymentReport[] = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(customerPayment)
|
||||
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
|
||||
: [],
|
||||
[customerPayment]
|
||||
);
|
||||
|
||||
const meta =
|
||||
isResponseSuccess(customerPayment) && customerPayment?.meta
|
||||
? customerPayment.meta
|
||||
: null;
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const customerPaymentExport = useCallback(async (): Promise<
|
||||
CustomerPaymentReport[] | null
|
||||
> => {
|
||||
const params = {
|
||||
customer_id:
|
||||
filterCustomer.length > 0
|
||||
? filterCustomer.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
sales:
|
||||
filterSales.length > 0
|
||||
? filterSales.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
filter_by: 'do_date' as const,
|
||||
start_date: filterStartDate || undefined,
|
||||
end_date: filterEndDate || undefined,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
const response = await FinanceApi.getCustomerPaymentReport(
|
||||
params.customer_id,
|
||||
params.sales,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date,
|
||||
params.page,
|
||||
params.limit
|
||||
);
|
||||
|
||||
return isResponseSuccess(response)
|
||||
? (response.data as unknown as CustomerPaymentReport[])
|
||||
: null;
|
||||
}, [filterCustomer, filterSales, filterStartDate, filterEndDate]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await customerPaymentExport();
|
||||
|
||||
if (
|
||||
!allDataForExport ||
|
||||
!Array.isArray(allDataForExport) ||
|
||||
allDataForExport.length === 0
|
||||
) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
generateCustomerPaymentExcel({ data: allDataForExport });
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [customerPaymentExport]);
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await customerPaymentExport();
|
||||
|
||||
if (
|
||||
!allDataForExport ||
|
||||
!Array.isArray(allDataForExport) ||
|
||||
allDataForExport.length === 0
|
||||
) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
await generateCustomerPaymentPDF({ data: allDataForExport });
|
||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsPdfExportLoading(false);
|
||||
}
|
||||
}, [customerPaymentExport]);
|
||||
|
||||
// ===== PAGINATION HANDLERS =====
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleRowChange = (pageSize: number) => {
|
||||
setPageSize(pageSize);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (meta && currentPage < meta.total_pages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getTableColumns = (
|
||||
summary: CustomerPaymentSummary
|
||||
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
|
||||
const tableColumns: ColumnDef<CustomerPaymentReport['rows'][0]>[] = [
|
||||
{
|
||||
id: 'no',
|
||||
header: 'No',
|
||||
cell: (props) => props.row.index + 1,
|
||||
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
|
||||
},
|
||||
{
|
||||
id: 'do_date_or_payment_date',
|
||||
header: 'Tanggal DO/Bayar',
|
||||
accessorKey: 'do_date',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.do_date;
|
||||
return formatDate(value, 'DD MMM YYYY');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'realization_date',
|
||||
header: 'Tanggal Realisasi',
|
||||
accessorKey: 'realization_date',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.realization_date;
|
||||
return formatDate(value, 'DD MMM YYYY');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'aging',
|
||||
header: 'Aging',
|
||||
accessorKey: 'aging',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.aging;
|
||||
return <div className='text-center'>{formatNumber(value)} hari</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
header: 'Referensi',
|
||||
accessorKey: 'reference',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.reference;
|
||||
return value || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vehicle_plate',
|
||||
header: 'Nomor Polisi',
|
||||
accessorKey: 'vehicle_plate',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.vehicle_plate;
|
||||
return value || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qty',
|
||||
header: 'Ekor/Qty',
|
||||
accessorKey: 'qty',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.qty;
|
||||
return <div className='text-right'>{formatNumber(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatNumber(summary.total_qty) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
header: 'Berat (Kg)',
|
||||
accessorKey: 'weight',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.weight;
|
||||
return <div className='text-right'>{formatNumber(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatNumber(summary.total_weight) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'average_weight',
|
||||
header: 'AVG',
|
||||
accessorKey: 'average_weight',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.average_weight;
|
||||
return <div className='text-right'>{formatNumber(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: 'Harga Awal',
|
||||
accessorKey: 'price',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.price;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary.total_initial_amount) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'credit_note',
|
||||
header: 'CN',
|
||||
accessorKey: 'credit_note',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.credit_note;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary.total_credit_note) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'final_price',
|
||||
header: 'Harga Akhir',
|
||||
accessorKey: 'final_price',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.final_price;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary.total_final_amount) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ppn',
|
||||
header: 'PPN (%)',
|
||||
accessorKey: 'ppn',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.ppn;
|
||||
return <div className='text-right'>{formatNumber(value)}%</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
header: 'Total',
|
||||
accessorKey: 'total',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.total;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary.total_grand_amount) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'payment',
|
||||
header: 'Pembayaran',
|
||||
accessorKey: 'payment',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.payment;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary.total_payment) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'accounts_receivable',
|
||||
header: 'Saldo Piutang',
|
||||
accessorKey: 'accounts_receivable',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.accounts_receivable;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary.total_accounts_receivable) || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
header: 'Keterangan',
|
||||
accessorKey: 'notes',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.notes;
|
||||
return value || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pickup_info',
|
||||
header: 'Pengambilan',
|
||||
accessorKey: 'pickup_info',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.pickup_info;
|
||||
return value || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sales_marketing',
|
||||
header: 'Sales/Marketing',
|
||||
accessorKey: 'sales_marketing',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.sales_marketing;
|
||||
return value || '-';
|
||||
},
|
||||
},
|
||||
];
|
||||
return tableColumns;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full p-0 sm:p-4'>
|
||||
<Card
|
||||
subtitle='Laporan > Kontrol Pembayaran Customer'
|
||||
className={{ wrapper: 'w-full', body: 'p-1!' }}
|
||||
>
|
||||
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
|
||||
<Button variant='outline' onClick={filterModal.openModal}>
|
||||
<Icon icon='heroicons:funnel' width={18} height={18} />
|
||||
Filter
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
trigger={
|
||||
<Button variant='outline' isLoading={isAnyExportLoading}>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
Export
|
||||
</Button>
|
||||
}
|
||||
align='end'
|
||||
>
|
||||
<Menu>
|
||||
<MenuItem title='Excel' onClick={handleExportExcel} />
|
||||
<MenuItem title='PDF' onClick={handleExportPdf} />
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-semibold'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='space-y-4 px-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
|
||||
<div>
|
||||
<DateInput
|
||||
label='Tanggal'
|
||||
name='start_date'
|
||||
value={filterStartDate}
|
||||
onChange={(e) => {
|
||||
setFilterStartDate(e.target.value);
|
||||
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
|
||||
}}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.start_date && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.start_date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DateInput
|
||||
label=' '
|
||||
name='end_date'
|
||||
value={filterEndDate}
|
||||
onChange={(e) => {
|
||||
setFilterEndDate(e.target.value);
|
||||
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
|
||||
}}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.end_date && (
|
||||
<p className='text-red-500 text-sm mt-1'>
|
||||
{filterErrors.end_date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SelectInput
|
||||
label='Customer'
|
||||
placeholder='Pilih Customer'
|
||||
isMulti
|
||||
options={customerOptions}
|
||||
value={filterCustomer}
|
||||
onChange={(val) => {
|
||||
setFilterCustomer(
|
||||
Array.isArray(val) ? val : val ? [val] : []
|
||||
);
|
||||
}}
|
||||
isLoading={isLoadingCustomers}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SelectInput
|
||||
label='Sales'
|
||||
placeholder='Pilih Sales'
|
||||
isMulti
|
||||
options={salesOptions}
|
||||
value={filterSales}
|
||||
onChange={(val) => {
|
||||
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
|
||||
}}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SelectInput
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={dataTypeOptions}
|
||||
value={dataTypeOptions[0]}
|
||||
isDisabled={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
|
||||
<Button
|
||||
variant='soft'
|
||||
className='ms-4 min-w-36 rounded-lg'
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
className='me-4 min-w-36 rounded-lg'
|
||||
onClick={handleApplyFilters}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{!isSubmitted ? (
|
||||
<div className='mt-6 text-center text-gray-500'>
|
||||
Silakan klik tombol Filter untuk mengatur filter dan menampilkan
|
||||
data.
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className='mt-6 text-center text-gray-500'>
|
||||
Tidak ada data yang dapat ditampilkan...
|
||||
</div>
|
||||
) : (
|
||||
data.map((customerReport) => {
|
||||
const summary = customerReport.summary || {
|
||||
total_qty: 0,
|
||||
total_weight: 0,
|
||||
total_initial_amount: 0,
|
||||
total_credit_note: 0,
|
||||
total_final_amount: 0,
|
||||
total_ppn: 0,
|
||||
total_grand_amount: 0,
|
||||
total_payment: 0,
|
||||
total_accounts_receivable: 0,
|
||||
};
|
||||
|
||||
const totalAccountsReceivable = summary.total_accounts_receivable;
|
||||
const tableColumns = getTableColumns(summary);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={customerReport.customer.id}
|
||||
title={customerReport.customer.name}
|
||||
subtitle={`NPWP: ${customerReport.customer_npwp || '-'} | ${customerReport.customer_address || ''}\nSaldo Piutang: ${formatCurrency(totalAccountsReceivable)}`}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
variant='bordered'
|
||||
collapsible={true}
|
||||
>
|
||||
<Table
|
||||
data={customerReport.rows}
|
||||
columns={tableColumns}
|
||||
pageSize={10}
|
||||
renderFooter={customerReport.rows.length > 0}
|
||||
className={{
|
||||
containerClassName: 'w-full',
|
||||
tableWrapperClassName: 'overflow-x-auto mt-4',
|
||||
tableClassName: 'w-full table-auto text-sm',
|
||||
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||
headerColumnClassName:
|
||||
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
|
||||
bodyRowClassName:
|
||||
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
tableFooterClassName:
|
||||
'bg-gray-100 font-semibold border border-gray-200',
|
||||
footerRowClassName: 'border-t-2 border-gray-300',
|
||||
footerColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Card>
|
||||
{meta && data.length > 0 && (
|
||||
<div className='mt-6'>
|
||||
<Pagination
|
||||
currentPage={meta.page}
|
||||
totalItems={meta.total_results}
|
||||
onPageChange={handlePageChange}
|
||||
onRowChange={handleRowChange}
|
||||
onNextPage={handleNextPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
rowOptions={[10, 25, 50, 100]}
|
||||
itemsPerPage={meta.limit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerPaymentTab;
|
||||
Reference in New Issue
Block a user