Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy

This commit is contained in:
randy-ar
2026-01-10 08:10:18 +07:00
110 changed files with 18387 additions and 816 deletions
@@ -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 &gt; 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;