diff --git a/src/app/globals.css b/src/app/globals.css index c3d05c67..e50e020d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -48,3 +48,8 @@ html { scrollbar-gutter: initial; } + +.react-select__menu-portal { + position: relative; + z-index: 99999 !important; +} diff --git a/src/app/marketing/sales-orders/add/page.tsx b/src/app/marketing/sales-orders/add/page.tsx index ed193137..e60085ef 100644 --- a/src/app/marketing/sales-orders/add/page.tsx +++ b/src/app/marketing/sales-orders/add/page.tsx @@ -1,7 +1,9 @@ +import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; + const AddSalesOrder = () => { return ( -
-

Tambah Sales Order

+
+
); }; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 958d88dd..ea53d2c9 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -48,6 +48,7 @@ export const useModal = () => { interface ModalProps { ref: RefObject; + id?: string; children?: ReactNode; closeOnBackdrop?: boolean; className?: { @@ -56,7 +57,13 @@ interface ModalProps { }; } -const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { +const Modal = ({ + ref, + id, + children, + closeOnBackdrop, + className, +}: ModalProps) => { const handleBackdropClick = (e: React.MouseEvent) => { if (closeOnBackdrop && e.target === ref.current) { ref.current?.close(); @@ -66,6 +73,7 @@ const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { return ( diff --git a/src/components/input/PatternInput.tsx b/src/components/input/PatternInput.tsx new file mode 100644 index 00000000..9af1b68e --- /dev/null +++ b/src/components/input/PatternInput.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { ChangeEvent } from 'react'; +import { + PatternFormat, + NumberFormatBase, + NumberFormatBaseProps, + OnValueChange, +} from 'react-number-format'; +import TextInput, { TextInputProps } from '@/components/input/TextInput'; + +interface PatternInputProps extends Omit { + /** + * Format pattern, contoh: "##/##/####", "(###) ###-####", "####-####-####" + */ + format: string; + /** Mask karakter kosong, misal "_" */ + mask?: string; + /** Menampilkan mask walau value kosong */ + allowEmptyFormatting?: boolean; + /** Placeholder karakter format, default: "#" */ + patternChar?: string; + /** Jika true, izinkan huruf (A-Z) selain angka */ + inputVehicleNumber?: boolean; + type?: 'text' | 'password' | 'tel'; +} + +/** + * PatternInput – tetap backward-compatible dengan Storybook + * tapi bisa menerima huruf jika `allowCharacters={true}` + */ +const PatternInput = ({ + type = 'text', + format, + mask = '_', + allowEmptyFormatting = false, + patternChar = '#', + inputVehicleNumber = false, + onChange, + ...restProps +}: PatternInputProps) => { + const handleValueChange: OnValueChange = (values, { event }) => { + const newEvent = event as ChangeEvent | undefined; + if (newEvent) { + newEvent.target.value = values.value.toUpperCase(); + onChange?.(newEvent); + } + }; + + if (inputVehicleNumber) { + return ( + { + const clean = value.replace(/[^a-z0-9]/gi, '').toUpperCase(); + + const match = clean.match(/^([A-Z]{0,2})(\d{0,4})([A-Z]{0,3})$/); + if (!match) return clean; + const [, prefix, number, suffix] = match; + return [prefix, number, suffix].filter(Boolean).join(' '); + }} + removeFormatting={(val) => val.replace(/\s+/g, '')} + isValidInputCharacter={(char) => /^[a-z0-9]$/i.test(char)} + getCaretBoundary={(val) => + Array(val.length + 1) + .fill(true) + .map(Boolean) + } + onValueChange={handleValueChange} + /> + ); + } + + return ( + + ); +}; + +export default PatternInput; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 8db939bd..8fa8b555 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -55,6 +55,7 @@ interface SelectInputBaseProps { delay?: number; onInputChange?: (search: string) => void; startAdornment?: ReactNode; + menuPortalTarget?: HTMLElement | null; } interface SelectInputProps extends SelectInputBaseProps { @@ -68,7 +69,7 @@ const animatedComponents = makeAnimated(); const CustomControl = < Option, IsMulti extends boolean, - Group extends GroupBase
); }; + const SalesOrderTable = () => { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const [approveAction, setApproveAction] = useState< + 'approve' | 'reject' | null + >(null); + const [rowSelection, setRowSelection] = useState>({}); + const selectedRowIds = Object.keys(rowSelection).filter( + (id) => rowSelection[id] + ); + + const deleteModal = useModal(); + const confirmationModal = useModal(); + const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value); @@ -91,139 +104,267 @@ const SalesOrderTable = () => { [] ); + const approveClickHandler = () => { + setApproveAction('approve'); + confirmationModal.openModal(); + }; + + const rejectClickHandler = () => { + setApproveAction('reject'); + confirmationModal.openModal(); + }; + + const { + state: tableFilterState, + updateFilter, + toQueryString: getTableFilterToQueryString, + } = useTableFilter({ + initial: { + search: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + return ( -
-
- +
+
+ + +
+ + + +
+
+ ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: 'so_number', + header: 'No. Order', + }, + { + accessorKey: 'so_date', + header: 'Tanggal', + }, + { + accessorKey: 'approval.step_name', + header: 'Status', + }, + { + accessorKey: 'customer.name', + header: 'Customer', + }, + { + accessorKey: 'grand_total', + header: 'Grand Total', + }, + { + accessorKey: 'marketing_products.length', + header: 'Product Details', + cell: (props) => ( +
    + {props.row.original.marketing_products?.map((product) => ( +
  • + {product.product_warehouse.product.name} - Qty:{' '} + {product.qty} +
  • + ))} +
+ ), + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = + props.table.getPaginationRowModel().rows.length; + const currentPageRows = + props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = + currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => {}; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]} + pageSize={pageSize} + page={page} + onPageChange={setPage} + 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-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', }} - search={{ - value: search, - onChange: searchChangeHandler, - placeholder: 'Cari Sales Order', - }} - /> - -
pageSize * (page - 1) + props.row.index + 1, - }, - { - accessorKey: 'so_number', - header: 'No. Order', - }, - { - accessorKey: 'so_date', - header: 'Tanggal', - }, - { - accessorKey: 'approval.step_name', - header: 'Status', - }, - { - accessorKey: 'customer.name', - header: 'Customer', - }, - { - accessorKey: 'grand_total', - header: 'Grand Total', - }, - { - accessorKey: 'marketing_products.length', - header: 'Product Details', - cell: (props) => ( -
    - {props.row.original.marketing_products?.map((product) => ( -
  • - {product.product_warehouse.product.name} - Qty:{' '} - {product.qty} -
  • - ))} -
- ), - }, - { - header: 'Aksi', - cell: (props) => {}, - }, - ]} - pageSize={pageSize} - page={page} - onPageChange={setPage} - 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-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', + - + + + ); }; export default SalesOrderTable; diff --git a/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts b/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts index e69de29b..cc6c5ee5 100644 --- a/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts +++ b/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts @@ -0,0 +1,27 @@ +import * as Yup from 'yup'; +import { MarketingProduct } from '@/types/api/marketing/marketing'; +import { + MarketingProductFormValues, + MarketingProductSchema, +} from './repeater/MarketingProduct.schema'; + +type MarketingSchema = { + customer_id: number | undefined; + so_date: string | undefined; + notes: string | undefined; + marketing_products: MarketingProductFormValues[]; +}; + +export const MarketingSchema: Yup.ObjectSchema = Yup.object({ + customer_id: Yup.number().required('Customer wajib diisi!'), + so_date: Yup.string().required('Tanggal wajib diisi!'), + notes: Yup.string().required('Catatan wajib diisi!'), + marketing_products: Yup.array() + .of(MarketingProductSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), +}); + +export const UpdateMarketingSchema = MarketingSchema; + +export type MarketingFormValues = Yup.InferType; diff --git a/src/components/pages/marketing/sales-orders/form/SalesForm.tsx b/src/components/pages/marketing/sales-orders/form/SalesForm.tsx index e69de29b..ec7a7076 100644 --- a/src/components/pages/marketing/sales-orders/form/SalesForm.tsx +++ b/src/components/pages/marketing/sales-orders/form/SalesForm.tsx @@ -0,0 +1,448 @@ +'use client'; + +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import DateInput from '@/components/input/DateInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import TextArea from '@/components/input/TextArea'; +import Modal, { useModal } from '@/components/Modal'; +import Table from '@/components/Table'; +import { cn, formatCurrency, formatNumber } from '@/lib/helper'; +import { + CreateMarketingPayload, + CreateMarketingProductPayload, + Marketing, + MarketingProduct, +} from '@/types/api/marketing/marketing'; +import { Icon } from '@iconify/react'; +import { useEffect, useState } from 'react'; +import MarketingProductForm from './repeater/MarketingProductForm'; +import CheckboxInput from '@/components/input/CheckboxInput'; +import { Customer } from '@/types/api/master-data/customer'; +import { CustomerApi } from '@/services/api/master-data'; +import { useFormik } from 'formik'; +import { MarketingFormValues, MarketingSchema } from './SalesForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { MarketingApi } from '@/services/api/marketing'; +import { MarketingProductFormValues } from './repeater/MarketingProduct.schema'; + +const SalesForm = ({ + formType = 'add', + initialValues, +}: { + formType?: 'add' | 'edit'; + initialValues?: Marketing; +}) => { + const addProductModal = useModal(); + + const [selectedMarketingProduct, setSelectedMarketingProduct] = + useState(null); + const [marketingProducts, setMarketingProducts] = useState< + MarketingProduct[] + >(initialValues?.marketing_products || []); + const [selectedCustomer, setSelectedCustomer] = useState( + null + ); + const [rowSelection, setRowSelection] = useState>({}); + const selectedRowIds = Object.keys(rowSelection).map((item) => + parseInt(item) + ); + const [grandTotal, setGrandTotal] = useState(0); + + const { + options: customerOptions, + rawData: customerRawData, + isLoadingOptions: isLoadingCustomerOptions, + } = useSelect(CustomerApi.basePath, 'id', 'name'); + + const handleAddProduct = () => { + addProductModal.openModal(); + }; + const handleDeleteProduct = (id: number) => { + setMarketingProducts((prev) => prev.filter((product) => product.id !== id)); + }; + const handleBulkDeleteProduct = () => { + setMarketingProducts((prev) => + prev.filter((product) => !selectedRowIds.includes(product.id)) + ); + }; + const handleAddSubmitProduct = async ( + values: CreateMarketingProductPayload + ) => { + const newMarketingProduct: MarketingProduct = { + id: marketingProducts.length + 1, + product_warehouse: values.product_warehouse!, + unit_price: values.unit_price as number, + total_weight: values.total_weight as number, + qty: values.qty as number, + avg_weight: values.avg_weight as number, + total_price: values.total_price as number, + marketing_delivery_products: { + id: marketingProducts.length + 1, + vehicle_number: values.vehicle_number as string, + delivery_date: values.delivery_date as string, + unit_price: values.unit_price as number, + total_weight: values.total_weight as number, + qty: values.qty as number, + avg_weight: values.avg_weight as number, + total_price: values.total_price as number, + }, + }; + const newMarketingProductPayload: MarketingProductFormValues = { + vehicle_number: values.vehicle_number as string, + kandang_id: values.kandang_id as number, + kandang: { + value: values.kandang_id as number, + label: values.kandang?.name as string, + }, + product_warehouse_id: values.product_warehouse_id as number, + product_warehouse: { + value: values.product_warehouse?.id as number, + label: values.product_warehouse?.product.name as string, + }, + unit_price: values.unit_price, + total_weight: values.total_weight, + qty: values.qty, + uom: values.uom as string, + avg_weight: values.avg_weight, + total_price: values.total_price, + delivery_date: values.delivery_date as string, + }; + setMarketingProducts((prev) => [...prev, newMarketingProduct]); + formik.setValues({ + ...formik.values, + marketing_products: [ + ...formik.values.marketing_products, + newMarketingProductPayload, + ], + }); + setGrandTotal((prev) => prev + (values.total_price as number)); + addProductModal.closeModal(); + }; + const handleChangeCustomer = (val: OptionType | OptionType[] | null) => { + setSelectedCustomer(val as OptionType); + formik.setFieldValue('customer_id', (val as OptionType)?.value); + }; + + const createProjectFlockHandler = async (values: CreateMarketingPayload) => { + console.log(values); + const createMarketingRes = await MarketingApi.create(values); + if (isResponseSuccess(createMarketingRes)) { + console.log(createMarketingRes); + } + if (isResponseError(createMarketingRes)) { + console.log(createMarketingRes); + } + }; + const updateProjectFlockHandler = async (values: CreateMarketingPayload) => { + console.log(values); + const createMarketingRes = await MarketingApi.update( + initialValues?.id as number, + values + ); + if (isResponseSuccess(createMarketingRes)) { + console.log(createMarketingRes); + } + if (isResponseError(createMarketingRes)) { + console.log(createMarketingRes); + } + }; + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + so_date: undefined, + notes: '', + customer_id: initialValues?.customer?.id || undefined, + marketing_products: [], + }, + validationSchema: MarketingSchema, + onSubmit: async (values) => { + const payload = { + customer_id: values.customer_id as number, + date: values.so_date as string, + notes: values.notes as string, + marketing_products: values.marketing_products, + } as CreateMarketingPayload; + switch (formType) { + case 'add': + createProjectFlockHandler(payload); + break; + case 'edit': + updateProjectFlockHandler(payload); + break; + default: + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + useEffect(() => { + formikSetValues(formik.initialValues); + }, [formikSetValues, formik.initialValues]); + + return ( + <> +
+ + +
+ + +
+
+ + + rowSelection={rowSelection} + setRowSelection={setRowSelection} + data={marketingProducts} + columns={[ + { + id: 'select', + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorFn(row) { + return row.marketing_delivery_products?.vehicle_number; + }, + header: 'No. Polisi', + }, + { + accessorFn(row) { + return row.product_warehouse.warehouse.name; + }, + header: 'Kandang', + }, + { + accessorFn(row) { + return row.product_warehouse.product.name; + }, + header: 'Produk', + }, + { + accessorFn(row) { + return formatCurrency(row.unit_price); + }, + header: 'Harga Satuan (Rp)', + }, + { + accessorFn(row) { + return formatNumber(row.total_weight); + }, + header: 'Total Bobot (Kg)', + }, + { + accessorFn(row) { + return formatNumber(row.qty); + }, + header: 'Kuantitas', + }, + { + accessorFn(row) { + return formatNumber(row.avg_weight); + }, + header: 'Avg. Bobot (Kg)', + }, + { + accessorFn(row) { + return formatCurrency(row.total_price); + }, + header: 'Total Penjualan (Rp)', + }, + { + header: 'Aksi', + cell: (props) => { + return ( +
+ +
+ ); + }, + }, + ]} + 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-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-end', + 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={ +
+ Belum ada data penjualan +
+ } + /> +
+ + {selectedRowIds.length > 0 && ( + + )} +
+ {JSON.stringify(formik.errors)} +
+
+