feat(FE-181-179-220): Slicing UI, Client Side Validation and API Integration for Delivery Order

This commit is contained in:
randy-ar
2025-11-20 00:57:07 +07:00
parent 429f2b9109
commit b33e7a1919
24 changed files with 1358 additions and 191 deletions
@@ -0,0 +1,52 @@
'use client';
import SalesForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const EditMarketingDelivery = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<SalesForm
formType='deliver'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
};
export default EditMarketingDelivery;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -12,9 +12,12 @@ const EditSalesOrder = () => {
const soId = searchParams.get('salesOrderId');
const { data: marketing, isLoading: isLoading } = useSWR(
`get-so-${soId}`,
() => MarketingApi.getSingle(soId ? parseInt(soId) : 0)
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
@@ -35,7 +38,13 @@ const EditSalesOrder = () => {
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<SalesForm formType='edit' initialValues={marketing.data} />
<SalesForm
formType='edit'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
+4 -1
View File
@@ -78,7 +78,10 @@ const DateInput = ({
// --- Sync value props ---
useEffect(() => {
if (!value) return;
if (!value) {
setDisplayValue('');
return;
}
if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined;
@@ -56,15 +56,28 @@ const RowsOptionsMenu = ({
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/marketing/sales-orders/detail/edit/?salesOrderId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
{props.row.original.latest_approval.step_number != 1 && (
<Button
href={`/marketing/sales-orders/detail/edit/delivery?salesOrderId=${props.row.original.id}`}
variant='ghost'
color='success'
className='justify-start text-sm'
>
<Icon icon='mdi:truck' width={16} height={16} />
Deliver
</Button>
)}
{props.row.original.latest_approval.step_number != 3 && (
<Button
href={`/marketing/sales-orders/detail/edit?salesOrderId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
)}
<Button
onClick={deleteClickHandler}
variant='ghost'
@@ -160,7 +173,7 @@ const SalesOrderTable = () => {
);
const disableApprove = !hasApprovable || hasRejectable;
const disableReject = !hasRejectable || hasApprovable;
// const disableReject = !hasRejectable || hasApprovable;
const idsToProcess =
approveAction === 'APPROVED'
@@ -224,7 +237,7 @@ const SalesOrderTable = () => {
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const step = row.original.latest_approval?.step_number;
return step === 1 || step === 2;
return step === 1;
};
return (
@@ -258,7 +271,7 @@ const SalesOrderTable = () => {
Approve
</Button>
<Button
{/* <Button
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
@@ -266,7 +279,7 @@ const SalesOrderTable = () => {
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
</Button> */}
</div>
</div>
<Table
@@ -11,13 +11,25 @@ import ApprovalSteps, {
} from '@/components/pages/ApprovalSteps';
import Table from '@/components/Table';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { BaseSalesOrder, Marketing } from '@/types/api/marketing/marketing';
import {
BaseDelivery,
BaseDeliveryOrder,
BaseSalesOrder,
Marketing,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
@@ -28,6 +40,7 @@ const SalesOrderDetail = ({
initialValues?: Marketing;
refresh?: () => void;
}) => {
const router = useRouter();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
@@ -57,10 +70,10 @@ const SalesOrderDetail = ({
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApprovalAction('REJECTED');
confirmationModal.openModal();
};
// const rejectClickHandler = () => {
// setApprovalAction('REJECTED');
// confirmationModal.openModal();
// };
const deliveryClickHandler = () => {
deliveryModal.openModal();
@@ -104,6 +117,9 @@ const SalesOrderDetail = ({
toast.success(res?.message as string);
refresh?.();
refreshApproval?.();
router.push(
`/marketing/sales-orders/detail/edit/delivery?salesOrderId=${initialValues?.id}`
);
};
return (
@@ -127,26 +143,30 @@ const SalesOrderDetail = ({
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
<Button
{/* <Button
color='error'
onClick={rejectClickHandler}
disabled={initialValues?.latest_approval?.step_number != 2}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button>
</Button> */}
</>
)}
{initialValues?.latest_approval?.step_number == 2 && (
<Button color='success' onClick={deliveryClickHandler}>
<Icon icon='mdi:check' width={24} height={24} />
<Button
color='success'
// href={`/marketing/sales-orders/detail/edit/delivery?salesOrderId=${initialValues?.id}`}
onClick={deliveryClickHandler}
>
<Icon icon='mdi:truck' width={24} height={24} />
Delivery Order
</Button>
)}
</div>
<Card
title='Informasi Sales Order'
title='Informasi Penjualan'
className={{
wrapper: 'w-full bg-white',
}}
@@ -159,7 +179,7 @@ const SalesOrderDetail = ({
No. Sales Order
</td>
<td>:</td>
<td width='50%'>{initialValues?.name}</td>
<td width='50%'>{initialValues?.so_number}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Pelanggan</td>
@@ -186,13 +206,23 @@ const SalesOrderDetail = ({
<td>:</td>
<td>{initialValues?.notes ?? '-'}</td>
</tr>
<tr>
<td className='font-semibold'>Dokumen</td>
<td>:</td>
<td>
<Button className='py-2 px-3 font-medium text-md'>
<Icon icon='mdi:file-pdf' width={16} height={16} />
{initialValues?.so_number}
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
{initialValues?.sales_order && (
<Card
title='Daftar Produk'
title='Informasi Produk'
className={{
wrapper: 'w-full bg-white',
}}
@@ -262,6 +292,117 @@ const SalesOrderDetail = ({
/>
</Card>
)}
{initialValues?.delivery_order && (
<Card
title='Informasi Pengiriman'
className={{
wrapper: 'w-full bg-white',
}}
>
{initialValues?.delivery_order.map((delivery, index) => {
return (
<div key={index}>
<Card
className={{
wrapper: 'w-full',
}}
>
<div className='flex flex-row gap-3'>
<div className='font-semibold'>
Nomor DO : {delivery.do_number}
</div>
</div>
<Table<BaseDelivery>
data={delivery.deliveries}
columns={[
{
header: 'Tanggal Pengiriman',
accessorFn() {
return formatDate(
delivery.delivery_date,
'DD MMM yyyy'
);
},
},
{
header: 'No. Polisi',
accessorFn(row) {
return formatVechicleNumber(row.vehicle_number);
},
},
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.sales_order &&
initialValues?.sales_order?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName:
'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
</Card>
<div className='flex flex-row gap-3 my-3'>
<Button className='py-2 px-3 font-medium text-md'>
<Icon icon='mdi:file-pdf' width={16} height={16} />
{delivery.do_number}
</Button>
</div>
</div>
);
})}
</Card>
)}
<div className='flex flex-row gap-3'>
<Button
color='warning'
@@ -3,6 +3,10 @@ import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './repeater/delivery-order/DeliverOrderProduct.schema';
type MarketingSchemaType = {
customer_id: number | undefined;
@@ -22,6 +26,10 @@ type SalesOrderSchemaType = MarketingSchemaType & {
sales_order: SalesOrderProductFormValues[];
};
type DeliveryOrderSchemaType = {
delivery_order: DeliveryOrderProductFormValues[];
};
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
@@ -38,6 +46,16 @@ export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
.required('Produk wajib diisi!'),
});
export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
Yup.object({
delivery_order: Yup.array()
.of(DeliveryOrderProductSchema)
.min(1, 'Pengiriman wajib diisi!')
.required(),
});
export const UpdateSalesOrderSchema = SalesOrderSchema;
export type SalesOrderFormValues = Yup.InferType<typeof SalesOrderSchema>;
export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
@@ -12,19 +12,27 @@ import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
BaseDeliveryOrder,
BaseSalesOrder,
CreateSalesOrderPayload,
CreateSalesOrderProductPayload,
Marketing,
UpdateDeliveryOrderPayload,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { useFormik } from 'formik';
import { SalesOrderFormValues, SalesOrderSchema } from './MarketingForm.schema';
import {
DeliveryOrderFormValues,
DeliveryOrderSchema,
SalesOrderFormValues,
SalesOrderSchema,
} from './MarketingForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
DeliveryOrderApi,
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
@@ -34,6 +42,9 @@ import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import SalesOrderProductTable from './table-view/SalesOrderProductTable';
import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct';
import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema';
const MarketingProductToFieldValues = (
product: BaseSalesOrder
@@ -59,6 +70,49 @@ const MarketingProductToFieldValues = (
};
};
const DeliveryProductToFieldValues = (
salesOrders: BaseSalesOrder[],
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const soId = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
)?.id;
return {
id: soId,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
avg_weight: item.avg_weight,
total_price: item.total_price,
vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number,
marketing_product_id: soId,
marketing_product: {
id: soId,
vehicle_number: item.vehicle_number,
kandang_id: item.product_warehouse.warehouse.id,
kandang: {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
product_warehouse: {
value: item.product_warehouse.id,
label: item.product_warehouse.product.name,
},
product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
avg_weight: item.avg_weight,
total_price: item.total_price,
},
} as DeliveryOrderProductFormValues;
});
return data;
};
const SalesForm = ({
formType = 'add',
initialValues,
@@ -69,24 +123,39 @@ const SalesForm = ({
afterSubmit?: () => void;
}) => {
const router = useRouter();
const addProductModal = useModal();
const deleteModal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<SalesOrderProductFormValues | null>(null);
const [selectedDeliveryProduct, setSelectedDeliveryProduct] =
useState<DeliveryOrderProductFormValues | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
// Repeater Props
const addSOModal = useModal();
const addDOModal = useModal();
const [rowSOSelection, setRowSOSelection] = useState<Record<string, boolean>>(
{}
);
const selectedRowSOIds = Object.keys(rowSOSelection).map((item) =>
parseInt(item)
);
const [rowDOSelection, setRowDOSelection] = useState<Record<string, boolean>>(
{}
);
const selectedRowDOIds = Object.keys(rowDOSelection).map((item) =>
parseInt(item)
);
// End Repeater Props
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const formikInitialValues = useMemo(() => {
const formikInitialValues = useMemo<
SalesOrderFormValues & DeliveryOrderFormValues
>(() => {
return {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
@@ -102,43 +171,69 @@ const SalesForm = ({
initialValues?.sales_order?.map((product) =>
MarketingProductToFieldValues(product)
) ?? [],
delivery_order:
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? [],
};
}, [initialValues]);
const formik = useFormik<SalesOrderFormValues>({
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
enableReinitialize: true,
initialValues: formikInitialValues,
validationSchema: SalesOrderSchema,
validationSchema:
formType === 'deliver' ? DeliveryOrderSchema : SalesOrderSchema,
validateOnMount: true,
onSubmit: async (values) => {
console.log('VALUES');
console.log(values);
const payload = {
customer_id: values.customer_id as number,
sales_person_id: values.sales_person_id as number,
date: formatDate(values.so_date as string, 'yyyy-MM-DD'),
notes: values.notes as string,
marketing_products: values.sales_order.map((product) => {
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload;
const payload =
formType != 'deliver'
? ({
customer_id: values.customer_id as number,
sales_person_id: values.sales_person_id as number,
date: formatDate(values.so_date as string, 'yyyy-MM-DD'),
notes: values.notes as string,
marketing_products: values.sales_order.map((product) => {
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload)
: ({
marketing_id: initialValues?.id as number,
delivery_products: values.delivery_order.map((product) => {
return {
marketing_product_id: product.marketing_product_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
delivery_date: formatDate(
product.delivery_date as string,
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
};
}),
} as UpdateDeliveryOrderPayload);
console.log('PAYLOAD');
console.log(payload);
switch (formType) {
case 'add':
await createMarketingHandler(payload);
await createMarketingHandler(payload as CreateSalesOrderPayload);
break;
case 'edit':
await updateMarketingHandler(payload);
await updateMarketingHandler(payload as CreateSalesOrderPayload);
break;
case 'deliver':
await updateDeliveryHandler(payload as UpdateDeliveryOrderPayload);
break;
default:
break;
@@ -156,6 +251,7 @@ const SalesForm = ({
}, [formik.values.sales_order]);
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
@@ -165,8 +261,11 @@ const SalesForm = ({
if (isResponseError(createMarketingRes)) {
toast.error(createMarketingRes?.message as string);
}
afterSubmit?.();
setIsLoading(false);
};
const updateMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const updateMarketingRes = await SalesOrderApi.update(
initialValues?.id as number,
@@ -174,12 +273,45 @@ const SalesForm = ({
);
if (isResponseSuccess(updateMarketingRes)) {
toast.success(updateMarketingRes?.message as string);
router.push('/marketing/sales-orders');
router.push(
`/marketing/sales-orders/detail?salesOrderId=${initialValues?.id}`
);
}
if (isResponseError(updateMarketingRes)) {
toast.error(updateMarketingRes?.message as string);
}
afterSubmit?.();
setIsLoading(false);
};
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const updateDeliveryRes =
initialValues?.delivery_order && initialValues?.delivery_order.length > 0
? await DeliveryOrderApi.update(initialValues?.id as number, values)
: await DeliveryOrderApi.update(initialValues?.id as number, values);
if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string);
formik.setFieldValue(
'delivery_order',
updateDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
updateDeliveryRes.data?.sales_order,
delivery
)
) ?? []
);
}
if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.error(updateDeliveryRes?.message as string);
}
afterSubmit?.();
setIsLoading(false);
};
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
@@ -199,7 +331,16 @@ const SalesForm = ({
router.push('/marketing/sales-orders');
};
const handleDeleteProduct = useCallback(
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[formik]
);
// Repeater Handle
const handleDeleteSO = useCallback(
(id: number) => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
@@ -209,28 +350,24 @@ const SalesForm = ({
},
[formik]
);
const handleBulkDeleteProduct = useCallback(() => {
const handleBulkDeleteSO = useCallback(() => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter(
(product) => !selectedRowIds.includes(product.id ?? -1)
(product) => !selectedRowSOIds.includes(product.id ?? -1)
)
);
setRowSelection({});
}, [formik, selectedRowIds]);
setRowSOSelection({});
}, [formik, selectedRowSOIds]);
const handleDelete = () => {
deleteModal.openModal();
};
const handleAddProductClick = useCallback(() => {
const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null);
addProductModal.openModal();
}, [addProductModal]);
const handleAddSubmitProduct = useCallback(
addSOModal.openModal();
}, [addSOModal]);
const handleAddSubmitSO = useCallback(
async (values: SalesOrderProductFormValues) => {
const currentProducts = formik.values.sales_order;
const newValues = {
@@ -240,21 +377,84 @@ const SalesForm = ({
formik.setFieldValue('sales_order', [...currentProducts, newValues]);
addProductModal.closeModal();
addSOModal.closeModal();
},
[formik, addProductModal]
[formik, addSOModal]
);
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
const handleDeleteDO = useCallback(
(id: number) => {
const currentProducts = formik.values.delivery_order;
formik.setFieldValue(
'delivery_order',
currentProducts.filter((p) => p.id != id)
);
},
[formik]
);
const handleEditDO = useCallback(
(id: number) => {
const currentProducts = formik.values.delivery_order.find(
(product) => product.id == id
);
setSelectedDeliveryProduct(currentProducts ?? null);
addDOModal.openModal();
},
[formik]
);
const handleBulkDeleteDO = useCallback(() => {
const currentProducts = formik.values.delivery_order;
formik.setFieldValue(
'delivery_order',
currentProducts.filter(
(product) => !selectedRowDOIds.includes(product.id ?? -1)
)
);
setRowDOSelection({});
}, [formik, selectedRowDOIds]);
const handleAddDOClick = useCallback(() => {
setSelectedDeliveryProduct(null);
addDOModal.openModal();
}, [addDOModal]);
const handleAddSubmitDO = useCallback(
async (values: DeliveryOrderProductFormValues) => {
const currentProducts = formik.values.delivery_order;
const newValues = {
...values,
id: values.id ?? Date.now(),
};
formik.setFieldValue('delivery_order', [...currentProducts, newValues]);
addDOModal.closeModal();
},
[formik, addDOModal]
);
const handleUpdateDO = useCallback(
async (id: number, values: DeliveryOrderProductFormValues) => {
formik.setFieldValue(
'delivery_order',
formik.values.delivery_order.map((product) => {
if (product.id === id) {
return {
...product,
...values,
};
}
return product;
})
);
setSelectedDeliveryProduct(null);
addDOModal.closeModal();
},
[formik, addDOModal]
);
// End Repeater Handle
const memoSalesOrder = formik.values.sales_order;
const memoDeliveryOrder = formik.values.delivery_order;
return (
<>
<form
@@ -285,6 +485,7 @@ const SalesForm = ({
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
isDisabled={formType === 'deliver'}
/>
<DateInput
name='so_date'
@@ -294,28 +495,56 @@ const SalesForm = ({
isError={formik.touched.so_date && Boolean(formik.errors.so_date)}
errorMessage={formik.errors.so_date}
placeholder='Pilih Tanggal'
readOnly={formType == 'deliver'}
/>
</div>
</Card>
<Card
title='Daftar Produk'
title='Informasi Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
<div className='text-blue-500'>{JSON.stringify(initialValues)}</div>
{/* <div className='text-blue-500'>{JSON.stringify(initialValues)}</div>
<div className='text-green-500'>{JSON.stringify(formik.values)}</div>
<div className='text-red-500'>{JSON.stringify(formik.errors)}</div>
<div className='text-red-500'>{JSON.stringify(formik.errors)}</div> */}
<SalesOrderProductTable
formType={formType}
data={memoSalesOrder}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
selectedRowIds={selectedRowIds}
onDelete={handleDeleteProduct}
onBulkDelete={handleBulkDeleteProduct}
onAddProductClick={handleAddProductClick}
rowSelection={rowSOSelection}
setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO}
onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick}
/>
</Card>
{formType == 'deliver' &&
initialValues?.sales_order &&
initialValues?.sales_order.length > 0 && (
<Card
title='Informasi Pengiriman'
className={{
wrapper: 'bg-white w-full',
}}
>
{/* {JSON.stringify(memoSalesOrder)}
{JSON.stringify(memoDeliveryOrder)} */}
<DeliveryOrderProductTable
formType={formType}
data={memoDeliveryOrder}
salesOrder={memoSalesOrder}
rowSelection={rowDOSelection}
setRowSelection={setRowDOSelection}
selectedRowIds={selectedRowDOIds}
onDelete={handleDeleteDO}
onEdit={handleEditDO}
onBulkDelete={handleBulkDeleteDO}
onAddProductClick={handleAddDOClick}
/>
</Card>
)}
<div className='grid grid-cols-2 gap-3'>
<TextArea
required
@@ -327,6 +556,7 @@ const SalesForm = ({
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={formType === 'deliver'}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<span>Total Penjualan</span>
@@ -362,7 +592,7 @@ const SalesForm = ({
</div>
)}
<Modal
ref={addProductModal.ref}
ref={addSOModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
@@ -375,20 +605,50 @@ const SalesForm = ({
variant='ghost'
color='error'
className='rounded-full'
onClick={addProductModal.closeModal}
onClick={addSOModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<SalesOrderProductForm
onSubmitForm={handleAddSubmitProduct}
modalRef={addProductModal.ref}
onSubmitForm={handleAddSubmitSO}
initialValues={selectedMarketingProduct ?? undefined}
/>
</div>
</div>
</Modal>
<Modal
ref={addDOModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>
{selectedDeliveryProduct ? 'Edit' : 'Tambah'} Pengiriman
</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addDOModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<DeliveryOrderProductForm
salesOrders={initialValues?.sales_order ?? []}
onSubmitForm={handleAddSubmitDO}
initialValues={selectedDeliveryProduct ?? undefined}
onUpdateForm={handleUpdateDO}
/>
</div>
</div>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
@@ -0,0 +1,60 @@
import * as Yup from 'yup';
import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '../sales-order/SalesOrderProduct.schema';
import { de } from 'react-day-picker/locale';
type DeliveryOrderProductSchemaType = {
id?: number | undefined;
marketing_product_id: number | undefined; // Sales Order ID
marketing_product?: SalesOrderProductFormValues | undefined | null;
unit_price: string | number | undefined;
total_weight: string | number | undefined;
qty: string | number | undefined;
avg_weight: string | number | undefined;
total_price: string | number | undefined;
vehicle_number: string | undefined;
delivery_date: string | undefined;
do_number?: string | undefined | null; // Uncertain
};
export const DeliveryOrderProductSchema: Yup.ObjectSchema<DeliveryOrderProductSchemaType> =
Yup.object({
id: Yup.number(),
marketing_product_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
marketing_product: Yup.object().nullable().optional(),
unit_price: Yup.number()
.min(1, 'Harga Satuan wajib diisi!')
.required('Harga Satuan wajib diisi!'),
total_weight: Yup.number()
.min(1, 'Total Bobot wajib diisi!')
.required('Total Bobot wajib diisi!'),
qty: Yup.number()
.min(1, 'Kuantitas wajib diisi!')
.required('Kuantitas wajib diisi!'),
avg_weight: Yup.number()
.min(0, 'Avg. Bobot wajib diisi!')
.required('Avg. Bobot wajib diisi!'),
total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
delivery_date: Yup.string().required('Tanggal Pengiriman wajib diisi!'),
do_number: Yup.string().nullable().optional(),
});
export type DeliveryOrderProductFormValues = Yup.InferType<
typeof DeliveryOrderProductSchema
>;
// "marketing_product_id": 3,
// "qty": 20,
// "unit_price": 1000,
// "avg_weight": 1.1,
// "total_weight": 220,
// "total_price": 20000,
// "delivery_date": "2025-11-09",
// "vehicle_number": "D 4321 XXX"
@@ -0,0 +1,345 @@
import { useEffect, useState } from 'react';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './DeliverOrderProduct.schema';
import { useFormik } from 'formik';
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import NumberInput from '@/components/input/NumberInput';
import PatternInput from '@/components/input/PatternInput';
import { formatVechicleNumber } from '@/lib/helper';
import DateInput from '@/components/input/DateInput';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { SalesOrderProductFormValues } from '../sales-order/SalesOrderProduct.schema';
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import Badge from '@/components/Badge';
const DeliveryOrderProductForm = ({
salesOrders,
initialValues,
onSubmitForm,
onUpdateForm,
}: {
salesOrders: BaseSalesOrder[];
initialValues?: DeliveryOrderProductFormValues;
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
onUpdateForm?: (
id: number,
value: DeliveryOrderProductFormValues
) => Promise<void>;
}) => {
const [formikErrorMessage, setFormErrorMessage] = useState('');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
delivery_date: initialValues?.delivery_date || undefined,
vehicle_number: initialValues?.vehicle_number || undefined,
marketing_product_id: initialValues?.marketing_product_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
marketing_product: initialValues?.marketing_product || undefined,
},
validationSchema: DeliveryOrderProductSchema,
validateOnBlur: false,
validateOnChange: true,
onSubmit: async (values) => {
setFormErrorMessage('');
if (initialValues?.id) {
await onUpdateForm?.(initialValues.id, values);
} else {
await onSubmitForm?.(values);
}
handleResetForm();
},
});
const handleResetForm = () => {
setFormErrorMessage('');
formik.resetForm({
values: {
delivery_date: '',
vehicle_number: '',
marketing_product_id: undefined,
unit_price: '',
total_weight: '',
qty: '',
avg_weight: '',
total_price: '',
marketing_product: undefined,
},
});
setSelectedProduct(null);
};
const handleBlurField = (field: string) => {
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue('total_price', Number(qty) * Number(unit_price));
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue('unit_price', Number(total_price) / Number(qty));
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight));
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
}
}
};
const MarketingProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
id: product.id,
vehicle_number: product.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
avg_weight: product.avg_weight,
total_price: product.total_price,
};
};
const options = salesOrders.map((item) => ({
value: item.id,
label: `${item.product_warehouse.product.name} - ${item.product_warehouse.warehouse.name}`,
}));
const { setValues: setFormikValues } = formik;
useEffect(() => {
if (initialValues) {
setFormikValues(initialValues);
const value = salesOrders.find(
(item) => item.id === initialValues.marketing_product_id
);
setSelectedProduct({
value: value?.id,
label: `${value?.product_warehouse.product.name} - ${value?.product_warehouse.warehouse.name}`,
} as OptionType);
}
}, [initialValues]);
return (
<>
<form
className='size-full'
onSubmit={formik.handleSubmit}
onReset={handleResetForm}
>
{/* <small className='block text-blue-500'>
{JSON.stringify(initialValues)}
</small>
<small className='block text-red-500'>
{JSON.stringify(formik.errors)}
</small>
<small className='block text-emerald-500'>
{JSON.stringify(formik.values)}
</small>
<div className='hidden'>
{JSON.stringify(formik.values.marketing_product)}
</div> */}
{formikErrorMessage && (
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
<Alert color='error'>{formikErrorMessage}</Alert>
</div>
)}
<div className='grid grid-cols-2 gap-4'>
<DateInput
name='delivery_date'
label='Tanggal'
value={formik.values.delivery_date}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.delivery_date &&
Boolean(formik.errors.delivery_date)
}
errorMessage={formik.errors.delivery_date}
placeholder='Pilih Tanggal'
required
/>
<PatternInput
name='vehicle_number'
label='No. Polisi'
format='AA #### AAA'
mask='_'
inputVehicleNumber
required
type='text'
placeholder='B 1234 CDE'
value={formatVechicleNumber(formik.values.vehicle_number ?? '')}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number}
/>
<SelectInput
options={options}
label='Produk'
placeholder='Pilih Produk'
value={selectedProduct}
onChange={(value) => {
const selected = value as OptionType;
setSelectedProduct(selected);
const so = salesOrders.find(
(item) => item.id === selected?.value
);
if (!so) {
formik.setValues({
...formik.values,
marketing_product_id: undefined,
marketing_product: null,
qty: formik.values.qty || '',
unit_price: '',
total_price: '',
avg_weight: '',
total_weight: '',
vehicle_number: '',
});
return;
}
formik.setValues({
...formik.values,
marketing_product_id: selected.value as number,
marketing_product: MarketingProductToFieldValues(so),
qty: formik.values.qty || so.qty,
unit_price: so.unit_price,
total_price: so.total_price,
avg_weight: so.avg_weight,
total_weight: so.total_weight,
vehicle_number: so.vehicle_number,
});
}}
startAdornment={
selectedProduct && (
<Badge
variant='soft'
color='success'
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
{
salesOrders.find(
(item) => item.id === selectedProduct?.value
)?.product_warehouse?.warehouse?.name
}
</Badge>
)
}
isClearable
isError={Boolean(formik.errors.marketing_product_id)}
errorMessage={formik.errors.marketing_product_id}
required
/>
<NumberInput
required
label='Kuantitas'
name='qty'
value={formik.values.qty}
onChange={formik.handleChange}
onBlur={() => handleBlurField('qty')}
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={formik.handleChange}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={formik.handleChange}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Total Bobot (Kg)'
name='total_weight'
value={formik.values.total_weight}
onChange={formik.handleChange}
onBlur={() => handleBlurField('total_weight')}
isError={Boolean(formik.errors.total_weight)}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
/>
<NumberInput
required
label='Total Penjualan (Rp)'
name='total_price'
value={formik.values.total_price}
onChange={formik.handleChange}
onBlur={() => handleBlurField('total_price')}
isError={Boolean(formik.errors.total_price)}
errorMessage={formik.errors.total_price}
placeholder='Masukan Total Penjualan'
/>
</div>
<div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'>
Reset
</Button>
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
</>
);
};
export default DeliveryOrderProductForm;
@@ -0,0 +1,233 @@
import Table from '@/components/Table';
import { DeliveryOrderProductFormValues } from '../repeater/delivery-order/DeliverOrderProduct.schema';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import * as TanStack from '@tanstack/react-table';
import { useMemo, useRef } from 'react';
import CheckboxInput from '@/components/input/CheckboxInput';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { SalesOrderProductFormValues } from '../repeater/sales-order/SalesOrderProduct.schema';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
salesOrder: SalesOrderProductFormValues[];
formType?: 'add' | 'edit' | 'deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
selectedRowIds: number[];
onDelete: (id: number) => void;
onEdit: (id: number) => void;
onBulkDelete: () => void;
onAddProductClick: () => void;
};
const DeliveryOrderProductTable = ({
data,
salesOrder,
formType,
rowSelection,
setRowSelection,
selectedRowIds,
onDelete,
onEdit,
onBulkDelete,
onAddProductClick,
}: DeliveryOrderProductTableProps) => {
const onDeleteRef = useRef(onDelete);
const onEditRef = useRef(onDelete);
onDeleteRef.current = onDelete;
onEditRef.current = onEdit;
const canAddData = salesOrder.reduce((acc, curr) => {
const deliveredQty = data.filter(
(deliveryItem) => deliveryItem.marketing_product_id == curr.id
);
return acc && deliveredQty.length != salesOrder.length;
}, true);
const columns = useMemo(
() => [
{
id: 'select',
header: ({
table,
}: {
table: TanStack.Table<DeliveryOrderProductFormValues>;
}) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({
row,
}: {
row: TanStack.Row<DeliveryOrderProductFormValues>;
}) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.do_number,
header: 'No. Pengiriman',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => props.row.original.do_number ?? '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatDate(row.delivery_date as string, 'DD MMM YYYY'),
header: 'Tanggal Delivery',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatVechicleNumber(row.vehicle_number as string),
header: 'No. Polisi',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.marketing_product?.kandang?.label,
header: 'Kandang',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.marketing_product?.product_warehouse?.label,
header: 'Produk',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatCurrency(parseFloat(row.unit_price as string)),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string)),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)),
header: 'Kuantitas',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string)),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatCurrency(parseFloat(row.total_price as string)),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='success'
className='p-1'
onClick={() => onEditRef.current(props.row.original.id as number)}
type='button'
>
<Icon icon='mdi:edit' width={16} height={16} />
</Button>
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
),
},
],
[]
);
return (
<>
<Table<DeliveryOrderProductFormValues>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={data}
columns={columns}
className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-2 py-2 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
paginationClassName: 'hidden',
}}
emptyContent={
<div
className={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data pengiriman</span>
</div>
}
/>
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
disabled={!canAddData}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Pengiriman
</Button>
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Pengiriman
</Button>
)}
</div>
</>
);
};
export default DeliveryOrderProductTable;
@@ -16,6 +16,7 @@ import CheckboxInput from '@/components/input/CheckboxInput';
type SalesOrderProductTableProps = {
data: SalesOrderProductFormValues[];
formType?: 'add' | 'edit' | 'deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
@@ -28,6 +29,7 @@ type SalesOrderProductTableProps = {
const SalesOrderProductTable = ({
data,
formType,
rowSelection,
setRowSelection,
selectedRowIds,
@@ -137,7 +139,13 @@ const SalesOrderProductTable = ({
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={data}
columns={columns}
columns={
formType == 'deliver'
? columns.filter(
(col) => col.header != 'Aksi' && col.id != 'select'
)
: columns
}
className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
@@ -159,33 +167,35 @@ const SalesOrderProductTable = ({
</div>
}
/>
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Produk
</Button>
{selectedRowIds.length > 0 && (
{formType != 'deliver' && (
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
onClick={onAddProductClick}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Produk
</Button>
)}
</div>
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
</Button>
)}
</div>
)}
</>
);
};
@@ -20,7 +20,6 @@ import { Icon } from '@iconify/react';
import { CellContext, SortingState } from '@tanstack/react-table';
import { useState } from 'react';
import useSWR from 'swr';
import ChickinForm from './form/ChickinForm';
const ChickinTable = () => {
const {
@@ -8,8 +8,6 @@ import {
ChickinSchema,
} from '../ChickinForm.schema';
import DateInput from '@/components/input/DateInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button';
import { useCallback, useEffect, useState } from 'react';
import { useFormik } from 'formik';
@@ -24,7 +22,6 @@ import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper';
const ChickinFormView = ({
formType = 'add',
initialValues,
afterSubmit,
}: {
@@ -122,7 +119,7 @@ const ChickinFormView = ({
return (
<form
className='flex flex-col gap-4'
onReset={(e) => {
onReset={() => {
handleReset();
}}
onSubmit={formik.handleSubmit}
@@ -14,7 +14,6 @@ import { cn } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
@@ -42,10 +41,7 @@ const ProjectFlockChickinDetail = ({
const [projectFlock, setProjectFlock] = useState<ProjectFlock>();
// Fetch Data
const {
data: listProjectFlockKandang,
isLoading: isLoadingListProjectFlockKandang,
} = useSWR(
const { data: listProjectFlockKandang } = useSWR(
`${ProjectFlockKandangApi.basePath}?${new URLSearchParams({
search: searchProjectFlock,
project_flock_id:
@@ -42,11 +42,6 @@ export const ProjectFlockFormSchema = Yup.object({
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'),
period: Yup.number()
.required('Periode wajib diisi!')
.typeError('Periode harus berupa angka')
.min(1, 'Minimal periode adalah 1'),
kandang_ids: Yup.array()
.of(Yup.number().typeError('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
@@ -29,7 +29,6 @@ import {
ProjectFlock,
} from '@/types/api/production/project-flock';
import toast from 'react-hot-toast';
import TextInput from '@/components/input/TextInput';
import { Kandang } from '@/types/api/master-data/kandang';
import Collapse from '@/components/Collapse';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
@@ -143,15 +142,14 @@ const ProjectFlockForm = ({
mutate: refreshKandang,
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, isLoading: isLoadingPeriodFlocks } = useSWR(
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`,
(id: string) => ProjectFlockApi.getNextPeriod(id)
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
);
const {
approvals,
isLoading: approvalsLoading,
rawDataApprovals: rawDataApprovals,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: initialValues?.approval,
@@ -182,6 +180,7 @@ const ProjectFlockForm = ({
formik.setFieldValue('kandang_ids', selectedRowIds);
}
}
refreshPeriodFlocks();
}
}, [kandang, selectedLocation]);
useEffect(() => {
@@ -319,7 +318,6 @@ const ProjectFlockForm = ({
>,
fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
period: initialValues?.period ?? 1,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number
| undefined
@@ -370,7 +368,6 @@ const ProjectFlockForm = ({
>,
fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
period: initialValues?.period ?? 1,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number
| undefined
@@ -390,7 +387,6 @@ const ProjectFlockForm = ({
category: values.category as string,
fcr_id: values.fcr_id as number,
location_id: values.location_id as number,
period: values.period as number,
kandang_ids: values.kandang_ids as number[],
};
@@ -419,8 +415,6 @@ const ProjectFlockForm = ({
if (initialValues?.area_id) {
setSelectedArea(initialValues?.area_id.toString() as string);
}
formik.setFieldValue('period', initialValues?.period);
}
}, [initialValues, setSelectedArea, formType]);
@@ -449,15 +443,6 @@ const ProjectFlockForm = ({
formik.validateForm();
}, [formik.values]);
useEffect(() => {
if (isResponseSuccess(periodFlocks)) {
formik.setFieldValue('period', periodFlocks.data.next_period);
}
if (isResponseError(periodFlocks)) {
console.log(periodFlocks?.message as string);
}
}, [periodFlocks]);
useEffect(() => {
const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id])
@@ -555,7 +540,7 @@ const ProjectFlockForm = ({
</div>
</div>
)}
{approvals && !approvalsLoading && (
{approvals && !approvalsLoading && formType == 'detail' && (
<ApprovalSteps approvals={approvals} />
)}
{formType == 'detail' && (
@@ -701,22 +686,6 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType === 'detail'}
/>
<TextInput
required
type='number'
name='period'
label='Periode'
placeholder='Masukkan periode yang project'
value={formik.values.period ?? (1 as number)}
onChange={formik.handleChange}
isError={
formik.touched.period && Boolean(formik.errors.period)
}
errorMessage={formik.errors.period as string}
readOnly={formType === 'detail'}
disabled={true}
isLoading={isLoadingPeriodFlocks}
/>
</div>
</div>
</div>
@@ -750,6 +719,9 @@ const ProjectFlockForm = ({
<span className='loading loading-dots loading-xl'></span>
)}
<ProjectFlockKandangTable
listPeriods={
isResponseSuccess(periodFlocks) ? periodFlocks.data : []
}
listKandang={optionsKandang}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
@@ -5,10 +5,12 @@ import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { cn } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockPeriods } from '@/types/api/production/project-flock';
import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react';
const ProjectFlockKandangTable = ({
listPeriods,
listKandang,
rowSelection,
setRowSelection,
@@ -16,6 +18,7 @@ const ProjectFlockKandangTable = ({
initialValues,
formType = 'add',
}: {
listPeriods: ProjectFlockPeriods;
listKandang: Kandang[];
rowSelection: Record<string, boolean>;
setRowSelection: OnChangeFn<Record<string, boolean>>;
@@ -134,6 +137,19 @@ const ProjectFlockKandangTable = ({
accessorFn: (row) => row.capacity,
header: 'Kapasitas',
},
{
accessorFn: (row) => row.location?.name,
header: 'Periode',
cell: (props) => {
console.log('listPeriods');
console.log(listPeriods);
const period =
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
return period?.period ?? '-';
},
},
{
accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab',
+6 -3
View File
@@ -277,7 +277,8 @@ export const dummyMarketings: Marketing[] = [
{
id: 1,
status: 'DRAFT',
name: 'SO-001-2025',
// name: 'SO-001-2025', // `name` is not part of BaseMarketing
so_number: 'SO-001-2025',
so_date: today,
customer: {
id: 1,
@@ -313,7 +314,8 @@ export const dummyMarketings: Marketing[] = [
{
id: 2,
status: 'APPROVED',
name: 'SO-002-2025',
// name: 'SO-002-2025', // `name` is not part of BaseMarketing
so_number: 'SO-002-2025',
so_date: today,
customer: {
id: 2,
@@ -350,7 +352,8 @@ export const dummyMarketings: Marketing[] = [
{
id: 3,
status: 'DELIVERED', // Asumsi status DELIVERED berarti DO sudah selesai/terbuat
name: 'SO-003-2025',
// name: 'SO-003-2025', // `name` is not part of BaseMarketing
so_number: 'SO-003-2025',
so_date: today,
customer: {
id: 3,
-1
View File
@@ -2,7 +2,6 @@ import moment from 'moment';
import 'moment/locale/id';
import { twMerge } from 'tailwind-merge';
import clsx, { ClassValue } from 'clsx';
import { ChangeEvent } from 'react';
// set locale globally
moment.locale('id');
+8 -1
View File
@@ -7,6 +7,8 @@ import {
Marketing,
CreateSalesOrderPayload,
UpdateSalesOrderPayload,
CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload,
} from '@/types/api/marketing/marketing';
/**
@@ -120,7 +122,7 @@ export class SalesOrderService extends BaseApiService<
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> {
try {
const path = `${this.basePath}/deliveries`;
const path = `${this.basePath}/approvals`;
return await httpClient<BaseApiResponse<{ message: string }>>(path, {
method: 'POST',
body: {
@@ -137,6 +139,11 @@ export class SalesOrderService extends BaseApiService<
}
export const SalesOrderApi = new SalesOrderService('/marketing/sales-orders');
export const DeliveryOrderApi = new BaseApiService<
Marketing,
CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload
>('/marketing/delivery-orders');
export const MarketingApi = new BaseApiService<Marketing, unknown, unknown>(
'/marketing'
);
+23 -17
View File
@@ -8,13 +8,10 @@ import {
BaseApiResponse,
BaseGroupedApproval,
ErrorApiResponse,
GroupedApprovals,
SuccessApiResponse,
} from '@/types/api/api-general';
import { sleep } from '@/lib/helper';
import { httpClient } from '@/services/http/client';
import axios from 'axios';
import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang';
import { RequestOptions } from '@/services/http/base';
@@ -104,25 +101,34 @@ export class ProjectFlockService extends BaseApiService<
/**
* Get Next Period of Project Flock
*/
async getNextPeriod(id: string): Promise<
| BaseApiResponse<{
flock: Flock;
next_period: number;
}>
async getNextPeriod(locationId: number): Promise<
| BaseApiResponse<
{
id: number;
name: string;
period: number;
}[]
>
| ErrorApiResponse
| SuccessApiResponse<{
flock: Flock;
next_period: number;
}>
| SuccessApiResponse<
{
id: number;
name: string;
period: number;
}[]
>
| undefined
> {
try {
const path = `${this.basePath}/kandangs/${id}`;
const path = `${this.basePath}/kandangs/${locationId.toString()}/periods`;
return await httpClient<
SuccessApiResponse<{
flock: Flock;
next_period: number;
}>
SuccessApiResponse<
{
id: number;
name: string;
period: number;
}[]
>
>(path, {
method: 'GET',
});
+19 -2
View File
@@ -15,9 +15,9 @@ import { Warehouse } from '../master-data/warehouse';
export type BaseMarketing = {
id: number;
status?: string;
name: string;
customer: Customer;
so_number: string;
so_date: string;
customer: Customer;
sales_person: CreatedUser;
notes: string;
latest_approval: BaseApproval;
@@ -118,6 +118,23 @@ export type CreateSalesOrderProductPayload =
product_warehouse?: ProductWarehouse | undefined;
};
export type CreateDeliveryOrderPayload = {
marketing_id?: number;
delivery_products: CreateDeliveryOrderProductPayload[];
};
export type CreateDeliveryOrderProductPayload =
BaseCreateMarketingProductPayload & {
id?: number;
marketing_product_id: number;
delivery_date: string;
};
export type UpdateSalesOrderProductPayload = CreateSalesOrderProductPayload;
export type UpdateDeliveryOrderProductPayload =
CreateDeliveryOrderProductPayload;
export type UpdateSalesOrderPayload = CreateSalesOrderPayload;
export type UpdateDeliveryOrderPayload = CreateDeliveryOrderPayload;
+6 -1
View File
@@ -41,7 +41,6 @@ export type CreateProjectFlockPayload = {
category: string;
fcr_id: number;
location_id: number;
period: number;
kandang_ids: number[];
};
@@ -61,3 +60,9 @@ export type ProjectFlockAvailableQuantity = {
available_qty: number;
}[];
};
export type ProjectFlockPeriods = {
id: number;
name: string;
period: number;
}[];