From 7ceb25ea71dc421b68bcc24a7d006a16e23c52ee Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 8 Oct 2025 15:26:45 +0700 Subject: [PATCH 001/212] feat(FE-62,65): add inventory movement management with API and form validation --- .../movement/form/MovementForm.schema.ts | 67 +++++ src/config/constant.ts | 241 ++++++++++-------- src/services/api/inventory.ts | 12 + src/types/api/inventory/movement.d.ts | 51 ++++ 4 files changed, 262 insertions(+), 109 deletions(-) create mode 100644 src/components/pages/inventory/movement/form/MovementForm.schema.ts create mode 100644 src/services/api/inventory.ts create mode 100644 src/types/api/inventory/movement.d.ts diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts new file mode 100644 index 00000000..cdabe355 --- /dev/null +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -0,0 +1,67 @@ +import * as Yup from 'yup'; + +export const MovementFormSchema = Yup.object({ + alasan_transfer: Yup.string() + .required('Alasan Transfer wajib diisi!'), + tanggal_transfer: Yup.date() + .required('Tanggal Transfer wajib diisi!') + .typeError('Tanggal Transfer tidak valid!'), + warehouse_asal: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + warehouse_asal_id: Yup.number() + .required('Gudang Asal wajib diisi!'), + warehouse_tujuan: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + warehouse_tujuan_id: Yup.number() + .required('Gudang Tujuan wajib diisi!'), + alasan: Yup.string() + .required('Alasan wajib diisi!'), + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.array() + .of(Yup.number()).min(1, 'Pilih minimal 1 produk') + .required('Produk wajib diisi!'), + qty_product: Yup.array() + .of(Yup.number().min(1, 'Kuantitas minimal 1')) + .min(1, 'Pilih minimal 1 produk') + .required('Kuantitas wajib diisi!'), + ekspedisi: Yup.array().of( + Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number() + .required('Produk wajib diisi!'), + qty: Yup.number().min(1, 'Kuantitas minimal 1') + .required('Kuantitas wajib diisi!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number() + .required('Supplier wajib diisi!'), + plat_nomor: Yup.string() + .required('Plat Nomor wajib diisi!'), + no_surat_jalan: Yup.string() + .required('No Surat Jalan wajib diisi!'), + dokumen: Yup.mixed() + .required('Dokumen wajib diisi!'), + biaya_ekspedisi: Yup.number() + .min(0, 'Biaya Ekspedisi minimal 0') + .required('Biaya Ekspedisi wajib diisi!'), + nama_sopir: Yup.string() + .required('Nama Sopir wajib diisi!'), + }) + ).min(1, 'Pilih minimal 1 ekspedisi').required('Ekspedisi wajib diisi!'), +}); + +export const UpdateMovementFormSchema = MovementFormSchema; + +export type MovementFormValues = Yup.InferType; \ No newline at end of file diff --git a/src/config/constant.ts b/src/config/constant.ts index 1fbef81f..870002bf 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -1,122 +1,145 @@ export const MAIN_DRAWER_LINKS = [ - { - title: 'Dashboard', - link: '/dashboard', - icon: 'gg:chart', - }, + { + title: 'Dashboard', + link: '/dashboard', + icon: 'gg:chart', + }, - { - title: 'Master Data', - link: '/master-data', - icon: 'majesticons:data-line', - submenu: [ - { - title: 'Product', - link: '/master-data/product', - icon: 'fluent-mdl2:product-variant', - }, - { - title: 'Product Category', - link: '/master-data/product-category', - icon: 'carbon:categories', - }, - { - title: 'Bank', - link: '/master-data/bank', - icon: 'mdi:bank-outline', - }, - { - title: 'Area', - link: '/master-data/area', - icon: 'majesticons:map-marker-area-line', - }, - { - title: 'Location', - link: '/master-data/location', - icon: 'mingcute:location-line', - }, - { - title: 'Kandang', - link: '/master-data/kandang', - icon: 'mdi:farm-home-outline', - }, - { - title: 'Warehouse', - link: '/master-data/warehouse', - icon: 'hugeicons:warehouse', - }, - { - title: 'Customer', - link: '/master-data/customer', - icon: 'ix:customer', - }, - { - title: 'UOM', - link: '/master-data/uom', - icon: 'lsicon:measure-outline', - }, - { - title: 'Non-Stock', - link: '/master-data/nonstock', - icon: 'fluent:box-32-regular', - }, - { - title: 'FCR', - link: '/master-data/FCR', - icon: 'fluent:food-chicken-leg-16-regular', - }, - { - title: 'Supplier', - link: '/master-data/supplier', - icon: 'material-symbols:add-business-outline-rounded', - }, - ], - }, + { + title: 'Persediaan', + link: '/inventory', + icon: 'mdi:warehouse', + submenu: [ + { + title: 'Product', + link: '/inventory/product', + icon: 'mdi:package-variant-closed', + }, + { + title: 'Penyesuaian Stok', + link: '/inventory/adjustment', + icon: 'mdi:database-edit', + }, + { + title: 'Transfer Stok', + link: '/inventory/movement', + icon: 'mdi:swap-horizontal', + }, + ], + }, + + { + title: 'Master Data', + link: '/master-data', + icon: 'majesticons:data-line', + submenu: [ + { + title: 'Product', + link: '/master-data/product', + icon: 'fluent-mdl2:product-variant', + }, + { + title: 'Product Category', + link: '/master-data/product-category', + icon: 'carbon:categories', + }, + { + title: 'Bank', + link: '/master-data/bank', + icon: 'mdi:bank-outline', + }, + { + title: 'Area', + link: '/master-data/area', + icon: 'majesticons:map-marker-area-line', + }, + { + title: 'Location', + link: '/master-data/location', + icon: 'mingcute:location-line', + }, + { + title: 'Kandang', + link: '/master-data/kandang', + icon: 'mdi:farm-home-outline', + }, + { + title: 'Warehouse', + link: '/master-data/warehouse', + icon: 'hugeicons:warehouse', + }, + { + title: 'Customer', + link: '/master-data/customer', + icon: 'ix:customer', + }, + { + title: 'UOM', + link: '/master-data/uom', + icon: 'lsicon:measure-outline', + }, + { + title: 'Non-Stock', + link: '/master-data/nonstock', + icon: 'fluent:box-32-regular', + }, + { + title: 'FCR', + link: '/master-data/FCR', + icon: 'fluent:food-chicken-leg-16-regular', + }, + { + title: 'Supplier', + link: '/master-data/supplier', + icon: 'material-symbols:add-business-outline-rounded', + }, + ], + }, ] as const; export const ROWS_OPTIONS = [ - { - label: '10', - value: 10, - }, - { - label: '20', - value: 20, - }, - { - label: '50', - value: 50, - }, - { - label: '100', - value: 100, - }, + { + label: '10', + value: 10, + }, + { + label: '20', + value: 20, + }, + { + label: '50', + value: 50, + }, + { + label: '100', + value: 100, + }, ]; export const WAREHOUSE_TYPE_OPTIONS = [ - { - label: 'AREA', - value: 'AREA', - }, - { - label: 'LOKASI', - value: 'LOKASI', - }, - { - label: 'KANDANG', - value: 'KANDANG', - }, + { + label: 'AREA', + value: 'AREA', + }, + { + label: 'LOKASI', + value: 'LOKASI', + }, + { + label: 'KANDANG', + value: 'KANDANG', + }, ]; export const PRODUCT_FLAG_OPTIONS = [ - { label: 'DOC', value: 'DOC' }, - { label: 'PAKAN', value: 'PAKAN' }, - { label: 'PRE-STARTER', value: 'PRE-STARTER' }, - { label: 'STARTER', value: 'STARTER' }, - { label: 'FINISHER', value: 'FINISHER' }, - { label: 'OVK', value: 'OVK' }, - { label: 'OBAT', value: 'OBAT' }, - { label: 'VITAMIN', value: 'VITAMIN' }, - { label: 'KIMIA', value: 'KIMIA' }, + {label: 'DOC', value: 'DOC'}, + {label: 'PAKAN', value: 'PAKAN'}, + {label: 'PRE-STARTER', value: 'PRE-STARTER'}, + {label: 'STARTER', value: 'STARTER'}, + {label: 'FINISHER', value: 'FINISHER'}, + {label: 'OVK', value: 'OVK'}, + {label: 'OBAT', value: 'OBAT'}, + {label: 'VITAMIN', value: 'VITAMIN'}, + {label: 'KIMIA', value: 'KIMIA'}, ]; diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts new file mode 100644 index 00000000..3e3115e2 --- /dev/null +++ b/src/services/api/inventory.ts @@ -0,0 +1,12 @@ +import { + CreateMovementPayload, + Movement, + UpdateMovementPayload, +} from "@/types/api/inventory/movement"; +import {BaseApiService} from "@/services/api/base"; + +export const MovementApi = new BaseApiService< + Movement, + CreateMovementPayload, + UpdateMovementPayload +>('/inventory/movements'); \ No newline at end of file diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts new file mode 100644 index 00000000..fe2996ba --- /dev/null +++ b/src/types/api/inventory/movement.d.ts @@ -0,0 +1,51 @@ +import {BaseMetadata} from '@/types/api/api-general'; +import {Product} from "@/types/api/master-data/product"; +import {Supplier} from "@/types/api/master-data/supplier"; +import {Warehouse} from "@/types/api/master-data/warehouse"; + +export type BaseMovement = { + id: number; + alasan_transfer: string; + tanggal_transfer: string; + warehouse_asal: Warehouse; + warehouse_tujuan: Warehouse; + product: Array<{ + product: Product; + qty_product: number; + }>; + ekspedisi: Array<{ + product_id: number; + qty: number; + supplier: Supplier; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string; + biaya_ekspedisi: number; + nama_sopir: string; + }>; + name: string; +}; + +export type Movement = BaseMetadata & BaseMovement; + +export type CreateMovementPayload = { + alasan: string; + warehouse_asal_id: number; + warehouse_tujuan_id: number; + product: Array<{ + product_id: number; + qty_product: number; + }>; + ekspedisi: Array<{ + product_id: number; + qty: number; + supplier_id: number; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string; + biaya_ekspedisi: number; + nama_sopir: string; + }>; +} + +export type UpdateMovementPayload = CreateMovementPayload; \ No newline at end of file From 3f97ec45f8b14d8c77347ad5104540c647b4d926 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 8 Oct 2025 16:02:52 +0700 Subject: [PATCH 002/212] feat(FE-64): add MovementTable component for inventory movement management --- src/app/inventory/movement/page.tsx | 11 + .../inventory/movement/MovementTable.tsx | 651 ++++++++++++++++++ 2 files changed, 662 insertions(+) create mode 100644 src/app/inventory/movement/page.tsx create mode 100644 src/components/pages/inventory/movement/MovementTable.tsx diff --git a/src/app/inventory/movement/page.tsx b/src/app/inventory/movement/page.tsx new file mode 100644 index 00000000..12fe795b --- /dev/null +++ b/src/app/inventory/movement/page.tsx @@ -0,0 +1,11 @@ +import MovementTable from '@/components/pages/inventory/movement/MovementTable'; + +const Product = () => { + return ( +
+ +
+ ); +}; + +export default Product; diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx new file mode 100644 index 00000000..288cb2a6 --- /dev/null +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -0,0 +1,651 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import { cn } from '@/lib/helper'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { Movement } from '@/types/api/inventory/movement'; +import { BaseMetadata } from '@/types/api/api-general'; + +// Dummy data +const baseMetadata: BaseMetadata = { + created_user: { + id: 1, + id_user: 1, + email: 'user@example.com', + name: 'User', + }, + created_at: '2024-06-01T00:00:00Z', + updated_at: '2024-06-01T00:00:00Z', +}; + +const dummyMovements: Movement[] = [ + { + ...baseMetadata, + id: 1, + alasan_transfer: 'Restock', + tanggal_transfer: '2024-06-01', + warehouse_asal: { + ...baseMetadata, + id: 1, + name: 'Warehouse A', + type: 'AREA', + area: { id: 1, name: 'Area 1' }, + }, + warehouse_tujuan: { + ...baseMetadata, + id: 2, + name: 'Warehouse B', + type: 'AREA', + area: { id: 2, name: 'Area 2' }, + }, + product: [ + { + product: { + ...baseMetadata, + id: 1, + name: 'Product X', + brand: 'Brand X', + sku: 'SKU-X', + product_price: 10000, + selling_price: 12000, + tax: 10, + expiry_period: 365, + uom: { + ...baseMetadata, + id: 1, + name: 'PCS', + }, + product_category: { + ...baseMetadata, + id: 1, + code: 'CAT-1', + name: 'Category 1', + }, + suppliers: [], + flags: [], + }, + qty_product: 10, + }, + ], + ekspedisi: [ + { + product_id: 1, + qty: 10, + supplier: { + ...baseMetadata, + id: 1, + name: 'Supplier 1', + alias: 'S1', + category: 'General', + pic: 'PIC 1', + type: 'Type 1', + phone: '08123456789', + email: 'supplier1@example.com', + address: 'Address 1', + account_number: '1234567890', + balance: 0, + due_date: 30, + }, + plat_nomor: 'B 1234 CD', + no_surat_jalan: 'SJ-001', + dokumen: 'doc1.pdf', + biaya_ekspedisi: 50000, + nama_sopir: 'Andi', + }, + ], + name: 'Movement 1', + }, + { + ...baseMetadata, + id: 2, + alasan_transfer: 'Mutasi Stok', + tanggal_transfer: '2024-06-02', + warehouse_asal: { + ...baseMetadata, + id: 2, + name: 'Warehouse B', + type: 'AREA', + area: { id: 2, name: 'Area 2' }, + }, + warehouse_tujuan: { + ...baseMetadata, + id: 3, + name: 'Warehouse C', + type: 'AREA', + area: { id: 3, name: 'Area 3' }, + }, + product: [ + { + product: { + ...baseMetadata, + id: 2, + name: 'Product Y', + brand: 'Brand Y', + sku: 'SKU-Y', + product_price: 20000, + selling_price: 25000, + tax: 5, + expiry_period: 180, + uom: { + ...baseMetadata, + id: 2, + name: 'BOX', + }, + product_category: { + ...baseMetadata, + id: 2, + code: 'CAT-2', + name: 'Category 2', + }, + suppliers: [], + flags: [], + }, + qty_product: 5, + }, + ], + ekspedisi: [ + { + product_id: 2, + qty: 5, + supplier: { + ...baseMetadata, + id: 2, + name: 'Supplier 2', + alias: 'S2', + category: 'Special', + pic: 'PIC 2', + type: 'Type 2', + phone: '08123456780', + email: 'supplier2@example.com', + address: 'Address 2', + account_number: '1234567891', + balance: 1000, + due_date: 15, + }, + plat_nomor: 'D 5678 EF', + no_surat_jalan: 'SJ-002', + dokumen: 'doc2.pdf', + biaya_ekspedisi: 60000, + nama_sopir: 'Budi', + }, + ], + name: 'Movement 2', + }, + { + ...baseMetadata, + id: 3, + alasan_transfer: 'Pengembalian', + tanggal_transfer: '2024-06-03', + warehouse_asal: { + ...baseMetadata, + id: 3, + name: 'Warehouse C', + type: 'AREA', + area: { id: 3, name: 'Area 3' }, + }, + warehouse_tujuan: { + ...baseMetadata, + id: 1, + name: 'Warehouse A', + type: 'AREA', + area: { id: 1, name: 'Area 1' }, + }, + product: [ + { + product: { + ...baseMetadata, + id: 3, + name: 'Product Z', + brand: 'Brand Z', + sku: 'SKU-Z', + product_price: 15000, + selling_price: 18000, + tax: 8, + expiry_period: 90, + uom: { + ...baseMetadata, + id: 3, + name: 'KG', + }, + product_category: { + ...baseMetadata, + id: 3, + code: 'CAT-3', + name: 'Category 3', + }, + suppliers: [], + flags: [], + }, + qty_product: 8, + }, + ], + ekspedisi: [ + { + product_id: 3, + qty: 8, + supplier: { + ...baseMetadata, + id: 3, + name: 'Supplier 3', + alias: 'S3', + category: 'Return', + pic: 'PIC 3', + type: 'Type 3', + phone: '08123456781', + email: 'supplier3@example.com', + address: 'Address 3', + account_number: '1234567892', + balance: 500, + due_date: 10, + }, + plat_nomor: 'F 9101 GH', + no_surat_jalan: 'SJ-003', + dokumen: 'doc3.pdf', + biaya_ekspedisi: 40000, + nama_sopir: 'Cici', + }, + ], + name: 'Movement 3', + }, + { + ...baseMetadata, + id: 4, + alasan_transfer: 'Transfer Internal', + tanggal_transfer: '2024-06-04', + warehouse_asal: { + ...baseMetadata, + id: 4, + name: 'Warehouse D', + type: 'AREA', + area: { id: 4, name: 'Area 4' }, + }, + warehouse_tujuan: { + ...baseMetadata, + id: 5, + name: 'Warehouse E', + type: 'AREA', + area: { id: 5, name: 'Area 5' }, + }, + product: [ + { + product: { + ...baseMetadata, + id: 4, + name: 'Product A', + brand: 'Brand A', + sku: 'SKU-A', + product_price: 5000, + selling_price: 7000, + tax: 0, + expiry_period: 60, + uom: { + ...baseMetadata, + id: 4, + name: 'LITER', + }, + product_category: { + ...baseMetadata, + id: 4, + code: 'CAT-4', + name: 'Category 4', + }, + suppliers: [], + flags: [], + }, + qty_product: 20, + }, + ], + ekspedisi: [ + { + product_id: 4, + qty: 20, + supplier: { + ...baseMetadata, + id: 4, + name: 'Supplier 4', + alias: 'S4', + category: 'Internal', + pic: 'PIC 4', + type: 'Type 4', + phone: '08123456782', + email: 'supplier4@example.com', + address: 'Address 4', + account_number: '1234567893', + balance: 200, + due_date: 20, + }, + plat_nomor: 'H 2345 IJ', + no_surat_jalan: 'SJ-004', + dokumen: 'doc4.pdf', + biaya_ekspedisi: 30000, + nama_sopir: 'Dedi', + }, + ], + name: 'Movement 4', + }, + { + ...baseMetadata, + id: 5, + alasan_transfer: 'Distribusi', + tanggal_transfer: '2024-06-05', + warehouse_asal: { + ...baseMetadata, + id: 5, + name: 'Warehouse E', + type: 'AREA', + area: { id: 5, name: 'Area 5' }, + }, + warehouse_tujuan: { + ...baseMetadata, + id: 1, + name: 'Warehouse A', + type: 'AREA', + area: { id: 1, name: 'Area 1' }, + }, + product: [ + { + product: { + ...baseMetadata, + id: 5, + name: 'Product B', + brand: 'Brand B', + sku: 'SKU-B', + product_price: 8000, + selling_price: 9500, + tax: 2, + expiry_period: 120, + uom: { + ...baseMetadata, + id: 5, + name: 'PAK', + }, + product_category: { + ...baseMetadata, + id: 5, + code: 'CAT-5', + name: 'Category 5', + }, + suppliers: [], + flags: [], + }, + qty_product: 15, + }, + ], + ekspedisi: [ + { + product_id: 5, + qty: 15, + supplier: { + ...baseMetadata, + id: 5, + name: 'Supplier 5', + alias: 'S5', + category: 'Distribusi', + pic: 'PIC 5', + type: 'Type 5', + phone: '08123456783', + email: 'supplier5@example.com', + address: 'Address 5', + account_number: '1234567894', + balance: 300, + due_date: 25, + }, + plat_nomor: 'K 6789 KL', + no_surat_jalan: 'SJ-005', + dokumen: 'doc5.pdf', + biaya_ekspedisi: 70000, + nama_sopir: 'Eka', + }, + ], + name: 'Movement 5', + }, +]; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => ( +
+ + + +
+); + +const MovementTable = () => { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [sorting, setSorting] = useState([]); + const [selectedMovement, setSelectedMovement] = useState< + Movement | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const paginatedData = useMemo(() => { + const start = (page - 1) * pageSize; + return dummyMovements.slice(start, start + pageSize); + }, [page, pageSize]); + + const movementsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => pageSize * (page - 1) + props.row.index + 1, + }, + { + accessorKey: 'warehouse_asal', + header: 'Gudang Asal', + cell: (props) => props.row.original.warehouse_asal.name, + }, + { + accessorKey: 'warehouse_tujuan', + header: 'Gudang Tujuan', + cell: (props) => props.row.original.warehouse_tujuan.name, + }, + { + accessorKey: 'product', + header: 'Nama Produk', + cell: (props) => props.row.original.product.map((p) => p.product.name), + }, + { + accessorKey: 'alasan_transfer', + header: 'Catatan', + }, + { + accessorKey: 'biaya_ekspedisi', + header: 'Biaya Ekspedisi', + cell: (props) => + props.row.original.ekspedisi.map((e) => e.biaya_ekspedisi), + }, + { + 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 = () => { + setSelectedMovement(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const deleteModal = useModal(); + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + setTimeout(() => { + setIsDeleteLoading(false); + deleteModal.closeModal(); + }, 1000); + }; + + const searchChangeHandler: React.ChangeEventHandler = ( + e + ) => { + setSearch(e.target.value); + setPage(1); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }; + + return ( + <> +
+
+
+
+ +
+ +
+
+ +
+
+ + data={paginatedData} + columns={movementsColumns} + pageSize={pageSize} + page={page} + totalItems={dummyMovements.length} + onPageChange={setPage} + isLoading={false} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': paginatedData.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', + }} + /> +
+ + + ); +}; + +export default MovementTable; From ddbf8b0896618e5810108f4dde360ceaa69f7707 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 8 Oct 2025 16:16:12 +0700 Subject: [PATCH 003/212] refactor(FE-62): rename Product component to Movement for clarity --- src/app/inventory/movement/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/inventory/movement/page.tsx b/src/app/inventory/movement/page.tsx index 12fe795b..a2c25612 100644 --- a/src/app/inventory/movement/page.tsx +++ b/src/app/inventory/movement/page.tsx @@ -1,6 +1,6 @@ import MovementTable from '@/components/pages/inventory/movement/MovementTable'; -const Product = () => { +const Movement = () => { return (
@@ -8,4 +8,4 @@ const Product = () => { ); }; -export default Product; +export default Movement; From 1ea9ee30695459fb5c0eac38e564235fb1732a1d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 9 Oct 2025 14:30:05 +0700 Subject: [PATCH 004/212] feat(FE-62,63,65): implement MovementForm component for managing inventory movements --- src/app/inventory/movement/add/page.tsx | 11 + .../movement/form/MovementForm.schema.ts | 121 +-- .../inventory/movement/form/MovementForm.tsx | 751 ++++++++++++++++++ src/types/api/inventory/movement.d.ts | 86 +- 4 files changed, 866 insertions(+), 103 deletions(-) create mode 100644 src/app/inventory/movement/add/page.tsx create mode 100644 src/components/pages/inventory/movement/form/MovementForm.tsx diff --git a/src/app/inventory/movement/add/page.tsx b/src/app/inventory/movement/add/page.tsx new file mode 100644 index 00000000..f883de95 --- /dev/null +++ b/src/app/inventory/movement/add/page.tsx @@ -0,0 +1,11 @@ +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; + +const AddMovement = () => { + return ( +
+ +
+ ); +}; + +export default AddMovement; diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index cdabe355..11c40fe5 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,67 +1,68 @@ import * as Yup from 'yup'; export const MovementFormSchema = Yup.object({ - alasan_transfer: Yup.string() - .required('Alasan Transfer wajib diisi!'), - tanggal_transfer: Yup.date() - .required('Tanggal Transfer wajib diisi!') - .typeError('Tanggal Transfer tidak valid!'), - warehouse_asal: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - warehouse_asal_id: Yup.number() - .required('Gudang Asal wajib diisi!'), - warehouse_tujuan: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - warehouse_tujuan_id: Yup.number() - .required('Gudang Tujuan wajib diisi!'), - alasan: Yup.string() - .required('Alasan wajib diisi!'), - product: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - product_id: Yup.array() - .of(Yup.number()).min(1, 'Pilih minimal 1 produk') - .required('Produk wajib diisi!'), - qty_product: Yup.array() - .of(Yup.number().min(1, 'Kuantitas minimal 1')) - .min(1, 'Pilih minimal 1 produk') - .required('Kuantitas wajib diisi!'), - ekspedisi: Yup.array().of( - Yup.object({ - product: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - product_id: Yup.number() - .required('Produk wajib diisi!'), - qty: Yup.number().min(1, 'Kuantitas minimal 1') - .required('Kuantitas wajib diisi!'), - supplier: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - supplier_id: Yup.number() - .required('Supplier wajib diisi!'), - plat_nomor: Yup.string() - .required('Plat Nomor wajib diisi!'), - no_surat_jalan: Yup.string() - .required('No Surat Jalan wajib diisi!'), - dokumen: Yup.mixed() - .required('Dokumen wajib diisi!'), - biaya_ekspedisi: Yup.number() - .min(0, 'Biaya Ekspedisi minimal 0') - .required('Biaya Ekspedisi wajib diisi!'), - nama_sopir: Yup.string() - .required('Nama Sopir wajib diisi!'), - }) - ).min(1, 'Pilih minimal 1 ekspedisi').required('Ekspedisi wajib diisi!'), + alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), + tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), + warehouse_asal: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + warehouse_asal_id: Yup.number() + .required('Gudang asal wajib diisi!') + .typeError('Gudang asal wajib diisi!'), + warehouse_tujuan: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + warehouse_tujuan_id: Yup.number() + .required('Gudang tujuan wajib diisi!') + .typeError('Gudang tujuan wajib diisi!'), + product: Yup.array() + .of( + Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + qty_product: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), + }) + ) + .min(1, 'Minimal harus ada 1 produk!'), + ekspedisi: Yup.array() + .of( + Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + qty: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number().required('Supplier wajib diisi!'), + plat_nomor: Yup.string().required('Plat nomor wajib diisi!'), + no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), + dokumen: Yup.mixed().required('Dokumen wajib diisi!'), + biaya_ekspedisi: Yup.number() + .required('Biaya ekspedisi wajib diisi!') + .min(0, 'Biaya minimal 0!') + .typeError('Biaya harus berupa angka!'), + nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), + }) + ) + .optional() + .default([]), }); export const UpdateMovementFormSchema = MovementFormSchema; -export type MovementFormValues = Yup.InferType; \ No newline at end of file +export type MovementFormValues = Yup.InferType; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx new file mode 100644 index 00000000..0105768d --- /dev/null +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -0,0 +1,751 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { FieldArray, FormikProvider, useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +import { + MovementFormSchema, + MovementFormValues, + UpdateMovementFormSchema, +} from '@/components/pages/inventory/movement/form/MovementForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Movement, + CreateMovementPayload, + UpdateMovementPayload, +} from '@/types/api/inventory/movement'; +import { + ProductApi, + WarehouseApi, + SupplierApi, +} from '@/services/api/master-data'; +import { MovementApi } from '@/services/api/inventory'; +import { cn } from '@/lib/helper'; + +interface MovementFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Movement; +} + +const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createMovementHandler = useCallback( + async (payload: CreateMovementPayload) => { + const res = await MovementApi.create(payload); + if (isResponseError(res)) { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/inventory/movement'); + }, + [router] + ); + + const updateMovementHandler = useCallback( + async (movementId: number, payload: UpdateMovementPayload) => { + const res = await MovementApi.update(movementId, payload); + if (res?.status === 'error') { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/inventory/movement'); + }, + [router] + ); + + const formikInitialValues = useMemo( + () => ({ + alasan_transfer: initialValues?.alasan_transfer ?? '', + tanggal_transfer: initialValues?.tanggal_transfer ?? '', + warehouse_asal: initialValues?.warehouse_asal + ? { + value: initialValues.warehouse_asal.id, + label: initialValues.warehouse_asal.name, + } + : null, + warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, + warehouse_tujuan: initialValues?.warehouse_tujuan + ? { + value: initialValues.warehouse_tujuan.id, + label: initialValues.warehouse_tujuan.name, + } + : null, + warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, + product: + initialValues?.product?.map((p) => ({ + product: { value: p.product.id, label: p.product.name }, + product_id: p.product.id, + qty_product: p.qty_product, + })) ?? [], + ekspedisi: + initialValues?.ekspedisi?.map((e) => ({ + product: { value: e.product_id, label: '' }, // Need to fetch product details + product_id: e.product_id, + qty: e.qty, + supplier: { value: e.supplier.id, label: e.supplier.name }, + supplier_id: e.supplier.id, + plat_nomor: e.plat_nomor, + no_surat_jalan: e.no_surat_jalan, + dokumen: e.dokumen, + biaya_ekspedisi: e.biaya_ekspedisi, + nama_sopir: e.nama_sopir, + })) ?? [], + }), + [initialValues] + ); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, + onSubmit: async (values) => { + setMovementFormErrorMessage(''); + const payload: CreateMovementPayload = { + alasan_transfer: values.alasan_transfer, + tanggal_transfer: values.tanggal_transfer, + warehouse_asal_id: values.warehouse_asal_id, + warehouse_tujuan_id: values.warehouse_tujuan_id, + product: (values.product ?? []).map((p) => ({ + product_id: p.product_id, + qty_product: p.qty_product, + })), + ekspedisi: (values.ekspedisi ?? []).map((e) => ({ + product_id: e.product_id, + qty: e.qty, + supplier_id: e.supplier_id, + plat_nomor: e.plat_nomor, + no_surat_jalan: e.no_surat_jalan, + dokumen: + e.dokumen instanceof File ? e.dokumen : (e.dokumen as string), + biaya_ekspedisi: e.biaya_ekspedisi, + nama_sopir: e.nama_sopir, + })), + }; + + switch (type) { + case 'add': + await createMovementHandler(payload); + break; + case 'edit': + await updateMovementHandler(initialValues?.id as number, payload); + break; + } + }, + }); + + // Warehouse selection + const [warehouseSelectInputValue, setWarehouseSelectInputValue] = + useState(''); + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; + const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + warehousesUrl, + WarehouseApi.getAllFetcher + ); + const warehouseOptions = isResponseSuccess(warehouses) + ? warehouses?.data.map((w) => ({ value: w.id, label: w.name })) + : []; + + // Product selection + const [productSelectInputValue, setProductSelectInputValue] = useState(''); + const productsUrl = `${ProductApi.basePath}?${new URLSearchParams({ search: productSelectInputValue }).toString()}`; + const { data: products, isLoading: isLoadingProducts } = useSWR( + productsUrl, + ProductApi.getAllFetcher + ); + const productOptions = isResponseSuccess(products) + ? products?.data.map((p) => ({ value: p.id, label: p.name })) + : []; + + // Supplier selection + const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); + const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( + suppliersUrl, + SupplierApi.getAllFetcher + ); + const supplierOptions = isResponseSuccess(suppliers) + ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) + : []; + + const deleteMovementClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + await MovementApi.delete(initialValues?.id as number); + deleteModal.closeModal(); + toast.success('Successfully delete Movement!'); + setIsDeleteLoading(false); + router.push('/inventory/movement'); + }; + + const { setValues: formikSetValues } = formik; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ +

+ {type === 'add' && 'Tambah Movement'} + {type === 'edit' && 'Edit Movement'} + {type === 'detail' && 'Detail Movement'} +

+
+ + +
+ {/* Top card - Movement details */} +
+
+
+ + +
+
+
+ + {/* Warehouse cards */} +
+
+
+ { + formik.setFieldValue('warehouse_asal', val); + formik.setFieldValue( + 'warehouse_asal_id', + (val as OptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.warehouse_asal_id && + Boolean(formik.errors.warehouse_asal_id) + } + errorMessage={formik.errors.warehouse_asal_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+ +
+
+ { + formik.setFieldValue('warehouse_tujuan', val); + formik.setFieldValue( + 'warehouse_tujuan_id', + (val as OptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.warehouse_tujuan_id && + Boolean(formik.errors.warehouse_tujuan_id) + } + errorMessage={formik.errors.warehouse_tujuan_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+
+ + {/* Products table */} +
+
+

Produk

+ + {({ push, remove }) => ( + <> + {typeof formik.errors.product === 'string' && ( +
+ {formik.errors.product} +
+ )} + + + + + + + + + + {formik.values.product?.map((_, index) => ( + + + + + + )) ?? []} + +
ProdukQtyAksi
+ { + formik.setFieldValue( + `product.${index}.product`, + val + ); + formik.setFieldValue( + `product.${index}.product_id`, + (val as OptionType)?.value + ); + }} + options={productOptions} + onInputChange={setProductSelectInputValue} + isLoading={isLoadingProducts} + isDisabled={type === 'detail'} + isClearable + /> + + + formik.setFieldValue( + `product.${index}.qty_product`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + {type !== 'detail' && ( + + )} +
+ {type !== 'detail' && ( + + )} + + )} +
+
+
+ + {/* Ekspedisi table */} +
+
+

Ekspedisi

+ + {({ push, remove }) => ( + <> + {typeof formik.errors.ekspedisi === 'string' && ( +
+ {formik.errors.ekspedisi} +
+ )} + + + + + + + + + + + + + + + + {formik.values.ekspedisi?.map((ekspedisi, index) => ( + + + + + + + + + + + + )) ?? []} + +
ProdukQtySupplierPlat NomorNo Surat JalanDokumenBiaya EkspedisiNama SopirAksi
+ { + formik.setFieldValue( + `ekspedisi.${index}.product`, + val + ); + formik.setFieldValue( + `ekspedisi.${index}.product_id`, + (val as OptionType)?.value + ); + }} + options={productOptions} + onInputChange={setProductSelectInputValue} + isLoading={isLoadingProducts} + isDisabled={type === 'detail'} + isClearable + /> + + + formik.setFieldValue( + `ekspedisi.${index}.qty`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + { + formik.setFieldValue( + `ekspedisi.${index}.supplier`, + val + ); + formik.setFieldValue( + `ekspedisi.${index}.supplier_id`, + (val as OptionType)?.value + ); + }} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isDisabled={type === 'detail'} + isClearable + /> + + + formik.setFieldValue( + `ekspedisi.${index}.plat_nomor`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + + formik.setFieldValue( + `ekspedisi.${index}.no_surat_jalan`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + { + const file = e.target.files?.[0]; + if (file) { + formik.setFieldValue( + `ekspedisi.${index}.dokumen`, + file + ); + } + }} + readOnly={type === 'detail'} + /> + + + formik.setFieldValue( + `ekspedisi.${index}.biaya_ekspedisi`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + + formik.setFieldValue( + `ekspedisi.${index}.nama_sopir`, + e.target.value + ) + } + readOnly={type === 'detail'} + /> + + {type !== 'detail' && ( + + )} +
+ {type !== 'detail' && ( + + )} + + )} +
+
+
+ + {/* Action buttons */} +
+ {type !== 'add' && ( +
+ + {type !== 'edit' && ( + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ + {movementFormErrorMessage && ( +
+ + {movementFormErrorMessage} +
+ )} +
+
+
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default MovementForm; diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index fe2996ba..0d58b8e1 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -1,51 +1,51 @@ -import {BaseMetadata} from '@/types/api/api-general'; -import {Product} from "@/types/api/master-data/product"; -import {Supplier} from "@/types/api/master-data/supplier"; -import {Warehouse} from "@/types/api/master-data/warehouse"; +import { BaseMetadata } from '@/types/api/api-general'; +import { Product } from '@/types/api/master-data/product'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Warehouse } from '@/types/api/master-data/warehouse'; export type BaseMovement = { - id: number; - alasan_transfer: string; - tanggal_transfer: string; - warehouse_asal: Warehouse; - warehouse_tujuan: Warehouse; - product: Array<{ - product: Product; - qty_product: number; - }>; - ekspedisi: Array<{ - product_id: number; - qty: number; - supplier: Supplier; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string; - biaya_ekspedisi: number; - nama_sopir: string; - }>; - name: string; + id: number; + alasan_transfer: string; + tanggal_transfer: string; + warehouse_asal: Warehouse; + warehouse_tujuan: Warehouse; + product: { + product: Product; + qty_product: number; + }[]; + ekspedisi: { + product_id: number; + qty: number; + supplier: Supplier; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string; + biaya_ekspedisi: number; + nama_sopir: string; + }[]; }; export type Movement = BaseMetadata & BaseMovement; export type CreateMovementPayload = { - alasan: string; - warehouse_asal_id: number; - warehouse_tujuan_id: number; - product: Array<{ - product_id: number; - qty_product: number; - }>; - ekspedisi: Array<{ - product_id: number; - qty: number; - supplier_id: number; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string; - biaya_ekspedisi: number; - nama_sopir: string; - }>; -} + alasan_transfer: string; + tanggal_transfer: string; + warehouse_asal_id: number; + warehouse_tujuan_id: number; + product: { + product_id: number; + qty_product: number; + }[]; + ekspedisi: { + product_id: number; + qty: number; + supplier_id: number; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string | File; + biaya_ekspedisi: number; + nama_sopir: string; + }[]; +}; -export type UpdateMovementPayload = CreateMovementPayload; \ No newline at end of file +export type UpdateMovementPayload = CreateMovementPayload; From c17ffc6aff22bc2dd58e9aba7fe88e6e2b1d55dc Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 9 Oct 2025 14:34:25 +0700 Subject: [PATCH 005/212] feat(FE-62): add MovementEdit and MovementDetail components for inventory movement management --- .../inventory/movement/detail/edit/page.tsx | 48 +++++++++++++++++++ src/app/inventory/movement/detail/layout.tsx | 11 +++++ src/app/inventory/movement/detail/page.tsx | 48 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 src/app/inventory/movement/detail/edit/page.tsx create mode 100644 src/app/inventory/movement/detail/layout.tsx create mode 100644 src/app/inventory/movement/detail/page.tsx diff --git a/src/app/inventory/movement/detail/edit/page.tsx b/src/app/inventory/movement/detail/edit/page.tsx new file mode 100644 index 00000000..bde4ece1 --- /dev/null +++ b/src/app/inventory/movement/detail/edit/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; +import { MovementApi } from '@/services/api/inventory'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const MovementEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const movementId = searchParams.get('movementId'); + + const { data: movement, isLoading: isLoadingMovement } = useSWR( + movementId, + (id: number) => MovementApi.getSingle(id) + ); + + if (!movementId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingMovement && (!movement || isResponseError(movement))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingMovement && ( + + )} + {!isLoadingMovement && isResponseSuccess(movement) && ( + + )} +
+ ); +}; + +export default MovementEdit; diff --git a/src/app/inventory/movement/detail/layout.tsx b/src/app/inventory/movement/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/inventory/movement/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/inventory/movement/detail/page.tsx b/src/app/inventory/movement/detail/page.tsx new file mode 100644 index 00000000..5947cd1b --- /dev/null +++ b/src/app/inventory/movement/detail/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; +import { MovementApi } from '@/services/api/inventory'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const MovementDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const movementId = searchParams.get('movementId'); + + const { data: movement, isLoading: isLoadingMovement } = useSWR( + movementId, + (id: number) => MovementApi.getSingle(id) + ); + + if (!movementId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingMovement && (!movement || isResponseError(movement))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingMovement && ( + + )} + {!isLoadingMovement && isResponseSuccess(movement) && ( + + )} +
+ ); +}; + +export default MovementDetail; From 7dbf8802284755a52318d7d9fdf7ada55965913c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 10 Oct 2025 08:39:34 +0700 Subject: [PATCH 006/212] feat(FE-62): add FormActions and FormHeader components for form management --- src/components/helper/form/FormActions.tsx | 82 +++++++++++++++++++ src/components/helper/form/FormHeader.tsx | 24 ++++++ .../movement/form/useMovementFormHandlers.ts | 69 ++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/components/helper/form/FormActions.tsx create mode 100644 src/components/helper/form/FormHeader.tsx create mode 100644 src/components/pages/inventory/movement/form/useMovementFormHandlers.ts diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx new file mode 100644 index 00000000..25b586c3 --- /dev/null +++ b/src/components/helper/form/FormActions.tsx @@ -0,0 +1,82 @@ +import { Icon } from '@iconify/react'; +import { FormikContextType } from 'formik'; +import Button from '@/components/Button'; +import { cn } from '@/lib/helper'; + +interface FormActionsProps { + type: 'add' | 'edit' | 'detail'; + formik: FormikContextType; + editUrl?: string; + onDelete?: () => void; +} + +export const FormActions = ({ + type, + formik, + editUrl, + onDelete, +}: FormActionsProps) => { + return ( +
+ {type !== 'add' && onDelete && ( +
+ + {type !== 'edit' && editUrl && ( + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ ); +}; diff --git a/src/components/helper/form/FormHeader.tsx b/src/components/helper/form/FormHeader.tsx new file mode 100644 index 00000000..ebc1d7ae --- /dev/null +++ b/src/components/helper/form/FormHeader.tsx @@ -0,0 +1,24 @@ +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; + +interface FormHeaderProps { + type: 'add' | 'edit' | 'detail'; + title: string; + backUrl: string; +} + +export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { + return ( +
+ +

+ {type === 'add' && `Tambah ${title}`} + {type === 'edit' && `Edit ${title}`} + {type === 'detail' && `Detail ${title}`} +

+
+ ); +}; diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts new file mode 100644 index 00000000..f6531bf4 --- /dev/null +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -0,0 +1,69 @@ +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-hot-toast'; +import { useModal } from '@/components/Modal'; +import { MovementApi } from '@/services/api/inventory'; +import { + CreateMovementPayload, + UpdateMovementPayload, +} from '@/types/api/inventory/movement'; +import { isResponseError } from '@/lib/api-helper'; + +export const useMovementFormHandlers = (initialValuesId?: number) => { + const router = useRouter(); + const deleteModal = useModal(); + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createMovementHandler = useCallback( + async (payload: CreateMovementPayload) => { + const res = await MovementApi.create(payload); + if (isResponseError(res)) { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/inventory/movement'); + }, + [router] + ); + + const updateMovementHandler = useCallback( + async (movementId: number, payload: UpdateMovementPayload) => { + const res = await MovementApi.update(movementId, payload); + if (res?.status === 'error') { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/inventory/movement'); + }, + [router] + ); + + const deleteMovementClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValuesId) return; + + setIsDeleteLoading(true); + await MovementApi.delete(initialValuesId); + deleteModal.closeModal(); + toast.success('Successfully delete Movement!'); + setIsDeleteLoading(false); + router.push('/inventory/movement'); + }, [deleteModal, initialValuesId, router]); + + return { + deleteModal, + movementFormErrorMessage, + isDeleteLoading, + createMovementHandler, + updateMovementHandler, + deleteMovementClickHandler, + confirmationModalDeleteClickHandler, + }; +}; From a9cdea73182518bdc81c03bce4825d5b4f2d63fb Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 10 Oct 2025 08:40:17 +0700 Subject: [PATCH 007/212] feat(FE-65): enhance MovementForm with initial values handling and refactor components --- .../movement/form/MovementForm.schema.ts | 41 ++++ .../inventory/movement/form/MovementForm.tsx | 219 +++--------------- 2 files changed, 78 insertions(+), 182 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 11c40fe5..7a34dfbd 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,4 +1,5 @@ import * as Yup from 'yup'; +import { Movement } from '@/types/api/inventory/movement'; export const MovementFormSchema = Yup.object({ alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), @@ -66,3 +67,43 @@ export const MovementFormSchema = Yup.object({ export const UpdateMovementFormSchema = MovementFormSchema; export type MovementFormValues = Yup.InferType; + +export const getMovementFormInitialValues = ( + initialValues?: Movement +): MovementFormValues => ({ + alasan_transfer: initialValues?.alasan_transfer ?? '', + tanggal_transfer: initialValues?.tanggal_transfer ?? '', + warehouse_asal: initialValues?.warehouse_asal + ? { + value: initialValues.warehouse_asal.id, + label: initialValues.warehouse_asal.name, + } + : null, + warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, + warehouse_tujuan: initialValues?.warehouse_tujuan + ? { + value: initialValues.warehouse_tujuan.id, + label: initialValues.warehouse_tujuan.name, + } + : null, + warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, + product: + initialValues?.product?.map((p) => ({ + product: { value: p.product.id, label: p.product.name }, + product_id: p.product.id, + qty_product: p.qty_product, + })) ?? [], + ekspedisi: + initialValues?.ekspedisi?.map((e) => ({ + product: { value: e.product_id, label: '' }, + product_id: e.product_id, + qty: e.qty, + supplier: { value: e.supplier.id, label: e.supplier.name }, + supplier_id: e.supplier.id, + plat_nomor: e.plat_nomor, + no_surat_jalan: e.no_surat_jalan, + dokumen: e.dokumen, + biaya_ekspedisi: e.biaya_ekspedisi, + nama_sopir: e.nama_sopir, + })) ?? [], +}); diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 0105768d..839b83fe 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1,36 +1,33 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; import { FieldArray, FormikProvider, useFormik } from 'formik'; -import { toast } from 'react-hot-toast'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; -import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; - +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { FormActions } from '@/components/helper/form/FormActions'; +import { + CreateMovementPayload, + Movement, +} from '@/types/api/inventory/movement'; +import { isResponseSuccess } from '@/lib/api-helper'; import { MovementFormSchema, MovementFormValues, UpdateMovementFormSchema, + getMovementFormInitialValues, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; -import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { - Movement, - CreateMovementPayload, - UpdateMovementPayload, -} from '@/types/api/inventory/movement'; +import { useMovementFormHandlers } from './useMovementFormHandlers'; import { ProductApi, - WarehouseApi, SupplierApi, + WarehouseApi, } from '@/services/api/master-data'; -import { MovementApi } from '@/services/api/inventory'; -import { cn } from '@/lib/helper'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; @@ -38,77 +35,20 @@ interface MovementFormProps { } const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { - const router = useRouter(); - const deleteModal = useModal(); + const [, setMovementFormErrorMessage] = useState(''); - const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const createMovementHandler = useCallback( - async (payload: CreateMovementPayload) => { - const res = await MovementApi.create(payload); - if (isResponseError(res)) { - setMovementFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.push('/inventory/movement'); - }, - [router] - ); - - const updateMovementHandler = useCallback( - async (movementId: number, payload: UpdateMovementPayload) => { - const res = await MovementApi.update(movementId, payload); - if (res?.status === 'error') { - setMovementFormErrorMessage(res.message); - return; - } - toast.success(res?.message as string); - router.refresh(); - router.push('/inventory/movement'); - }, - [router] - ); + const { + deleteModal, + movementFormErrorMessage, + isDeleteLoading, + createMovementHandler, + updateMovementHandler, + deleteMovementClickHandler, + confirmationModalDeleteClickHandler, + } = useMovementFormHandlers(initialValues?.id); const formikInitialValues = useMemo( - () => ({ - alasan_transfer: initialValues?.alasan_transfer ?? '', - tanggal_transfer: initialValues?.tanggal_transfer ?? '', - warehouse_asal: initialValues?.warehouse_asal - ? { - value: initialValues.warehouse_asal.id, - label: initialValues.warehouse_asal.name, - } - : null, - warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, - warehouse_tujuan: initialValues?.warehouse_tujuan - ? { - value: initialValues.warehouse_tujuan.id, - label: initialValues.warehouse_tujuan.name, - } - : null, - warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, - product: - initialValues?.product?.map((p) => ({ - product: { value: p.product.id, label: p.product.name }, - product_id: p.product.id, - qty_product: p.qty_product, - })) ?? [], - ekspedisi: - initialValues?.ekspedisi?.map((e) => ({ - product: { value: e.product_id, label: '' }, // Need to fetch product details - product_id: e.product_id, - qty: e.qty, - supplier: { value: e.supplier.id, label: e.supplier.name }, - supplier_id: e.supplier.id, - plat_nomor: e.plat_nomor, - no_surat_jalan: e.no_surat_jalan, - dokumen: e.dokumen, - biaya_ekspedisi: e.biaya_ekspedisi, - nama_sopir: e.nama_sopir, - })) ?? [], - }), + () => getMovementFormInitialValues(initialValues), [initialValues] ); @@ -185,19 +125,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) : []; - const deleteMovementClickHandler = () => { - deleteModal.openModal(); - }; - - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - await MovementApi.delete(initialValues?.id as number); - deleteModal.closeModal(); - toast.success('Successfully delete Movement!'); - setIsDeleteLoading(false); - router.push('/inventory/movement'); - }; - const { setValues: formikSetValues } = formik; useEffect(() => { @@ -207,22 +134,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return ( <>
-
- -

- {type === 'add' && 'Tambah Movement'} - {type === 'edit' && 'Edit Movement'} - {type === 'detail' && 'Detail Movement'} -

-
- +
{ {({ push, remove }) => ( <> - {typeof formik.errors.product === 'string' && ( -
- {formik.errors.product} -
- )} @@ -433,11 +344,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {({ push, remove }) => ( <> - {typeof formik.errors.ekspedisi === 'string' && ( -
- {formik.errors.ekspedisi} -
- )}
@@ -652,67 +558,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {/* Action buttons */} -
- {type !== 'add' && ( -
- - {type !== 'edit' && ( - - )} -
- )} - {type !== 'detail' && ( -
- - -
- )} -
+ + type={type} + formik={formik} + editUrl={ + initialValues + ? `/inventory/movement/detail/edit/?movementId=${initialValues.id}` + : undefined + } + onDelete={deleteMovementClickHandler} + /> {movementFormErrorMessage && (
From 095190d75783841e574f742efc8461c2e16838eb Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 10 Oct 2025 10:19:56 +0700 Subject: [PATCH 008/212] refactor(FE-62,65): refactor MovementForm schema and component for improved product and ekspedisi handling --- .../movement/form/MovementForm.schema.ts | 110 +- .../inventory/movement/form/MovementForm.tsx | 950 ++++++++++-------- 2 files changed, 577 insertions(+), 483 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 7a34dfbd..230244be 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -1,6 +1,72 @@ import * as Yup from 'yup'; import { Movement } from '@/types/api/inventory/movement'; +export type ProductSchema = { + product: { + value: number; + label: string; + } | null; + product_id: number; + qty_product: number; +}; + +export type EkspedisiSchema = { + product: { + value: number; + label: string; + } | null; + product_id: number; + qty: number; + supplier: { + value: number; + label: string; + } | null; + supplier_id: number; + plat_nomor: string; + no_surat_jalan: string; + dokumen: string | File; + biaya_ekspedisi: number; + nama_sopir: string; +}; + +// Define schemas for nested objects +const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + qty_product: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), +}); + +const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + qty: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number().required('Supplier wajib diisi!'), + plat_nomor: Yup.string().required('Plat nomor wajib diisi!'), + no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), + dokumen: Yup.mixed().required('Dokumen wajib diisi!'), + biaya_ekspedisi: Yup.number() + .required('Biaya ekspedisi wajib diisi!') + .min(0, 'Biaya minimal 0!') + .typeError('Biaya harus berupa angka!'), + nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), +}); + export const MovementFormSchema = Yup.object({ alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), @@ -19,49 +85,9 @@ export const MovementFormSchema = Yup.object({ .required('Gudang tujuan wajib diisi!') .typeError('Gudang tujuan wajib diisi!'), product: Yup.array() - .of( - Yup.object({ - product: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - product_id: Yup.number().required('Produk wajib diisi!'), - qty_product: Yup.number() - .required('Qty wajib diisi!') - .min(1, 'Qty minimal 1!') - .typeError('Qty harus berupa angka!'), - }) - ) + .of(ProductObjectSchema) .min(1, 'Minimal harus ada 1 produk!'), - ekspedisi: Yup.array() - .of( - Yup.object({ - product: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - product_id: Yup.number().required('Produk wajib diisi!'), - qty: Yup.number() - .required('Qty wajib diisi!') - .min(1, 'Qty minimal 1!') - .typeError('Qty harus berupa angka!'), - supplier: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - supplier_id: Yup.number().required('Supplier wajib diisi!'), - plat_nomor: Yup.string().required('Plat nomor wajib diisi!'), - no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), - dokumen: Yup.mixed().required('Dokumen wajib diisi!'), - biaya_ekspedisi: Yup.number() - .required('Biaya ekspedisi wajib diisi!') - .min(0, 'Biaya minimal 0!') - .typeError('Biaya harus berupa angka!'), - nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), - }) - ) - .optional() - .default([]), + ekspedisi: Yup.array().of(EkspedisiObjectSchema).optional().default([]), }); export const UpdateMovementFormSchema = MovementFormSchema; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 839b83fe..bca69015 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { FieldArray, FormikProvider, useFormik } from 'formik'; +import { useFormik } from 'formik'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; @@ -21,6 +21,8 @@ import { MovementFormValues, UpdateMovementFormSchema, getMovementFormInitialValues, + ProductSchema, + EkspedisiSchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; import { @@ -91,6 +93,72 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, }); + const addProduct = () => { + const newProducts = [ + ...(formik.values.product || []), + { + product: null, + product_id: 0, + qty_product: 0, + }, + ]; + formik.setFieldValue('product', newProducts); + }; + + const removeProduct = (index: number) => { + const newProducts = formik.values.product?.filter( + (_, idx) => idx !== index + ); + formik.setFieldValue('product', newProducts); + }; + + const addEkspedisi = () => { + const newEkspedisi = [ + ...(formik.values.ekspedisi || []), + { + product: null, + product_id: 0, + qty: 0, + supplier: null, + supplier_id: 0, + plat_nomor: '', + no_surat_jalan: '', + dokumen: '', + biaya_ekspedisi: 0, + nama_sopir: '', + }, + ]; + formik.setFieldValue('ekspedisi', newEkspedisi); + }; + + const removeEkspedisi = (index: number) => { + const newEkspedisi = formik.values.ekspedisi?.filter( + (_, idx) => idx !== index + ); + formik.setFieldValue('ekspedisi', newEkspedisi); + }; + + const isRepeaterInputError = ( + arrayName: T, + column: T extends 'product' ? keyof ProductSchema : keyof EkspedisiSchema, + idx: number + ) => { + if ( + !formik.touched[arrayName] || + !Array.isArray(formik.touched[arrayName]) + ) { + return false; + } + + const touchedField = formik.touched[arrayName]?.[idx]?.[column as string]; + const errorField = formik.errors[arrayName]?.[idx] as Record< + string, + unknown + >; + + return touchedField && Boolean(errorField?.[column as string]); + }; + // Warehouse selection const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState(''); @@ -139,448 +207,448 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { title='Movement' backUrl='/inventory/movement' /> - - - {/* Top card - Movement details */} -
-
-
- - -
-
-
- - {/* Warehouse cards */} -
-
-
- { - formik.setFieldValue('warehouse_asal', val); - formik.setFieldValue( - 'warehouse_asal_id', - (val as OptionType)?.value - ); - }} - options={warehouseOptions} - onInputChange={setWarehouseSelectInputValue} - isLoading={isLoadingWarehouses} - isError={ - formik.touched.warehouse_asal_id && - Boolean(formik.errors.warehouse_asal_id) - } - errorMessage={formik.errors.warehouse_asal_id as string} - isDisabled={type === 'detail'} - isClearable - /> -
-
- -
-
- { - formik.setFieldValue('warehouse_tujuan', val); - formik.setFieldValue( - 'warehouse_tujuan_id', - (val as OptionType)?.value - ); - }} - options={warehouseOptions} - onInputChange={setWarehouseSelectInputValue} - isLoading={isLoadingWarehouses} - isError={ - formik.touched.warehouse_tujuan_id && - Boolean(formik.errors.warehouse_tujuan_id) - } - errorMessage={formik.errors.warehouse_tujuan_id as string} - isDisabled={type === 'detail'} - isClearable - /> -
-
-
- - {/* Products table */} -
-
-

Produk

- - {({ push, remove }) => ( - <> -
- - - - - - - - - {formik.values.product?.map((_, index) => ( - - - - - - )) ?? []} - -
ProdukQtyAksi
- { - formik.setFieldValue( - `product.${index}.product`, - val - ); - formik.setFieldValue( - `product.${index}.product_id`, - (val as OptionType)?.value - ); - }} - options={productOptions} - onInputChange={setProductSelectInputValue} - isLoading={isLoadingProducts} - isDisabled={type === 'detail'} - isClearable - /> - - - formik.setFieldValue( - `product.${index}.qty_product`, - e.target.value - ) - } - readOnly={type === 'detail'} - /> - - {type !== 'detail' && ( - - )} -
- {type !== 'detail' && ( - - )} - - )} -
- - - - {/* Ekspedisi table */} -
-
-

Ekspedisi

- - {({ push, remove }) => ( - <> - - - - - - - - - - - - - - - - {formik.values.ekspedisi?.map((ekspedisi, index) => ( - - - - - - - - - - - - )) ?? []} - -
ProdukQtySupplierPlat NomorNo Surat JalanDokumenBiaya EkspedisiNama SopirAksi
- { - formik.setFieldValue( - `ekspedisi.${index}.product`, - val - ); - formik.setFieldValue( - `ekspedisi.${index}.product_id`, - (val as OptionType)?.value - ); - }} - options={productOptions} - onInputChange={setProductSelectInputValue} - isLoading={isLoadingProducts} - isDisabled={type === 'detail'} - isClearable - /> - - - formik.setFieldValue( - `ekspedisi.${index}.qty`, - e.target.value - ) - } - readOnly={type === 'detail'} - /> - - { - formik.setFieldValue( - `ekspedisi.${index}.supplier`, - val - ); - formik.setFieldValue( - `ekspedisi.${index}.supplier_id`, - (val as OptionType)?.value - ); - }} - options={supplierOptions} - onInputChange={setSupplierSelectInputValue} - isLoading={isLoadingSuppliers} - isDisabled={type === 'detail'} - isClearable - /> - - - formik.setFieldValue( - `ekspedisi.${index}.plat_nomor`, - e.target.value - ) - } - readOnly={type === 'detail'} - /> - - - formik.setFieldValue( - `ekspedisi.${index}.no_surat_jalan`, - e.target.value - ) - } - readOnly={type === 'detail'} - /> - - { - const file = e.target.files?.[0]; - if (file) { - formik.setFieldValue( - `ekspedisi.${index}.dokumen`, - file - ); - } - }} - readOnly={type === 'detail'} - /> - - - formik.setFieldValue( - `ekspedisi.${index}.biaya_ekspedisi`, - e.target.value - ) - } - readOnly={type === 'detail'} - /> - - - formik.setFieldValue( - `ekspedisi.${index}.nama_sopir`, - e.target.value - ) - } - readOnly={type === 'detail'} - /> - - {type !== 'detail' && ( - - )} -
- {type !== 'detail' && ( - - )} - - )} -
-
-
- - {/* Action buttons */} - - type={type} - formik={formik} - editUrl={ - initialValues - ? `/inventory/movement/detail/edit/?movementId=${initialValues.id}` - : undefined - } - onDelete={deleteMovementClickHandler} - /> - - {movementFormErrorMessage && ( -
- + {/* Top card - Movement details */} +
+
+
+ + - {movementFormErrorMessage}
- )} - - +
+
+ + {/* Warehouse cards */} +
+
+
+ { + formik.setFieldValue('warehouse_asal', val); + formik.setFieldValue( + 'warehouse_asal_id', + (val as OptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.warehouse_asal_id && + Boolean(formik.errors.warehouse_asal_id) + } + errorMessage={formik.errors.warehouse_asal_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+ +
+
+ { + formik.setFieldValue('warehouse_tujuan', val); + formik.setFieldValue( + 'warehouse_tujuan_id', + (val as OptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.warehouse_tujuan_id && + Boolean(formik.errors.warehouse_tujuan_id) + } + errorMessage={formik.errors.warehouse_tujuan_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+
+ + {/* Products table */} +
+
+

Produk

+
+ + + + + + {type !== 'detail' && } + + + + {formik.values.product?.map((product, idx) => ( + + + + {type !== 'detail' && ( + + )} + + ))} + +
ProdukQtyAksi
+ { + formik.setFieldValue( + `product.${idx}.product`, + val + ); + formik.setFieldValue( + `product.${idx}.product_id`, + (val as OptionType)?.value + ); + }} + options={productOptions} + onInputChange={setProductSelectInputValue} + isLoading={isLoadingProducts} + isDisabled={type === 'detail'} + isClearable + isError={isRepeaterInputError( + 'product', + 'product', + idx + )} + /> + + + + +
+
+ {type !== 'detail' && ( + + )} +
+
+ + {/* Ekspedisi table */} +
+
+

Ekspedisi

+
+ + + + + + + + + + + + {type !== 'detail' && } + + + + {formik.values.ekspedisi?.map((ekspedisi, idx) => ( + + + + + + + + + + {type !== 'detail' && ( + + )} + + ))} + +
ProdukQtySupplierPlat NomorNo Surat JalanDokumenBiaya EkspedisiNama SopirAksi
+ { + formik.setFieldValue( + `ekspedisi.${idx}.product`, + val + ); + formik.setFieldValue( + `ekspedisi.${idx}.product_id`, + (val as OptionType)?.value + ); + }} + options={productOptions} + onInputChange={setProductSelectInputValue} + isLoading={isLoadingProducts} + isDisabled={type === 'detail'} + isClearable + isError={isRepeaterInputError( + 'ekspedisi', + 'product', + idx + )} + /> + + + + { + formik.setFieldValue( + `ekspedisi.${idx}.supplier`, + val + ); + formik.setFieldValue( + `ekspedisi.${idx}.supplier_id`, + (val as OptionType)?.value + ); + }} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isDisabled={type === 'detail'} + isClearable + isError={isRepeaterInputError( + 'ekspedisi', + 'supplier', + idx + )} + /> + + + + + + { + const file = e.target.files?.[0]; + if (file) { + formik.setFieldValue( + `ekspedisi.${idx}.dokumen`, + file + ); + } + }} + isError={isRepeaterInputError( + 'ekspedisi', + 'dokumen', + idx + )} + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} + /> + + + + + + +
+
+ {type !== 'detail' && ( + + )} +
+
+ + {/* Action buttons */} + + type={type} + formik={formik} + editUrl={ + initialValues + ? `/inventory/movement/detail/edit/?movementId=${initialValues.id}` + : undefined + } + onDelete={deleteMovementClickHandler} + /> + + {movementFormErrorMessage && ( +
+ + {movementFormErrorMessage} +
+ )} +
{type !== 'add' && ( From 57831646d950eeb5b7ddb0b9561083673c93bb29 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 10 Oct 2025 11:14:59 +0700 Subject: [PATCH 009/212] refactor(FE-62): optimize product and ekspedisi removal logic in MovementForm --- .../inventory/movement/form/MovementForm.tsx | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index bca69015..d9e1102e 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useFormik } from 'formik'; import useSWR from 'swr'; @@ -105,12 +105,20 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue('product', newProducts); }; - const removeProduct = (index: number) => { - const newProducts = formik.values.product?.filter( - (_, idx) => idx !== index - ); - formik.setFieldValue('product', newProducts); - }; + const removeProduct = useCallback( + (i: number) => { + const updatedProducts = + formik.values.product?.reduce((acc: ProductSchema[], item, index) => { + if (index !== i) { + acc.push(item); + } + return acc; + }, []) ?? []; + + formik.setFieldValue('product', updatedProducts); + }, + [formik] + ); const addEkspedisi = () => { const newEkspedisi = [ @@ -131,12 +139,23 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue('ekspedisi', newEkspedisi); }; - const removeEkspedisi = (index: number) => { - const newEkspedisi = formik.values.ekspedisi?.filter( - (_, idx) => idx !== index - ); - formik.setFieldValue('ekspedisi', newEkspedisi); - }; + const removeEkspedisi = useCallback( + (i: number) => { + const updatedEkspedisi = + formik.values.ekspedisi?.reduce( + (acc: EkspedisiSchema[], item, index) => { + if (index !== i) { + acc.push(item); + } + return acc; + }, + [] + ) ?? []; + + formik.setFieldValue('ekspedisi', updatedEkspedisi); + }, + [formik] + ); const isRepeaterInputError = ( arrayName: T, @@ -201,7 +220,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return ( <> -
+
{ {formik.values.product?.map((product, idx) => ( - + { {formik.values.ekspedisi?.map((ekspedisi, idx) => ( - + Date: Fri, 10 Oct 2025 13:14:39 +0700 Subject: [PATCH 010/212] feat(FE-62): implement bulk removal functionality for selected products and ekspedisi in MovementForm --- .../inventory/movement/form/MovementForm.tsx | 180 ++++++++++++++++-- 1 file changed, 162 insertions(+), 18 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index d9e1102e..14c6fc45 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -38,6 +38,8 @@ interface MovementFormProps { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); + const [selectedProducts, setSelectedProducts] = useState([]); + const [selectedEkspedisi, setSelectedEkspedisi] = useState([]); const { deleteModal, @@ -120,6 +122,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik] ); + const bulkRemoveProduct = useCallback(() => { + const updatedProducts = + formik.values.product?.filter( + (_, idx) => !selectedProducts.includes(idx) + ) ?? []; + formik.setFieldValue('product', updatedProducts); + setSelectedProducts([]); + }, [formik, selectedProducts]); + const addEkspedisi = () => { const newEkspedisi = [ ...(formik.values.ekspedisi || []), @@ -157,6 +168,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik] ); + const bulkRemoveEkspedisi = useCallback(() => { + const updatedEkspedisi = + formik.values.ekspedisi?.filter( + (_, idx) => !selectedEkspedisi.includes(idx) + ) ?? []; + formik.setFieldValue('ekspedisi', updatedEkspedisi); + setSelectedEkspedisi([]); + }, [formik, selectedEkspedisi]); + const isRepeaterInputError = ( arrayName: T, column: T extends 'product' ? keyof ProductSchema : keyof EkspedisiSchema, @@ -333,6 +353,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + {type !== 'detail' && ( + + )} {type !== 'detail' && } @@ -341,6 +384,27 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {formik.values.product?.map((product, idx) => ( + {type !== 'detail' && ( + + )}
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedProducts( + formik.values.product?.map((_, idx) => idx) ?? + [] + ); + } else { + setSelectedProducts([]); + } + }} + /> + Produk QtyAksi
+ { + if (e.target.checked) { + setSelectedProducts([ + ...selectedProducts, + idx, + ]); + } else { + setSelectedProducts( + selectedProducts.filter((i) => i !== idx) + ); + } + }} + /> + {
{type !== 'detail' && ( - +
+ {selectedProducts.length > 0 && ( + + )} + +
)} @@ -428,6 +509,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + {type !== 'detail' && ( + + )} @@ -444,6 +549,27 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + {type !== 'detail' && ( + + )}
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedEkspedisi( + formik.values.ekspedisi?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedEkspedisi([]); + } + }} + /> + Produk Qty Supplier
+ { + if (e.target.checked) { + setSelectedEkspedisi([ + ...selectedEkspedisi, + idx, + ]); + } else { + setSelectedEkspedisi( + selectedEkspedisi.filter((i) => i !== idx) + ); + } + }} + /> + {
{type !== 'detail' && ( - +
+ {selectedEkspedisi.length > 0 && ( + + )} + +
)} From a1dc13ceb42e9543ad335a7041f630f4ce5df19a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 10 Oct 2025 13:36:22 +0700 Subject: [PATCH 011/212] feat(FE-62): enhance MovementForm with area and location display for warehouse selection --- .../inventory/movement/form/MovementForm.tsx | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 14c6fc45..22e39642 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -198,6 +198,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return touchedField && Boolean(errorField?.[column as string]); }; + interface WarehouseOptionType extends OptionType { + area?: string; + location?: string; + } + // Warehouse selection const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState(''); @@ -207,7 +212,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { WarehouseApi.getAllFetcher ); const warehouseOptions = isResponseSuccess(warehouses) - ? warehouses?.data.map((w) => ({ value: w.id, label: w.name })) + ? warehouses?.data.map((w) => ({ + value: w.id, + label: w.name, + area: w.area?.name, + location: + 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') + ? w.location?.name + : undefined, + })) : []; // Product selection @@ -291,16 +304,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { {/* Warehouse cards */}
-
+
+

Gudang Asal

{ formik.setFieldValue('warehouse_asal', val); formik.setFieldValue( 'warehouse_asal_id', - (val as OptionType)?.value + (val as WarehouseOptionType)?.value ); }} options={warehouseOptions} @@ -314,20 +328,51 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isDisabled={type === 'detail'} isClearable /> + + {/* Area and Location Info */} +
+ + +
-
+
+

Gudang Tujuan

{ formik.setFieldValue('warehouse_tujuan', val); formik.setFieldValue( 'warehouse_tujuan_id', - (val as OptionType)?.value + (val as WarehouseOptionType)?.value ); }} options={warehouseOptions} @@ -341,10 +386,39 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isDisabled={type === 'detail'} isClearable /> + + {/* Area and Location Info */} +
+ + +
- {/* Products table */}
From 757893c75789b09ce1d422771d2b71c53885ca48 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 10 Oct 2025 13:43:30 +0700 Subject: [PATCH 012/212] feat(FE-62): add quantity validation for ekspedisi in MovementForm and filter product options --- .../inventory/movement/form/MovementForm.tsx | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 22e39642..a2dd43c8 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -30,6 +30,7 @@ import { SupplierApi, WarehouseApi, } from '@/services/api/master-data'; +import { toast } from 'react-hot-toast'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; @@ -251,6 +252,37 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formikSetValues(formikInitialValues); }, [formikSetValues, formikInitialValues]); + const getFilteredProductOptions = useCallback(() => { + return ( + formik.values.product + ?.filter((p) => p.product) + .map((p) => ({ + value: p.product_id, + label: (p.product as OptionType)?.label, + })) ?? [] + ); + }, [formik.values.product]); + + const validateEkspedisiQty = (ekspedisiIdx: number, qty: number) => { + const productId = formik.values.ekspedisi?.[ekspedisiIdx]?.product_id; + if (!productId) return true; + + const relatedProduct = formik.values.product?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return true; + + const totalQtyUsed = + formik.values.ekspedisi?.reduce((total, eks, i) => { + if (eks.product_id === productId && i !== ekspedisiIdx) { + return total + (Number(eks.qty) || 0); + } + return total; + }, 0) || 0; + + return totalQtyUsed + qty <= Number(relatedProduct.qty_product); + }; + return ( <>
@@ -419,6 +451,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
+ {/* Products table */}
@@ -657,10 +690,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { `ekspedisi.${idx}.product_id`, (val as OptionType)?.value ); + formik.setFieldValue(`ekspedisi.${idx}.qty`, ''); }} - options={productOptions} - onInputChange={setProductSelectInputValue} - isLoading={isLoadingProducts} + options={getFilteredProductOptions()} isDisabled={type === 'detail'} isClearable isError={isRepeaterInputError( @@ -676,7 +708,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type='number' name={`ekspedisi.${idx}.qty`} value={ekspedisi.qty ?? ''} - onChange={formik.handleChange} + onChange={(e) => { + const newQty = Number(e.target.value); + if (validateEkspedisiQty(idx, newQty)) { + formik.handleChange(e); + } else { + toast.error( + 'Quantity exceeds available product quantity' + ); + } + }} onBlur={formik.handleBlur} isError={isRepeaterInputError( 'ekspedisi', From 478f52c94b5b6068f0a71a7ff5714fe99589d289 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 11 Oct 2025 08:33:48 +0700 Subject: [PATCH 013/212] feat(FE-62,65): add biaya_ekspedisi_per_item field and calculation in MovementForm --- .../movement/form/MovementForm.schema.ts | 9 +++- .../inventory/movement/form/MovementForm.tsx | 44 ++++++++++++++++++- src/types/api/inventory/movement.d.ts | 1 + 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 230244be..0bc0397c 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -26,10 +26,10 @@ export type EkspedisiSchema = { no_surat_jalan: string; dokumen: string | File; biaya_ekspedisi: number; + biaya_ekspedisi_per_item?: number | undefined; nama_sopir: string; }; -// Define schemas for nested objects const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ product: Yup.object({ value: Yup.number().min(1).required(), @@ -64,6 +64,12 @@ const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ .required('Biaya ekspedisi wajib diisi!') .min(0, 'Biaya minimal 0!') .typeError('Biaya harus berupa angka!'), + biaya_ekspedisi_per_item: Yup.number() + .transform((value) => (isNaN(value) ? undefined : value)) + .min(0, 'Biaya per item minimal 0!') + .typeError('Biaya per item harus berupa angka!') + .optional() + .default(undefined), nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), }); @@ -130,6 +136,7 @@ export const getMovementFormInitialValues = ( no_surat_jalan: e.no_surat_jalan, dokumen: e.dokumen, biaya_ekspedisi: e.biaya_ekspedisi, + biaya_ekspedisi_per_item: e.biaya_ekspedisi, nama_sopir: e.nama_sopir, })) ?? [], }); diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index a2dd43c8..db432a4d 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -81,6 +81,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { dokumen: e.dokumen instanceof File ? e.dokumen : (e.dokumen as string), biaya_ekspedisi: e.biaya_ekspedisi, + biaya_ekspedisi_per_item: e.qty + ? e.biaya_ekspedisi / e.qty + : e.biaya_ekspedisi, nama_sopir: e.nama_sopir, })), }; @@ -145,6 +148,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { no_surat_jalan: '', dokumen: '', biaya_ekspedisi: 0, + biaya_ekspedisi_per_item: 0, nama_sopir: '', }, ]; @@ -252,6 +256,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formikSetValues(formikInitialValues); }, [formikSetValues, formikInitialValues]); + useEffect(() => { + formik.values.ekspedisi?.forEach((eks, idx) => { + if (eks.qty && eks.biaya_ekspedisi) { + const perItem = eks.biaya_ekspedisi / eks.qty; + formik.setFieldValue( + `ekspedisi.${idx}.biaya_ekspedisi_per_item`, + perItem + ); + } + }); + }, [formik.values.ekspedisi]); + const getFilteredProductOptions = useCallback(() => { return ( formik.values.product @@ -646,7 +662,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { Plat Nomor No Surat Jalan Dokumen - Biaya Ekspedisi + Biaya Ekspedisi (Rp.) + Biaya Ekspedisi / Item (Rp.) Nama Sopir {type !== 'detail' && Aksi} @@ -836,6 +853,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }} /> + + + Date: Sun, 12 Oct 2025 19:15:14 +0700 Subject: [PATCH 014/212] feat(FE-64): add hatchery and npwp fields to MovementTable data structure --- .../inventory/movement/MovementTable.tsx | 91 ++----------------- 1 file changed, 10 insertions(+), 81 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index 288cb2a6..c863a1a9 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -89,9 +89,11 @@ const dummyMovements: Movement[] = [ category: 'General', pic: 'PIC 1', type: 'Type 1', + hatchery: 'Hatchery 1', phone: '08123456789', email: 'supplier1@example.com', address: 'Address 1', + npwp: '1234567890123456', account_number: '1234567890', balance: 0, due_date: 30, @@ -103,7 +105,6 @@ const dummyMovements: Movement[] = [ nama_sopir: 'Andi', }, ], - name: 'Movement 1', }, { ...baseMetadata, @@ -165,9 +166,11 @@ const dummyMovements: Movement[] = [ category: 'Special', pic: 'PIC 2', type: 'Type 2', + hatchery: 'Hatchery 2', phone: '08123456780', email: 'supplier2@example.com', address: 'Address 2', + npwp: '1234567890123457', account_number: '1234567891', balance: 1000, due_date: 15, @@ -179,7 +182,6 @@ const dummyMovements: Movement[] = [ nama_sopir: 'Budi', }, ], - name: 'Movement 2', }, { ...baseMetadata, @@ -241,9 +243,11 @@ const dummyMovements: Movement[] = [ category: 'Return', pic: 'PIC 3', type: 'Type 3', + hatchery: 'Hatchery 3', phone: '08123456781', email: 'supplier3@example.com', address: 'Address 3', + npwp: '1234567890123458', account_number: '1234567892', balance: 500, due_date: 10, @@ -255,7 +259,6 @@ const dummyMovements: Movement[] = [ nama_sopir: 'Cici', }, ], - name: 'Movement 3', }, { ...baseMetadata, @@ -317,9 +320,11 @@ const dummyMovements: Movement[] = [ category: 'Internal', pic: 'PIC 4', type: 'Type 4', + hatchery: 'Hatchery 4', phone: '08123456782', email: 'supplier4@example.com', address: 'Address 4', + npwp: '1234567890123459', account_number: '1234567893', balance: 200, due_date: 20, @@ -331,86 +336,10 @@ const dummyMovements: Movement[] = [ nama_sopir: 'Dedi', }, ], - name: 'Movement 4', - }, - { - ...baseMetadata, - id: 5, - alasan_transfer: 'Distribusi', - tanggal_transfer: '2024-06-05', - warehouse_asal: { - ...baseMetadata, - id: 5, - name: 'Warehouse E', - type: 'AREA', - area: { id: 5, name: 'Area 5' }, - }, - warehouse_tujuan: { - ...baseMetadata, - id: 1, - name: 'Warehouse A', - type: 'AREA', - area: { id: 1, name: 'Area 1' }, - }, - product: [ - { - product: { - ...baseMetadata, - id: 5, - name: 'Product B', - brand: 'Brand B', - sku: 'SKU-B', - product_price: 8000, - selling_price: 9500, - tax: 2, - expiry_period: 120, - uom: { - ...baseMetadata, - id: 5, - name: 'PAK', - }, - product_category: { - ...baseMetadata, - id: 5, - code: 'CAT-5', - name: 'Category 5', - }, - suppliers: [], - flags: [], - }, - qty_product: 15, - }, - ], - ekspedisi: [ - { - product_id: 5, - qty: 15, - supplier: { - ...baseMetadata, - id: 5, - name: 'Supplier 5', - alias: 'S5', - category: 'Distribusi', - pic: 'PIC 5', - type: 'Type 5', - phone: '08123456783', - email: 'supplier5@example.com', - address: 'Address 5', - account_number: '1234567894', - balance: 300, - due_date: 25, - }, - plat_nomor: 'K 6789 KL', - no_surat_jalan: 'SJ-005', - dokumen: 'doc5.pdf', - biaya_ekspedisi: 70000, - nama_sopir: 'Eka', - }, - ], - name: 'Movement 5', }, ]; + const RowOptionsMenu = ({ type = 'dropdown', props, @@ -633,7 +562,7 @@ const MovementTable = () => { Date: Sun, 12 Oct 2025 20:32:02 +0700 Subject: [PATCH 015/212] feat: add Layout component to wrap children with SuspenseHelper --- src/app/master-data/customer/detail/layout.tsx | 11 +++++++++++ src/app/master-data/supplier/detail/layout.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/app/master-data/customer/detail/layout.tsx create mode 100644 src/app/master-data/supplier/detail/layout.tsx diff --git a/src/app/master-data/customer/detail/layout.tsx b/src/app/master-data/customer/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/customer/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/master-data/supplier/detail/layout.tsx b/src/app/master-data/supplier/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/supplier/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; From 44e07ddc501335c568e928df72a4a6be3ef3d8e0 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 09:26:21 +0700 Subject: [PATCH 016/212] feat(FE-64): refactor MovementTable with new TableToolbar and TableRowSizeSelector components --- .../inventory/movement/MovementTable.tsx | 325 +++++++----------- src/components/table/TableRowOptions.tsx | 62 ++++ src/components/table/TableRowSizeSelector.tsx | 33 ++ src/components/table/TableToolbar.tsx | 37 ++ 4 files changed, 249 insertions(+), 208 deletions(-) create mode 100644 src/components/table/TableRowOptions.tsx create mode 100644 src/components/table/TableRowSizeSelector.tsx create mode 100644 src/components/table/TableToolbar.tsx diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index c863a1a9..b39906d3 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,23 +1,22 @@ 'use client'; import { useState, useMemo } from 'react'; -import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import { SortingState } from '@tanstack/react-table'; import { Icon } from '@iconify/react'; import Table from '@/components/Table'; -import DebouncedTextInput from '@/components/input/DebouncedTextInput'; -import Button from '@/components/Button'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; -import RowDropdownOptions from '@/components/table/RowDropdownOptions'; -import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import { cn } from '@/lib/helper'; import { ROWS_OPTIONS } from '@/config/constant'; import { Movement } from '@/types/api/inventory/movement'; -import { BaseMetadata } from '@/types/api/api-general'; +import { TableToolbar } from '@/components/table/TableToolbar'; +import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; +import { TableRowOptions } from '@/components/table/TableRowOptions'; +import { OptionType } from '@/components/input/SelectInput'; +import Button from '@/components/Button'; +import { cn } from '@/lib/helper'; // Dummy data -const baseMetadata: BaseMetadata = { +const baseMetadata = { created_user: { id: 1, id_user: 1, @@ -339,159 +338,17 @@ const dummyMovements: Movement[] = [ }, ]; - -const RowOptionsMenu = ({ - type = 'dropdown', - props, - deleteClickHandler, -}: { - type: 'dropdown' | 'collapse'; - props: CellContext; - deleteClickHandler: () => void; -}) => ( -
- - - -
-); - const MovementTable = () => { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); - const [selectedMovement, setSelectedMovement] = useState< - Movement | undefined - >(undefined); + const [, setSelectedMovement] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const paginatedData = useMemo(() => { - const start = (page - 1) * pageSize; - return dummyMovements.slice(start, start + pageSize); - }, [page, pageSize]); - - const movementsColumns: ColumnDef[] = [ - { - header: '#', - cell: (props) => pageSize * (page - 1) + props.row.index + 1, - }, - { - accessorKey: 'warehouse_asal', - header: 'Gudang Asal', - cell: (props) => props.row.original.warehouse_asal.name, - }, - { - accessorKey: 'warehouse_tujuan', - header: 'Gudang Tujuan', - cell: (props) => props.row.original.warehouse_tujuan.name, - }, - { - accessorKey: 'product', - header: 'Nama Produk', - cell: (props) => props.row.original.product.map((p) => p.product.name), - }, - { - accessorKey: 'alasan_transfer', - header: 'Catatan', - }, - { - accessorKey: 'biaya_ekspedisi', - header: 'Biaya Ekspedisi', - cell: (props) => - props.row.original.ekspedisi.map((e) => e.biaya_ekspedisi), - }, - { - 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 = () => { - setSelectedMovement(props.row.original); - deleteModal.openModal(); - }; - - return ( - <> - {currentPageSize > 2 && ( - - - - )} - {currentPageSize <= 2 && ( - - - - )} - - ); - }, - }, - ]; - const deleteModal = useModal(); - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - setTimeout(() => { - setIsDeleteLoading(false); - deleteModal.closeModal(); - }, 1000); - }; - const searchChangeHandler: React.ChangeEventHandler = ( - e - ) => { + const searchChangeHandler = (e: React.ChangeEvent) => { setSearch(e.target.value); setPage(1); }; @@ -502,67 +359,119 @@ const MovementTable = () => { setPage(1); }; + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + setTimeout(() => { + setIsDeleteLoading(false); + deleteModal.closeModal(); + }, 1000); + }; + + const paginatedData = useMemo(() => { + const start = (page - 1) * pageSize; + return dummyMovements.slice(start, start + pageSize); + }, [page, pageSize]); + return ( - <> -
-
-
-
- -
- -
-
- -
-
- - data={paginatedData} - columns={movementsColumns} - pageSize={pageSize} - page={page} - totalItems={dummyMovements.length} - onPageChange={setPage} - isLoading={false} - sorting={sorting} - setSorting={setSorting} - className={{ - containerClassName: cn({ - 'mb-20': paginatedData.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', +
+
+ +
+ + pageSize * (page - 1) + props.row.index + 1, + }, + { + accessorKey: 'warehouse_asal', + header: 'Gudang Asal', + cell: (props) => props.row.original.warehouse_asal.name, + }, + { + accessorKey: 'warehouse_tujuan', + header: 'Gudang Tujuan', + cell: (props) => props.row.original.warehouse_tujuan.name, + }, + { + accessorKey: 'product', + header: 'Nama Produk', + cell: (props) => + props.row.original.product.map((p) => p.product.name), + }, + { + accessorKey: 'alasan_transfer', + header: 'Catatan', + }, + { + accessorKey: 'biaya_ekspedisi', + header: 'Biaya Ekspedisi', + cell: (props) => + props.row.original.ekspedisi.map((e) => e.biaya_ekspedisi), + }, + { + id: 'actions', + cell: (props) => ( +
+ + { + setSelectedMovement(props.row.original); + deleteModal.openModal(); + }} + /> +
+ ), + }, + ]} + pageSize={pageSize} + page={page} + totalItems={dummyMovements.length} + onPageChange={setPage} + isLoading={false} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': paginatedData.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', + }} + /> + { onClick: confirmationModalDeleteClickHandler, }} /> - + ); }; diff --git a/src/components/table/TableRowOptions.tsx b/src/components/table/TableRowOptions.tsx new file mode 100644 index 00000000..61332b4f --- /dev/null +++ b/src/components/table/TableRowOptions.tsx @@ -0,0 +1,62 @@ +import { Icon } from '@iconify/react'; +import Button from '../Button'; +import { cn } from '@/lib/helper'; + +interface TableRowOptionsProps { + type?: 'dropdown' | 'collapse'; + recordId: string | number; + basePath: string; + onDelete?: () => void; +} + +export const TableRowOptions = ({ + type = 'dropdown', + recordId, + basePath, + onDelete, +}: TableRowOptionsProps) => ( +
+ + + {onDelete && ( + + )} +
+); diff --git a/src/components/table/TableRowSizeSelector.tsx b/src/components/table/TableRowSizeSelector.tsx new file mode 100644 index 00000000..a6fd039d --- /dev/null +++ b/src/components/table/TableRowSizeSelector.tsx @@ -0,0 +1,33 @@ +import SelectInput from '../input/SelectInput'; + +export interface OptionType { + label: string; + value: string | number; +} + +interface TableRowSizeSelectorProps { + value: number; + onChange: (val: OptionType | OptionType[] | null) => void; + options: OptionType[]; +} + +export const TableRowSizeSelector = ({ + value, + onChange, + options, +}: TableRowSizeSelectorProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/components/table/TableToolbar.tsx b/src/components/table/TableToolbar.tsx new file mode 100644 index 00000000..e3b385b1 --- /dev/null +++ b/src/components/table/TableToolbar.tsx @@ -0,0 +1,37 @@ +import { Icon } from '@iconify/react'; +import Button from '../Button'; +import DebouncedTextInput from '../input/DebouncedTextInput'; + +interface TableToolbarProps { + addButton?: { + href: string; + label: string; + }; + search: { + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + }; +} + +export const TableToolbar = ({ addButton, search }: TableToolbarProps) => { + return ( +
+ {addButton && ( +
+ +
+ )} + +
+ ); +}; From e7085ab4ff31b3e368a47d12f1b36a1cdbbab967 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 10:02:56 +0700 Subject: [PATCH 017/212] feat(FE-65): add file size validation for dokumen in MovementForm --- .../inventory/movement/form/MovementForm.schema.ts | 10 +++++++++- .../pages/inventory/movement/form/MovementForm.tsx | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 0bc0397c..f606b0c5 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -59,7 +59,15 @@ const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ supplier_id: Yup.number().required('Supplier wajib diisi!'), plat_nomor: Yup.string().required('Plat nomor wajib diisi!'), no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), - dokumen: Yup.mixed().required('Dokumen wajib diisi!'), + dokumen: Yup.mixed() + .required('Dokumen wajib diisi!') + .test( + 'fileSize', + 'Ukuran dokumen maksimal 2 MB!', + (value) => + typeof value === 'string' || + (value instanceof File && value.size <= 2 * 1024 * 1024) + ), biaya_ekspedisi: Yup.number() .required('Biaya ekspedisi wajib diisi!') .min(0, 'Biaya minimal 0!') diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index db432a4d..772f70d5 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -817,6 +817,10 @@ 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!'); + return; + } formik.setFieldValue( `ekspedisi.${idx}.dokumen`, file From b2f0bd6698e57fc19a3ed9c41dd28594fc6c1d5a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 10:31:34 +0700 Subject: [PATCH 018/212] feat(FE-65): add file type validation for dokumen in MovementForm --- .../inventory/movement/form/MovementForm.schema.ts | 8 ++++++++ .../pages/inventory/movement/form/MovementForm.tsx | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index f606b0c5..aa138cac 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -61,6 +61,14 @@ const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), dokumen: Yup.mixed() .required('Dokumen wajib diisi!') + .test( + 'fileType', + 'Mohon upload file berformat PDF atau JPEG/JPG.', + (value) => + typeof value === 'string' || + (value instanceof File && + ['application/pdf', 'image/jpeg', 'image/jpg'].includes(value.type)) + ) .test( 'fileSize', 'Ukuran dokumen maksimal 2 MB!', diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 772f70d5..cd81b8a0 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -817,6 +817,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onChange={(e) => { const file = e.target.files?.[0]; if (file) { + const allowedTypes = [ + 'application/pdf', + 'image/jpeg', + 'image/jpg', + ]; + if (!allowedTypes.includes(file.type)) { + toast.error( + 'Mohon upload file berformat PDF atau JPEG/JPG.' + ); + return; + } if (file.size > 2 * 1024 * 1024) { toast.error('Ukuran dokumen maksimal 2 MB!'); return; From 6facfd3d3c2d89e706db63b815e29f66b5a03f20 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 14:00:58 +0700 Subject: [PATCH 019/212] feat(FE-65): enhance MovementForm to support file uploads with FormData conversion --- .../inventory/movement/form/MovementForm.tsx | 17 +++++-- .../movement/form/useMovementFormHandlers.ts | 13 +++++- src/lib/form-data.ts | 45 +++++++++++++++++++ src/services/api/base.ts | 26 +++++++---- 4 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/lib/form-data.ts diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index cd81b8a0..e912a453 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -24,13 +24,17 @@ import { ProductSchema, EkspedisiSchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; -import { useMovementFormHandlers } from './useMovementFormHandlers'; +import { + useMovementFormHandlers, + containsFile, +} from './useMovementFormHandlers'; import { ProductApi, SupplierApi, WarehouseApi, } from '@/services/api/master-data'; import { toast } from 'react-hot-toast'; +import FileInput from '@/components/input/FileInput'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; @@ -62,6 +66,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { validationSchema: type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, onSubmit: async (values) => { + console.log( + 'Dokumen:', + values.ekspedisi?.map((e) => e.dokumen) + ); + setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { alasan_transfer: values.alasan_transfer, @@ -88,6 +97,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { })), }; + console.log('containsFile:', containsFile(payload)); + console.log('payload:', payload); + switch (type) { case 'add': await createMovementHandler(payload); @@ -810,9 +822,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { />
{type !== 'detail' && ( @@ -515,6 +565,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'bobot_badan', + 'berat_ayam', + idx + )} readOnly={type === 'detail'} /> @@ -526,6 +581,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.jumlah_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'bobot_badan', + 'jumlah_ayam', + idx + )} readOnly={type === 'detail'} /> @@ -537,6 +597,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.rata_rata_berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'bobot_badan', + 'rata_rata_berat_ayam', + idx + )} readOnly={type === 'detail'} /> @@ -658,7 +723,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.nama_vaksin} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'vaksinasi', + 'nama_vaksin', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> {type !== 'detail' && ( @@ -805,6 +894,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { (val as OptionType)?.value ); }} + isError={isRepeaterInputError( + 'mortalitas', + 'kondisi', + idx + )} options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} /> @@ -817,7 +911,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={mortal.jumlah} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'mortalitas', + 'jumlah', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> {type !== 'detail' && ( From 56a9fc2349f8aa20ba56aa6c56e3ef433a516e3b Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:51:06 +0700 Subject: [PATCH 029/212] refactor(FE-62,65): simplify error handling in MovementForm by consolidating error checks --- .../inventory/movement/form/MovementForm.tsx | 90 ++++++++----------- 1 file changed, 37 insertions(+), 53 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index e5c90f4a..5d69f03e 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -38,24 +38,6 @@ interface MovementFormProps { initialValues?: Movement; } -function getEkspedisiFieldError( - formik: FormikProps, - idx: number, - field: keyof EkspedisiSchema -) { - const errorObj = formik.errors.ekspedisi?.[idx]; - const touched = formik.touched.ekspedisi?.[idx]?.[field]; - const isError = - touched && - typeof errorObj === 'object' && - !!(errorObj as Record)?.[field]; - const errorMessage = - typeof errorObj === 'object' - ? (errorObj as Record)?.[field] - : undefined; - return { isError, errorMessage }; -} - const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); @@ -212,16 +194,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { !formik.touched[arrayName] || !Array.isArray(formik.touched[arrayName]) ) { - return false; + return { + isError: false, + errorMessage: undefined, + }; } const touchedField = formik.touched[arrayName]?.[idx]?.[column as string]; const errorField = formik.errors[arrayName]?.[idx] as Record< string, - unknown + string >; - return touchedField && Boolean(errorField?.[column as string]); + return { + isError: touchedField && Boolean(errorField?.[column as string]), + errorMessage: touchedField ? errorField?.[column as string] : undefined, + }; }; interface WarehouseOptionType extends OptionType { @@ -571,11 +559,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingProducts} isDisabled={type === 'detail'} isClearable - isError={isRepeaterInputError( - 'product', - 'product', - idx - )} + {...isRepeaterInputError('product', 'product', idx)} /> - {formik.values.product?.map((product, idx) => ( + {formik.values.products?.map((product, idx) => ( {type !== 'detail' && (
- { const file = e.target.files?.[0]; diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index f6531bf4..0b6b0962 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -8,6 +8,7 @@ import { UpdateMovementPayload, } from '@/types/api/inventory/movement'; import { isResponseError } from '@/lib/api-helper'; +import { containsFile, toFormData } from '@/lib/form-data'; export const useMovementFormHandlers = (initialValuesId?: number) => { const router = useRouter(); @@ -17,7 +18,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const createMovementHandler = useCallback( async (payload: CreateMovementPayload) => { - const res = await MovementApi.create(payload); + const finalPayload = containsFile(payload) + ? (toFormData(payload) as unknown as CreateMovementPayload) + : payload; + + const res = await MovementApi.create(finalPayload); if (isResponseError(res)) { setMovementFormErrorMessage(res.message); return; @@ -30,7 +35,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const updateMovementHandler = useCallback( async (movementId: number, payload: UpdateMovementPayload) => { - const res = await MovementApi.update(movementId, payload); + const finalPayload = containsFile(payload) + ? (toFormData(payload) as unknown as UpdateMovementPayload) + : payload; + + const res = await MovementApi.update(movementId, finalPayload); if (res?.status === 'error') { setMovementFormErrorMessage(res.message); return; diff --git a/src/lib/form-data.ts b/src/lib/form-data.ts new file mode 100644 index 00000000..d94e0724 --- /dev/null +++ b/src/lib/form-data.ts @@ -0,0 +1,45 @@ +export function toFormData( + value: unknown, + form = new FormData(), + parentKey?: string +) { + if (value === undefined || value === null) { + if (parentKey) form.append(parentKey, ''); + return form; + } + + if (value instanceof File) { + if (!parentKey) throw new Error('File must have a key'); + form.append(parentKey, value); + return form; + } + + if (Array.isArray(value)) { + value.forEach((v, i) => { + const key = parentKey ? `${parentKey}[${i}]` : `${i}`; + toFormData(v, form, key); + }); + return form; + } + + if (typeof value === 'object') { + Object.entries(value as Record).forEach(([k, v]) => { + const key = parentKey ? `${parentKey}[${k}]` : k; + toFormData(v, form, key); + }); + return form; + } + + if (parentKey) form.append(parentKey, String(value)); + return form; +} + +export function containsFile(obj: unknown): boolean { + if (!obj) return false; + if (obj instanceof File) return true; + if (Array.isArray(obj)) return obj.some(containsFile); + if (typeof obj === 'object') { + return Object.values(obj as Record).some(containsFile); + } + return false; +} diff --git a/src/services/api/base.ts b/src/services/api/base.ts index d1ac4729..c4dd826e 100644 --- a/src/services/api/base.ts +++ b/src/services/api/base.ts @@ -4,9 +4,11 @@ import { BaseApiResponse } from '@/types/api/api-general'; export class BaseApiService { basePath: string; + header?: Record; - constructor(basePath: string) { + constructor(basePath: string, header?: Record) { this.basePath = basePath; + this.header = header; } async getAllFetcher(endpoint: string): Promise> { @@ -23,42 +25,52 @@ export class BaseApiService { if (axios.isAxiosError>(error)) { return error.response?.data; } - return undefined; } } async create(payload: CreatePayloadGeneric) { + const isFormData = + typeof FormData !== 'undefined' && payload instanceof FormData; try { + const headers = isFormData + ? { ...(this.header ?? {}) } + : { 'Content-Type': 'application/json', ...(this.header ?? {}) }; + const createRes = await httpClient>(this.basePath, { method: 'POST', body: payload, + headers, }); - return createRes; } catch (error: unknown) { if (axios.isAxiosError>(error)) { return error.response?.data; } - return undefined; } } async update(id: number, payload: UpdatePayloadGeneric) { + const isFormData = + typeof FormData !== 'undefined' && payload instanceof FormData; try { const updatePath = `${this.basePath}/${id}`; + + const headers = isFormData + ? { ...(this.header ?? {}) } + : { 'Content-Type': 'application/json', ...(this.header ?? {}) }; + const updateRes = await httpClient>(updatePath, { method: 'PATCH', body: payload, + headers, }); - return updateRes; } catch (error: unknown) { if (axios.isAxiosError>(error)) { return error.response?.data; } - return undefined; } } @@ -69,13 +81,11 @@ export class BaseApiService { const deleteRes = await httpClient(deletePath, { method: 'DELETE', }); - return deleteRes; } catch (error) { if (axios.isAxiosError(error)) { return error.response?.data; } - return undefined; } } From 19bca9ec730438e8831c100ac0d6d56daaa829d3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 14:00:58 +0700 Subject: [PATCH 020/212] feat(FE-65): enhance MovementForm to support file uploads with FormData conversion --- .../inventory/movement/form/MovementForm.tsx | 13 +++++- .../movement/form/useMovementFormHandlers.ts | 13 +++++- src/lib/form-data.ts | 45 +++++++++++++++++++ src/services/api/base.ts | 26 +++++++---- 4 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 src/lib/form-data.ts diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index cd81b8a0..89546442 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -31,6 +31,8 @@ import { WarehouseApi, } from '@/services/api/master-data'; import { toast } from 'react-hot-toast'; +import FileInput from '@/components/input/FileInput'; +import { containsFile } from '@/lib/form-data'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; @@ -62,6 +64,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { validationSchema: type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, onSubmit: async (values) => { + console.log( + 'Dokumen:', + values.ekspedisi?.map((e) => e.dokumen) + ); + setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { alasan_transfer: values.alasan_transfer, @@ -88,6 +95,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { })), }; + console.log('containsFile:', containsFile(payload)); + console.log('payload:', payload); + switch (type) { case 'add': await createMovementHandler(payload); @@ -810,9 +820,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { /> - { const file = e.target.files?.[0]; diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index f6531bf4..0b6b0962 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -8,6 +8,7 @@ import { UpdateMovementPayload, } from '@/types/api/inventory/movement'; import { isResponseError } from '@/lib/api-helper'; +import { containsFile, toFormData } from '@/lib/form-data'; export const useMovementFormHandlers = (initialValuesId?: number) => { const router = useRouter(); @@ -17,7 +18,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const createMovementHandler = useCallback( async (payload: CreateMovementPayload) => { - const res = await MovementApi.create(payload); + const finalPayload = containsFile(payload) + ? (toFormData(payload) as unknown as CreateMovementPayload) + : payload; + + const res = await MovementApi.create(finalPayload); if (isResponseError(res)) { setMovementFormErrorMessage(res.message); return; @@ -30,7 +35,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const updateMovementHandler = useCallback( async (movementId: number, payload: UpdateMovementPayload) => { - const res = await MovementApi.update(movementId, payload); + const finalPayload = containsFile(payload) + ? (toFormData(payload) as unknown as UpdateMovementPayload) + : payload; + + const res = await MovementApi.update(movementId, finalPayload); if (res?.status === 'error') { setMovementFormErrorMessage(res.message); return; diff --git a/src/lib/form-data.ts b/src/lib/form-data.ts new file mode 100644 index 00000000..d94e0724 --- /dev/null +++ b/src/lib/form-data.ts @@ -0,0 +1,45 @@ +export function toFormData( + value: unknown, + form = new FormData(), + parentKey?: string +) { + if (value === undefined || value === null) { + if (parentKey) form.append(parentKey, ''); + return form; + } + + if (value instanceof File) { + if (!parentKey) throw new Error('File must have a key'); + form.append(parentKey, value); + return form; + } + + if (Array.isArray(value)) { + value.forEach((v, i) => { + const key = parentKey ? `${parentKey}[${i}]` : `${i}`; + toFormData(v, form, key); + }); + return form; + } + + if (typeof value === 'object') { + Object.entries(value as Record).forEach(([k, v]) => { + const key = parentKey ? `${parentKey}[${k}]` : k; + toFormData(v, form, key); + }); + return form; + } + + if (parentKey) form.append(parentKey, String(value)); + return form; +} + +export function containsFile(obj: unknown): boolean { + if (!obj) return false; + if (obj instanceof File) return true; + if (Array.isArray(obj)) return obj.some(containsFile); + if (typeof obj === 'object') { + return Object.values(obj as Record).some(containsFile); + } + return false; +} diff --git a/src/services/api/base.ts b/src/services/api/base.ts index d1ac4729..c4dd826e 100644 --- a/src/services/api/base.ts +++ b/src/services/api/base.ts @@ -4,9 +4,11 @@ import { BaseApiResponse } from '@/types/api/api-general'; export class BaseApiService { basePath: string; + header?: Record; - constructor(basePath: string) { + constructor(basePath: string, header?: Record) { this.basePath = basePath; + this.header = header; } async getAllFetcher(endpoint: string): Promise> { @@ -23,42 +25,52 @@ export class BaseApiService { if (axios.isAxiosError>(error)) { return error.response?.data; } - return undefined; } } async create(payload: CreatePayloadGeneric) { + const isFormData = + typeof FormData !== 'undefined' && payload instanceof FormData; try { + const headers = isFormData + ? { ...(this.header ?? {}) } + : { 'Content-Type': 'application/json', ...(this.header ?? {}) }; + const createRes = await httpClient>(this.basePath, { method: 'POST', body: payload, + headers, }); - return createRes; } catch (error: unknown) { if (axios.isAxiosError>(error)) { return error.response?.data; } - return undefined; } } async update(id: number, payload: UpdatePayloadGeneric) { + const isFormData = + typeof FormData !== 'undefined' && payload instanceof FormData; try { const updatePath = `${this.basePath}/${id}`; + + const headers = isFormData + ? { ...(this.header ?? {}) } + : { 'Content-Type': 'application/json', ...(this.header ?? {}) }; + const updateRes = await httpClient>(updatePath, { method: 'PATCH', body: payload, + headers, }); - return updateRes; } catch (error: unknown) { if (axios.isAxiosError>(error)) { return error.response?.data; } - return undefined; } } @@ -69,13 +81,11 @@ export class BaseApiService { const deleteRes = await httpClient(deletePath, { method: 'DELETE', }); - return deleteRes; } catch (error) { if (axios.isAxiosError(error)) { return error.response?.data; } - return undefined; } } From 4b4b74d07cb23cb223df6020a25fc9e97231f6ad Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 18:00:34 +0700 Subject: [PATCH 021/212] feat(FE-65): add validation for quantity and required fields in MovementForm --- src/components/helper/form/FormActions.tsx | 4 +- .../movement/form/MovementForm.schema.ts | 20 +++- .../inventory/movement/form/MovementForm.tsx | 98 ++++++++++--------- 3 files changed, 70 insertions(+), 52 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 25b586c3..7ced46cd 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -8,6 +8,7 @@ interface FormActionsProps { formik: FormikContextType; editUrl?: string; onDelete?: () => void; + disableSubmit?: boolean; } export const FormActions = ({ @@ -15,6 +16,7 @@ export const FormActions = ({ formik, editUrl, onDelete, + disableSubmit = false, }: FormActionsProps) => { return (
@@ -71,7 +73,7 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={!formik.isValid || formik.isSubmitting} + disabled={disableSubmit || !formik.isValid || formik.isSubmitting} > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index aa138cac..453ca40b 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -51,7 +51,17 @@ const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ qty: Yup.number() .required('Qty wajib diisi!') .min(1, 'Qty minimal 1!') - .typeError('Qty harus berupa angka!'), + .typeError('Qty harus berupa angka!') + .test('max-product-qty', 'Qty melebihi stok produk!', function (value) { + const { product_id } = this.parent; + const products = (this.options.context?.product ?? []) as { + product_id: number; + qty_product: number; + }[]; + const product = products.find((p) => p.product_id === product_id); + if (!product) return true; + return (value ?? 0) <= Number(product.qty_product); + }), supplier: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), @@ -108,8 +118,12 @@ export const MovementFormSchema = Yup.object({ .typeError('Gudang tujuan wajib diisi!'), product: Yup.array() .of(ProductObjectSchema) - .min(1, 'Minimal harus ada 1 produk!'), - ekspedisi: Yup.array().of(EkspedisiObjectSchema).optional().default([]), + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), + ekspedisi: Yup.array() + .of(EkspedisiObjectSchema) + .min(1, 'Minimal harus ada 1 ekspedisi!') + .required('Ekspedisi wajib diisi!'), }); export const UpdateMovementFormSchema = MovementFormSchema; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 89546442..e5c90f4a 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useFormik } from 'formik'; +import { FormikProps, useFormik } from 'formik'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; @@ -32,13 +32,30 @@ import { } from '@/services/api/master-data'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; -import { containsFile } from '@/lib/form-data'; interface MovementFormProps { type?: 'add' | 'edit' | 'detail'; initialValues?: Movement; } +function getEkspedisiFieldError( + formik: FormikProps, + idx: number, + field: keyof EkspedisiSchema +) { + const errorObj = formik.errors.ekspedisi?.[idx]; + const touched = formik.touched.ekspedisi?.[idx]?.[field]; + const isError = + touched && + typeof errorObj === 'object' && + !!(errorObj as Record)?.[field]; + const errorMessage = + typeof errorObj === 'object' + ? (errorObj as Record)?.[field] + : undefined; + return { isError, errorMessage }; +} + const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); @@ -63,12 +80,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { initialValues: formikInitialValues, validationSchema: type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, + validateOnChange: true, + validateOnBlur: true, onSubmit: async (values) => { - console.log( - 'Dokumen:', - values.ekspedisi?.map((e) => e.dokumen) - ); - setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { alasan_transfer: values.alasan_transfer, @@ -95,9 +109,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { })), }; - console.log('containsFile:', containsFile(payload)); - console.log('payload:', payload); - switch (type) { case 'add': await createMovementHandler(payload); @@ -292,12 +303,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const validateEkspedisiQty = (ekspedisiIdx: number, qty: number) => { const productId = formik.values.ekspedisi?.[ekspedisiIdx]?.product_id; if (!productId) return true; - const relatedProduct = formik.values.product?.find( (p) => p.product_id === productId ); if (!relatedProduct) return true; - const totalQtyUsed = formik.values.ekspedisi?.reduce((total, eks, i) => { if (eks.product_id === productId && i !== ekspedisiIdx) { @@ -305,10 +314,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } return total; }, 0) || 0; - return totalQtyUsed + qty <= Number(relatedProduct.qty_product); }; + const invalidQtyRows = + formik.values.ekspedisi?.map((eks, idx) => { + const qty = Number(eks.qty) || 0; + return !validateEkspedisiQty(idx, qty); + }) ?? []; + return ( <>
@@ -735,22 +749,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type='number' name={`ekspedisi.${idx}.qty`} value={ekspedisi.qty ?? ''} - onChange={(e) => { - const newQty = Number(e.target.value); - if (validateEkspedisiQty(idx, newQty)) { - formik.handleChange(e); - } else { - toast.error( - 'Quantity exceeds available product quantity' - ); - } - }} + onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'ekspedisi', - 'qty', - idx - )} + {...getEkspedisiFieldError(formik, idx, 'qty')} readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -790,10 +791,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.plat_nomor ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'ekspedisi', - 'plat_nomor', - idx + {...getEkspedisiFieldError( + formik, + idx, + 'plat_nomor' )} readOnly={type === 'detail'} className={{ @@ -808,10 +809,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.no_surat_jalan ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'ekspedisi', - 'no_surat_jalan', - idx + {...getEkspedisiFieldError( + formik, + idx, + 'no_surat_jalan' )} readOnly={type === 'detail'} className={{ @@ -866,10 +867,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.biaya_ekspedisi ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'ekspedisi', - 'biaya_ekspedisi', - idx + {...getEkspedisiFieldError( + formik, + idx, + 'biaya_ekspedisi' )} readOnly={type === 'detail'} className={{ @@ -882,10 +883,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { disabled onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'ekspedisi', - 'biaya_ekspedisi_per_item', - idx + {...getEkspedisiFieldError( + formik, + idx, + 'biaya_ekspedisi_per_item' )} name={`ekspedisi.${idx}.biaya_ekspedisi_per_item`} value={ @@ -909,10 +910,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.nama_sopir ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'ekspedisi', - 'nama_sopir', - idx + {...getEkspedisiFieldError( + formik, + idx, + 'nama_sopir' )} readOnly={type === 'detail'} className={{ @@ -982,6 +983,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { : undefined } onDelete={deleteMovementClickHandler} + disableSubmit={invalidQtyRows.some(Boolean)} /> {movementFormErrorMessage && ( From 1869fa8dc55cf1e5e9105981c8614866532b86af Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 22:03:09 +0700 Subject: [PATCH 022/212] feat(FE-136): add flock and recording management with validation in forms --- .../recording/form/RecordingForm.schema.ts | 121 ++++++++++++++++++ src/config/constant.ts | 30 +++++ src/services/api/flock.ts | 22 ++++ src/types/api/flock/flock.d.ts | 14 ++ src/types/api/flock/recording.d.ts | 54 ++++++++ 5 files changed, 241 insertions(+) create mode 100644 src/components/pages/flock/recording/form/RecordingForm.schema.ts create mode 100644 src/services/api/flock.ts create mode 100644 src/types/api/flock/flock.d.ts create mode 100644 src/types/api/flock/recording.d.ts diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts new file mode 100644 index 00000000..f78e65e1 --- /dev/null +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -0,0 +1,121 @@ +import * as Yup from 'yup'; +import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; + +export const RecordingFormSchema = Yup.object({ + flock_id: Yup.number().required('Flock wajib diisi!'), + tanggal: Yup.string().required('Tanggal wajib diisi!'), + data_pakan: Yup.array() + .of( + Yup.object({ + nama_pakan: Yup.string().required('Nama pakan wajib diisi!'), + qty_pakan: Yup.number() + .required('Qty pakan wajib diisi!') + .min(1, 'Qty minimal 1!'), + stock_pakan: Yup.number() + .required('Stock pakan wajib diisi!') + .min(0, 'Stock minimal 0!'), + }) + ) + .min(1, 'Minimal harus ada 1 data pakan!') + .required('Data pakan wajib diisi!'), + bobot_badan: Yup.array() + .of( + Yup.object({ + berat_ayam: Yup.number() + .required('Berat ayam wajib diisi!') + .min(1, 'Berat minimal 1!'), + jumlah_ayam: Yup.number() + .required('Jumlah ayam wajib diisi!') + .min(1, 'Jumlah minimal 1!'), + rata_rata_berat_ayam: Yup.number() + .required('Rata-rata berat ayam wajib diisi!') + .min(1, 'Rata-rata minimal 1!'), + }) + ) + .min(1, 'Minimal harus ada 1 data bobot badan!') + .required('Data bobot badan wajib diisi!'), + vaksinasi: Yup.array() + .of( + Yup.object({ + nama_vaksin: Yup.string().required('Nama vaksin wajib diisi!'), + total_stock: Yup.number() + .required('Total stock wajib diisi!') + .min(0, 'Total stock minimal 0!'), + jumlah_stock: Yup.number() + .required('Jumlah stock wajib diisi!') + .min(0, 'Jumlah stock minimal 0!'), + }) + ) + .min(1, 'Minimal harus ada 1 data vaksinasi!') + .required('Data vaksinasi wajib diisi!'), + mortalitas: Yup.array() + .of( + Yup.object({ + kondisi: Yup.mixed() + .oneOf( + RECORDING_FLAG_OPTIONS.map((opt) => opt.value), + 'Kondisi tidak valid!' + ) + .required('Kondisi wajib diisi!'), + jumlah: Yup.number() + .required('Jumlah wajib diisi!') + .min(1, 'Jumlah minimal 1!'), + }) + ) + .min(1, 'Minimal harus ada 1 data mortalitas!') + .required('Data mortalitas wajib diisi!'), +}); + +export const UpdateRecordingFormSchema = RecordingFormSchema; + +export type RecordingFormValues = Yup.InferType; + +export const getRecordingFormInitialValues = ( + initialValues?: Partial +): RecordingFormValues => ({ + flock_id: initialValues?.flock_id ?? 0, + tanggal: initialValues?.tanggal ?? '', + data_pakan: + Array.isArray(initialValues?.data_pakan) && + initialValues.data_pakan.length > 0 + ? initialValues.data_pakan + : [ + { + nama_pakan: '', + qty_pakan: 0, + stock_pakan: 0, + }, + ], + bobot_badan: + Array.isArray(initialValues?.bobot_badan) && + initialValues.bobot_badan.length > 0 + ? initialValues.bobot_badan + : [ + { + berat_ayam: 0, + jumlah_ayam: 0, + rata_rata_berat_ayam: 0, + }, + ], + vaksinasi: + Array.isArray(initialValues?.vaksinasi) && + initialValues.vaksinasi.length > 0 + ? initialValues.vaksinasi + : [ + { + nama_vaksin: '', + total_stock: 0, + jumlah_stock: 0, + }, + ], + mortalitas: + Array.isArray(initialValues?.mortalitas) && + initialValues.mortalitas.length > 0 + ? initialValues.mortalitas + : [ + { + kondisi: '', + jumlah: 0, + }, + ], +}); diff --git a/src/config/constant.ts b/src/config/constant.ts index 8f712726..87dcd927 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -12,6 +12,29 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ icon: 'gg:chart', }, + { + title: 'Flock', + link: '/flock', + icon: 'mdi:chicken', + submenu: [ + { + title: 'List Flock', + link: '/flock/list', + icon: 'mdi:chicken', + }, + { + title: 'Chick In', + link: '/flock/chick-in', + icon: 'mdi:home-import-outline', + }, + { + title: 'Recording', + link: '/flock/recording', + icon: 'mdi:clipboard-text', + }, + ], + }, + { title: 'Persediaan', link: '/inventory', @@ -175,3 +198,10 @@ export const PRODUCT_FLAG_OPTIONS = [ export const SUPPLIER_FLAG_OPTIONS = [ { label: 'EKSPEDISI', value: 'EKSPEDISI' }, ]; + +export const RECORDING_FLAG_OPTIONS = [ + { label: 'Ayam Afkir', value: 'Ayam Afkir' }, + { label: 'Ayam Culling', value: 'Ayam Culling' }, + { label: 'Ayam Mati', value: 'Ayam Mati' }, + { label: 'DOC', value: 'DOC' }, +]; diff --git a/src/services/api/flock.ts b/src/services/api/flock.ts new file mode 100644 index 00000000..83fbb0d8 --- /dev/null +++ b/src/services/api/flock.ts @@ -0,0 +1,22 @@ +import { + CreateFlockPayload, + Flock, + UpdateFlockPayload, +} from '@/types/api/flock/flock'; +import { + CreateRecordingPayload, + Recording, + UpdateRecordingPayload, +} from '@/types/api/flock/recording'; +import { BaseApiService } from '@/services/api/base'; + +export const FlockApi = new BaseApiService< + Flock, + CreateFlockPayload, + UpdateFlockPayload +>('/flock/flocks'); +export const RecordingApi = new BaseApiService< + Recording, + CreateRecordingPayload, + UpdateRecordingPayload +>('/flock/recordings'); diff --git a/src/types/api/flock/flock.d.ts b/src/types/api/flock/flock.d.ts new file mode 100644 index 00000000..e0dcfda4 --- /dev/null +++ b/src/types/api/flock/flock.d.ts @@ -0,0 +1,14 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseFlock = { + id: number; + name: string; +}; + +export type Flock = BaseMetadata & BaseFlock; + +export type CreateFlockPayload = { + name: string; +}; + +export type UpdateFlockPayload = CreateFlockPayload; diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts new file mode 100644 index 00000000..66818f0c --- /dev/null +++ b/src/types/api/flock/recording.d.ts @@ -0,0 +1,54 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { Flock } from '@/types/api/flock/flock'; + +export type BaseRecording = { + id: number; + flock: Flock; + data_pakan: { + nama_pakan: string; + qty_pakan: number; + stock_pakan; + }[]; + bobot_badan: { + berat_ayam: number; + jumlah_ayam: number; + rata_rata_berat_ayam: number; + }[]; + vaksinasi: { + nama_vaksin: string; + total_stock: number; + jumlah_stock: number; + }[]; + mortalitas: { + kondisi: string; + jumlah: number; + }[]; +}; + +export type Recording = BaseMetadata & BaseRecording; + +export type CreateRecordingPayload = { + flock_id: number; + tanggal: string; + data_pakan: { + nama_pakan: string; + qty_pakan: number; + stock_pakan: number; + }[]; + bobot_badan: { + berat_ayam: number; + jumlah_ayam: number; + rata_rata_berat_ayam: number; + }[]; + vaksinasi: { + nama_vaksin: string; + total_stock: number; + jumlah_stock: number; + }[]; + mortalitas: { + kondisi: string; + jumlah: number; + }[]; +}; + +export type UpdateRecordingPayload = CreateRecordingPayload; From 6dcb97bcacdb671740863371e9e0c70c862c0f11 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 22:03:51 +0700 Subject: [PATCH 023/212] feat(FE-114,129): add RecordingForm and RecordingTable components with handlers --- src/components/pages/flock/recording/RecordingTable.tsx | 0 src/components/pages/flock/recording/form/RecordingForm.tsx | 0 .../pages/flock/recording/form/useRecordingFormHandlers.ts | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/components/pages/flock/recording/RecordingTable.tsx create mode 100644 src/components/pages/flock/recording/form/RecordingForm.tsx create mode 100644 src/components/pages/flock/recording/form/useRecordingFormHandlers.ts diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts b/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts new file mode 100644 index 00000000..e69de29b From 89318407eaa0c242fa3018d5f0ad534d2763dec4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 23:01:01 +0700 Subject: [PATCH 024/212] feat(FE-136): update RecordingForm schema to remove tanggal and add flock object --- .../recording/form/RecordingForm.schema.ts | 87 +++++++++---------- src/types/api/flock/recording.d.ts | 1 - 2 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index f78e65e1..ffd9e2bd 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -1,9 +1,13 @@ import * as Yup from 'yup'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; +import { Recording } from '@/types/api/flock/recording'; export const RecordingFormSchema = Yup.object({ + flock: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), flock_id: Yup.number().required('Flock wajib diisi!'), - tanggal: Yup.string().required('Tanggal wajib diisi!'), data_pakan: Yup.array() .of( Yup.object({ @@ -71,51 +75,40 @@ export const UpdateRecordingFormSchema = RecordingFormSchema; export type RecordingFormValues = Yup.InferType; export const getRecordingFormInitialValues = ( - initialValues?: Partial + initialValues?: Recording ): RecordingFormValues => ({ - flock_id: initialValues?.flock_id ?? 0, - tanggal: initialValues?.tanggal ?? '', - data_pakan: - Array.isArray(initialValues?.data_pakan) && - initialValues.data_pakan.length > 0 - ? initialValues.data_pakan - : [ - { - nama_pakan: '', - qty_pakan: 0, - stock_pakan: 0, - }, - ], - bobot_badan: - Array.isArray(initialValues?.bobot_badan) && - initialValues.bobot_badan.length > 0 - ? initialValues.bobot_badan - : [ - { - berat_ayam: 0, - jumlah_ayam: 0, - rata_rata_berat_ayam: 0, - }, - ], - vaksinasi: - Array.isArray(initialValues?.vaksinasi) && - initialValues.vaksinasi.length > 0 - ? initialValues.vaksinasi - : [ - { - nama_vaksin: '', - total_stock: 0, - jumlah_stock: 0, - }, - ], - mortalitas: - Array.isArray(initialValues?.mortalitas) && - initialValues.mortalitas.length > 0 - ? initialValues.mortalitas - : [ - { - kondisi: '', - jumlah: 0, - }, - ], + flock: initialValues?.flock + ? { + value: initialValues.flock.id, + label: initialValues.flock.name, + } + : null, + flock_id: initialValues?.flock?.id ?? 0, + data_pakan: initialValues?.data_pakan ?? [ + { + nama_pakan: '', + qty_pakan: 0, + stock_pakan: 0, + }, + ], + bobot_badan: initialValues?.bobot_badan ?? [ + { + berat_ayam: 0, + jumlah_ayam: 0, + rata_rata_berat_ayam: 0, + }, + ], + vaksinasi: initialValues?.vaksinasi ?? [ + { + nama_vaksin: '', + total_stock: 0, + jumlah_stock: 0, + }, + ], + mortalitas: initialValues?.mortalitas ?? [ + { + kondisi: '', + jumlah: 0, + }, + ], }); diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 66818f0c..7fc873ef 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -29,7 +29,6 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { flock_id: number; - tanggal: string; data_pakan: { nama_pakan: string; qty_pakan: number; From b1a3796ecaa61dbefad07d83357cce7e1c3c456a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:28:57 +0700 Subject: [PATCH 025/212] feat(FE-114,136): implement RecordingForm component with data handling and validation --- .../flock/recording/form/RecordingForm.tsx | 873 ++++++++++++++++++ .../form/useRecordingFormHandlers.ts | 70 ++ 2 files changed, 943 insertions(+) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index e69de29b..1b09b274 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -0,0 +1,873 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormikProps, useFormik } from 'formik'; +import { Icon } from '@iconify/react'; +import { toast } from 'react-hot-toast'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { FormActions } from '@/components/helper/form/FormActions'; +import { CreateRecordingPayload, Recording } from '@/types/api/flock/recording'; +import { + RecordingFormSchema, + RecordingFormValues, + getRecordingFormInitialValues, + UpdateRecordingFormSchema, +} from './RecordingForm.schema'; +import { useRecordingFormHandlers } from './useRecordingFormHandlers'; +import { FlockApi } from '@/services/api/flock'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; +import useSWR from 'swr'; + +interface RecordingFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Recording; +} + +const DUMMY_FLOCKS = [ + { value: 1, label: 'Flock A' }, + { value: 2, label: 'Flock B' }, + { value: 3, label: 'Flock C' }, + { value: 4, label: 'Flock D' }, +]; + +const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { + const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); + const [selectedPakan, setSelectedPakan] = useState([]); + const [selectedBobot, setSelectedBobot] = useState([]); + const [selectedVaksin, setSelectedVaksin] = useState([]); + const [selectedMortal, setSelectedMortal] = useState([]); + const [, setRecordingFormErrorMessage] = useState(''); + const { + deleteModal, + recordingFormErrorMessage, + isDeleteLoading, + createRecordingHandler, + updateRecordingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + } = useRecordingFormHandlers(initialValues?.id); + + const formikInitialValues = useMemo( + () => getRecordingFormInitialValues(initialValues), + [initialValues] + ); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateRecordingFormSchema : RecordingFormSchema, + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + setRecordingFormErrorMessage(''); + const payload: CreateRecordingPayload = { + flock_id: values.flock_id, + data_pakan: (values.data_pakan ?? []).map((p) => ({ + nama_pakan: p.nama_pakan, + qty_pakan: p.qty_pakan, + stock_pakan: p.stock_pakan, + })), + bobot_badan: (values.bobot_badan ?? []).map((b) => ({ + berat_ayam: b.berat_ayam, + jumlah_ayam: b.jumlah_ayam, + rata_rata_berat_ayam: b.rata_rata_berat_ayam, + })), + vaksinasi: (values.vaksinasi ?? []).map((v) => ({ + nama_vaksin: v.nama_vaksin, + total_stock: v.total_stock, + jumlah_stock: v.jumlah_stock, + })), + mortalitas: (values.mortalitas ?? []).map((m) => ({ + kondisi: m.kondisi, + jumlah: m.jumlah, + })), + }; + + switch (type) { + case 'add': + await createRecordingHandler(payload); + break; + case 'edit': + await updateRecordingHandler(initialValues?.id as number, payload); + break; + } + }, + }); + + // // Flock selection + // const flocksUrl = `${FlockApi.basePath}?${new URLSearchParams({ search: flockSelectInputValue }).toString()}`; + // const { data: flocks, isLoading: isLoadingFlocks } = useSWR( + // flocksUrl, + // FlockApi.getAllFetcher + // ); + // const flockOptions = isResponseSuccess(flocks) + // ? flocks?.data.map((f) => ({ value: f.id, label: f.name })) + // : []; + + const flockOptions = DUMMY_FLOCKS; + + const addDataPakan = () => { + const newDataPakan = [ + ...(formik.values.data_pakan || []), + { + nama_pakan: '', + qty_pakan: 0, + stock_pakan: 0, + }, + ]; + formik.setFieldValue('data_pakan', newDataPakan); + }; + + const removeDataPakan = (idx: number) => { + const updatedDataPakan = formik.values.data_pakan?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('data_pakan', updatedDataPakan); + }; + + const removeSelectedDataPakan = () => { + const updatedDataPakan = formik.values.data_pakan?.filter( + (_, idx) => !selectedPakan.includes(idx) + ); + formik.setFieldValue('data_pakan', updatedDataPakan); + setSelectedPakan([]); + }; + + const addBobotBadan = () => { + const newBobotBadan = [ + ...(formik.values.bobot_badan || []), + { + berat_ayam: 0, + jumlah_ayam: 0, + rata_rata_berat_ayam: 0, + }, + ]; + formik.setFieldValue('bobot_badan', newBobotBadan); + }; + + const removeBobotBadan = (idx: number) => { + const updatedBobotBadan = formik.values.bobot_badan?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('bobot_badan', updatedBobotBadan); + }; + + const removeSelectedBobotBadan = () => { + const updatedBobotBadan = formik.values.bobot_badan?.filter( + (_, idx) => !selectedBobot.includes(idx) + ); + formik.setFieldValue('bobot_badan', updatedBobotBadan); + setSelectedBobot([]); + }; + + const addVaksinasi = () => { + const newVaksinasi = [ + ...(formik.values.vaksinasi || []), + { + nama_vaksin: '', + total_stock: 0, + jumlah_stock: 0, + }, + ]; + formik.setFieldValue('vaksinasi', newVaksinasi); + }; + + const removeVaksinasi = (idx: number) => { + const updatedVaksinasi = formik.values.vaksinasi?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('vaksinasi', updatedVaksinasi); + }; + + const removeSelectedVaksinasi = () => { + const updatedVaksinasi = formik.values.vaksinasi?.filter( + (_, idx) => !selectedVaksin.includes(idx) + ); + formik.setFieldValue('vaksinasi', updatedVaksinasi); + setSelectedVaksin([]); + }; + + const addMortalitas = () => { + const newMortalitas = [ + ...(formik.values.mortalitas || []), + { + kondisi: RECORDING_FLAG_OPTIONS[0].value, + jumlah: 0, + }, + ]; + formik.setFieldValue('mortalitas', newMortalitas); + }; + + const removeMortalitas = (idx: number) => { + const updatedMortalitas = formik.values.mortalitas?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('mortalitas', updatedMortalitas); + }; + + const removeSelectedMortalitas = () => { + const updatedMortalitas = formik.values.mortalitas?.filter( + (_, idx) => !selectedMortal.includes(idx) + ); + formik.setFieldValue('mortalitas', updatedMortalitas); + setSelectedMortal([]); + }; + + return ( + <> +
+ +
+ {/* Basic Info Card */} +
+
+
+ { + formik.setFieldValue('flock', val); + formik.setFieldValue( + 'flock_id', + (val as OptionType)?.value + ); + }} + options={flockOptions} + onInputChange={(val) => { + // Filter options locally instead of API call + return val; + }} + isLoading={false} // Remove isLoadingFlocks + isError={ + formik.touched.flock_id && Boolean(formik.errors.flock_id) + } + errorMessage={formik.errors.flock_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+
+ + {/* Data Pakan Table */} +
+
+

Data Pakan

+
+ + + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && } + + + + {formik.values.data_pakan?.map((pakan, idx) => ( + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedPakan( + formik.values.data_pakan?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedPakan([]); + } + }} + /> + Nama PakanQty PakanStock PakanAksi
+ { + if (e.target.checked) { + setSelectedPakan([...selectedPakan, idx]); + } else { + setSelectedPakan( + selectedPakan.filter((i) => i !== idx) + ); + } + }} + /> + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedPakan.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Bobot Badan Table */} +
+
+

Bobot Badan

+
+ + + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && } + + + + {formik.values.bobot_badan?.map((bobot, idx) => ( + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedBobot( + formik.values.bobot_badan?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedBobot([]); + } + }} + /> + Berat AyamJumlah AyamRata-rata Berat AyamAksi
+ { + if (e.target.checked) { + setSelectedBobot([...selectedBobot, idx]); + } else { + setSelectedBobot( + selectedBobot.filter((i) => i !== idx) + ); + } + }} + /> + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedBobot.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Vaksinasi Table */} +
+
+

Vaksinasi

+
+ + + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && } + + + + {formik.values.vaksinasi?.map((vaksin, idx) => ( + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedVaksin( + formik.values.vaksinasi?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedVaksin([]); + } + }} + /> + Nama VaksinTotal StockJumlah StockAksi
+ { + if (e.target.checked) { + setSelectedVaksin([...selectedVaksin, idx]); + } else { + setSelectedVaksin( + selectedVaksin.filter((i) => i !== idx) + ); + } + }} + /> + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedVaksin.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Mortalitas Table */} +
+
+

Mortalitas

+
+ + + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && } + + + + {formik.values.mortalitas?.map((mortal, idx) => ( + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedMortal( + formik.values.mortalitas?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedMortal([]); + } + }} + /> + KondisiJumlahAksi
+ { + if (e.target.checked) { + setSelectedMortal([...selectedMortal, idx]); + } else { + setSelectedMortal( + selectedMortal.filter((i) => i !== idx) + ); + } + }} + /> + + opt.value === mortal.kondisi + )} + onChange={(val) => { + formik.setFieldValue( + `mortalitas.${idx}.kondisi`, + (val as OptionType)?.value + ); + }} + options={RECORDING_FLAG_OPTIONS} + isDisabled={type === 'detail'} + /> + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedMortal.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Action buttons */} + + type={type} + formik={formik} + editUrl={ + initialValues + ? `/flock/recording/detail/edit/?recordingId=${initialValues.id}` + : undefined + } + onDelete={deleteRecordingClickHandler} + /> + + {recordingFormErrorMessage && ( +
+ + {recordingFormErrorMessage} +
+ )} + +
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default RecordingForm; diff --git a/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts b/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts index e69de29b..a508806f 100644 --- a/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts +++ b/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-hot-toast'; +import { useModal } from '@/components/Modal'; +import { RecordingApi } from '@/services/api/flock'; +import { + CreateRecordingPayload, + UpdateRecordingPayload, +} from '@/types/api/flock/recording'; +import { isResponseError } from '@/lib/api-helper'; + +export const useRecordingFormHandlers = (initialValuesId?: number) => { + const router = useRouter(); + const deleteModal = useModal(); + const [recordingFormErrorMessage, setRecordingFormErrorMessage] = + useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createRecordingHandler = useCallback( + async (payload: CreateRecordingPayload) => { + const res = await RecordingApi.create(payload); + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/flock/recording'); + }, + [router] + ); + + const updateRecordingHandler = useCallback( + async (recordingId: number, payload: UpdateRecordingPayload) => { + const res = await RecordingApi.update(recordingId, payload); + if (res?.status === 'error') { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/flock/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValuesId) return; + + setIsDeleteLoading(true); + await RecordingApi.delete(initialValuesId); + deleteModal.closeModal(); + toast.success('Successfully delete Recording!'); + setIsDeleteLoading(false); + router.push('/flock/recording'); + }, [deleteModal, initialValuesId, router]); + + return { + deleteModal, + recordingFormErrorMessage, + isDeleteLoading, + createRecordingHandler, + updateRecordingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + }; +}; From 53ee4cdc1bb3cf89276f3bae826bea2fdaf05fa1 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:29:29 +0700 Subject: [PATCH 026/212] feat(FE-114): add Layout and AddRecording components with routing link --- src/app/flock/recording/add/page.tsx | 11 +++++++++++ src/app/flock/recording/detail/edit/page.tsx | 0 src/app/flock/recording/detail/layout.tsx | 11 +++++++++++ src/app/flock/recording/detail/page.tsx | 0 src/app/flock/recording/page.tsx | 9 +++++++++ 5 files changed, 31 insertions(+) create mode 100644 src/app/flock/recording/add/page.tsx create mode 100644 src/app/flock/recording/detail/edit/page.tsx create mode 100644 src/app/flock/recording/detail/layout.tsx create mode 100644 src/app/flock/recording/detail/page.tsx create mode 100644 src/app/flock/recording/page.tsx diff --git a/src/app/flock/recording/add/page.tsx b/src/app/flock/recording/add/page.tsx new file mode 100644 index 00000000..50bb1d92 --- /dev/null +++ b/src/app/flock/recording/add/page.tsx @@ -0,0 +1,11 @@ +import RecordingForm from '@/components/pages/flock/recording/form/RecordingForm'; + +const AddRecording = () => { + return ( +
+ +
+ ); +}; + +export default AddRecording; diff --git a/src/app/flock/recording/detail/edit/page.tsx b/src/app/flock/recording/detail/edit/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/flock/recording/detail/layout.tsx b/src/app/flock/recording/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/flock/recording/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/flock/recording/detail/page.tsx b/src/app/flock/recording/detail/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/flock/recording/page.tsx b/src/app/flock/recording/page.tsx new file mode 100644 index 00000000..01154025 --- /dev/null +++ b/src/app/flock/recording/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + <> + Recording + + ); +} From 6f0467918b790229ccd0f741e91b3f91ea4a35d3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:50:21 +0700 Subject: [PATCH 027/212] feat(FE-114): add tanggal_recording field to RecordingForm and update schema validation --- .../recording/form/RecordingForm.schema.ts | 6 +++ .../flock/recording/form/RecordingForm.tsx | 51 ++++++++++++++++++- src/types/api/flock/recording.d.ts | 4 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index ffd9e2bd..90d91c4f 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -8,6 +8,9 @@ export const RecordingFormSchema = Yup.object({ label: Yup.string().required(), }).nullable(), flock_id: Yup.number().required('Flock wajib diisi!'), + tanggal_recording: Yup.date() + .required('Tanggal recording wajib diisi') + .typeError('Format tanggal tidak valid'), data_pakan: Yup.array() .of( Yup.object({ @@ -84,6 +87,9 @@ export const getRecordingFormInitialValues = ( } : null, flock_id: initialValues?.flock?.id ?? 0, + tanggal_recording: initialValues?.tanggal_recording + ? new Date(initialValues.tanggal_recording) + : new Date(), data_pakan: initialValues?.data_pakan ?? [ { nama_pakan: '', diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 1b09b274..cb291720 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -67,6 +67,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { flock_id: values.flock_id, + tanggal_recording: values.tanggal_recording.toISOString(), data_pakan: (values.data_pakan ?? []).map((p) => ({ nama_pakan: p.nama_pakan, qty_pakan: p.qty_pakan, @@ -230,6 +231,33 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
+ {/* {*/} + {/* formik.setFieldValue(*/} + {/* 'flock_id',*/} + {/* (val as OptionType)?.value*/} + {/* );*/} + {/* }}*/} + {/* options={flockOptions}*/} + {/* onInputChange={setFlockSelectInputValue}*/} + {/* isLoading={isLoadingFlocks}*/} + {/* isError={*/} + {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} + {/* }*/} + {/* errorMessage={formik.errors.flock_id as string}*/} + {/* isDisabled={type === 'detail'}*/} + {/* isClearable*/} + {/*/>*/} { }} options={flockOptions} onInputChange={(val) => { - // Filter options locally instead of API call return val; }} - isLoading={false} // Remove isLoadingFlocks + isLoading={false} isError={ formik.touched.flock_id && Boolean(formik.errors.flock_id) } @@ -254,6 +281,26 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isDisabled={type === 'detail'} isClearable /> + { + const date = new Date(e.target.value); + formik.setFieldValue('tanggal_recording', date); + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.tanggal_recording && + Boolean(formik.errors.tanggal_recording) + } + errorMessage={formik.errors.tanggal_recording as string} + readOnly={type === 'detail'} + />
diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 7fc873ef..69c2b0b5 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -4,10 +4,11 @@ import { Flock } from '@/types/api/flock/flock'; export type BaseRecording = { id: number; flock: Flock; + tanggal_recording: string; data_pakan: { nama_pakan: string; qty_pakan: number; - stock_pakan; + stock_pakan: number; }[]; bobot_badan: { berat_ayam: number; @@ -29,6 +30,7 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { flock_id: number; + tanggal_recording: string; data_pakan: { nama_pakan: string; qty_pakan: number; From 24144f01d4bce7495776fdebdc10e698ae96a928 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:37:04 +0700 Subject: [PATCH 028/212] feat(FE-114,136): add error handling for repeater inputs in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index cb291720..c0efe1fa 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -112,6 +112,32 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockOptions = DUMMY_FLOCKS; + const isRepeaterInputError = ( + arrayName: T, + field: T extends 'data_pakan' + ? keyof CreateRecordingPayload['data_pakan'][0] + : T extends 'bobot_badan' + ? keyof CreateRecordingPayload['bobot_badan'][0] + : T extends 'vaksinasi' + ? keyof CreateRecordingPayload['vaksinasi'][0] + : T extends 'mortalitas' + ? keyof CreateRecordingPayload['mortalitas'][0] + : never, + idx: number + ) => { + const touched = formik.touched[arrayName] as + | Record[] + | undefined; + const errors = formik.errors[arrayName] as + | Record[] + | undefined; + + return ( + touched?.[idx]?.[field as string] && + Boolean(errors?.[idx]?.[field as string]) + ); + }; + const addDataPakan = () => { const newDataPakan = [ ...(formik.values.data_pakan || []), @@ -371,7 +397,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.nama_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'data_pakan', + 'nama_pakan', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} />
@@ -382,7 +416,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.qty_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'data_pakan', + 'qty_pakan', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -393,7 +435,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.stock_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'data_pakan', + 'stock_pakan', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -669,7 +742,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.total_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'vaksinasi', + 'total_stock', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -680,7 +761,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.jumlah_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'vaksinasi', + 'jumlah_stock', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -586,7 +570,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={product.qty_product ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( + {...isRepeaterInputError( 'product', 'qty_product', idx @@ -734,13 +718,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue(`ekspedisi.${idx}.qty`, ''); }} options={getFilteredProductOptions()} - isDisabled={type === 'detail'} - isClearable - isError={isRepeaterInputError( + {...isRepeaterInputError( 'ekspedisi', 'product', idx )} + isDisabled={type === 'detail'} + isClearable /> @@ -751,7 +735,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.qty ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError(formik, idx, 'qty')} + {...isRepeaterInputError('ekspedisi', 'qty', idx)} readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -777,7 +761,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingSuppliers} isDisabled={type === 'detail'} isClearable - isError={isRepeaterInputError( + {...isRepeaterInputError( 'ekspedisi', 'supplier', idx @@ -791,10 +775,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.plat_nomor ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'plat_nomor' + {...isRepeaterInputError( + 'ekspedisi', + 'plat_nomor', + idx )} readOnly={type === 'detail'} className={{ @@ -809,10 +793,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.no_surat_jalan ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'no_surat_jalan' + {...isRepeaterInputError( + 'ekspedisi', + 'no_surat_jalan', + idx )} readOnly={type === 'detail'} className={{ @@ -848,7 +832,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); } }} - isError={isRepeaterInputError( + {...isRepeaterInputError( 'ekspedisi', 'dokumen', idx @@ -867,10 +851,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.biaya_ekspedisi ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'biaya_ekspedisi' + {...isRepeaterInputError( + 'ekspedisi', + 'biaya_ekspedisi', + idx )} readOnly={type === 'detail'} className={{ @@ -883,10 +867,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { disabled onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'biaya_ekspedisi_per_item' + {...isRepeaterInputError( + 'ekspedisi', + 'biaya_ekspedisi_per_item', + idx )} name={`ekspedisi.${idx}.biaya_ekspedisi_per_item`} value={ @@ -910,10 +894,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={ekspedisi.nama_sopir ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} - {...getEkspedisiFieldError( - formik, - idx, - 'nama_sopir' + {...isRepeaterInputError( + 'ekspedisi', + 'nama_sopir', + idx )} readOnly={type === 'detail'} className={{ From 3c4333021fb88ffb04130e4742ef272e71a84ee8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:54:38 +0700 Subject: [PATCH 030/212] feat(FE-62,65): enhance MovementForm and FormActions to improve form validation and reset behavior --- src/components/helper/form/FormActions.tsx | 12 ++++++++++-- .../pages/inventory/movement/form/MovementForm.tsx | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 7ced46cd..54600d00 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -64,7 +64,10 @@ export const FormActions = ({ type='reset' color='warning' className='px-4' - onClick={formik.handleReset} + onClick={() => { + formik.handleReset(); + formik.validateForm(); + }} > Reset @@ -73,7 +76,12 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={disableSubmit || !formik.isValid || formik.isSubmitting} + disabled={ + disableSubmit || + !formik.isValid || + !formik.dirty || + formik.isSubmitting + } > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 5d69f03e..f196b242 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -64,6 +64,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, validateOnChange: true, validateOnBlur: true, + validateOnMount: true, onSubmit: async (values) => { setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { From dcd5d2692fdb12baa9eb9a43a53f788ce70ec951 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:54:38 +0700 Subject: [PATCH 031/212] feat(FE-62,65): enhance MovementForm and FormActions to improve form validation and reset behavior --- src/components/helper/form/FormActions.tsx | 12 ++++++++++-- .../pages/inventory/movement/form/MovementForm.tsx | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 7ced46cd..54600d00 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -64,7 +64,10 @@ export const FormActions = ({ type='reset' color='warning' className='px-4' - onClick={formik.handleReset} + onClick={() => { + formik.handleReset(); + formik.validateForm(); + }} > Reset @@ -73,7 +76,12 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={disableSubmit || !formik.isValid || formik.isSubmitting} + disabled={ + disableSubmit || + !formik.isValid || + !formik.dirty || + formik.isSubmitting + } > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 5d69f03e..dd927665 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FormikProps, useFormik } from 'formik'; +import { useFormik } from 'formik'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; @@ -64,6 +64,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, validateOnChange: true, validateOnBlur: true, + validateOnMount: true, onSubmit: async (values) => { setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { From df73ee1fdfc19bdcf935445b5f6d799c108de191 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 12:00:17 +0700 Subject: [PATCH 032/212] feat(FE-62,63,65): refactor MovementForm and related types for improved clarity and consistency --- src/components/helper/form/FormActions.tsx | 7 +- .../movement/form/MovementForm.schema.ts | 164 +++--- .../inventory/movement/form/MovementForm.tsx | 476 ++++++++---------- src/types/api/inventory/movement.d.ts | 61 +-- 4 files changed, 335 insertions(+), 373 deletions(-) diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx index 54600d00..92c2a92c 100644 --- a/src/components/helper/form/FormActions.tsx +++ b/src/components/helper/form/FormActions.tsx @@ -76,12 +76,7 @@ export const FormActions = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={ - disableSubmit || - !formik.isValid || - !formik.dirty || - formik.isSubmitting - } + disabled={disableSubmit || !formik.isValid || formik.isSubmitting} > Submit diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 453ca40b..cb0d228d 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -7,27 +7,28 @@ export type ProductSchema = { label: string; } | null; product_id: number; - qty_product: number; + product_qty: number; }; -export type EkspedisiSchema = { - product: { - value: number; - label: string; - } | null; - product_id: number; - qty: number; +export type DeliverySchema = { + delivery_cost: number; + delivery_cost_per_item?: number | undefined; + document: string | File; + driver_name: string; + vehicle_plate: string; supplier: { value: number; label: string; } | null; supplier_id: number; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string | File; - biaya_ekspedisi: number; - biaya_ekspedisi_per_item?: number | undefined; - nama_sopir: string; + products: { + product: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number; + }[]; }; const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ @@ -36,40 +37,34 @@ const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ label: Yup.string().required(), }).nullable(), product_id: Yup.number().required('Produk wajib diisi!'), - qty_product: Yup.number() + product_qty: Yup.number() .required('Qty wajib diisi!') .min(1, 'Qty minimal 1!') .typeError('Qty harus berupa angka!'), }); -const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ +const DeliveryProductObjectSchema = Yup.object({ product: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), product_id: Yup.number().required('Produk wajib diisi!'), - qty: Yup.number() + product_qty: Yup.number() .required('Qty wajib diisi!') .min(1, 'Qty minimal 1!') - .typeError('Qty harus berupa angka!') - .test('max-product-qty', 'Qty melebihi stok produk!', function (value) { - const { product_id } = this.parent; - const products = (this.options.context?.product ?? []) as { - product_id: number; - qty_product: number; - }[]; - const product = products.find((p) => p.product_id === product_id); - if (!product) return true; - return (value ?? 0) <= Number(product.qty_product); - }), - supplier: Yup.object({ - value: Yup.number().min(1).required(), - label: Yup.string().required(), - }).nullable(), - supplier_id: Yup.number().required('Supplier wajib diisi!'), - plat_nomor: Yup.string().required('Plat nomor wajib diisi!'), - no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'), - dokumen: Yup.mixed() + .typeError('Qty harus berupa angka!'), +}); + +const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ + delivery_cost: Yup.number() + .required('Biaya pengiriman wajib diisi!') + .min(0, 'Biaya minimal 0!') + .typeError('Biaya harus berupa angka!'), + delivery_cost_per_item: Yup.number() + .transform((value) => (isNaN(value) ? undefined : value)) + .min(0, 'Biaya per item minimal 0!') + .typeError('Biaya per item harus berupa angka!'), + document: Yup.mixed() .required('Dokumen wajib diisi!') .test( 'fileType', @@ -86,44 +81,44 @@ const EkspedisiObjectSchema: Yup.ObjectSchema = Yup.object({ typeof value === 'string' || (value instanceof File && value.size <= 2 * 1024 * 1024) ), - biaya_ekspedisi: Yup.number() - .required('Biaya ekspedisi wajib diisi!') - .min(0, 'Biaya minimal 0!') - .typeError('Biaya harus berupa angka!'), - biaya_ekspedisi_per_item: Yup.number() - .transform((value) => (isNaN(value) ? undefined : value)) - .min(0, 'Biaya per item minimal 0!') - .typeError('Biaya per item harus berupa angka!') - .optional() - .default(undefined), - nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), + driver_name: Yup.string().required('Nama sopir wajib diisi!'), + vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number().required('Supplier wajib diisi!'), + products: Yup.array() + .of(DeliveryProductObjectSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), }); export const MovementFormSchema = Yup.object({ - alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), - tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), - warehouse_asal: Yup.object({ + transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), + transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), + source_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - warehouse_asal_id: Yup.number() + source_warehouse_id: Yup.number() .required('Gudang asal wajib diisi!') .typeError('Gudang asal wajib diisi!'), - warehouse_tujuan: Yup.object({ + destination_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - warehouse_tujuan_id: Yup.number() + destination_warehouse_id: Yup.number() .required('Gudang tujuan wajib diisi!') .typeError('Gudang tujuan wajib diisi!'), - product: Yup.array() + products: Yup.array() .of(ProductObjectSchema) .min(1, 'Minimal harus ada 1 produk!') .required('Produk wajib diisi!'), - ekspedisi: Yup.array() - .of(EkspedisiObjectSchema) - .min(1, 'Minimal harus ada 1 ekspedisi!') - .required('Ekspedisi wajib diisi!'), + deliveries: Yup.array() + .of(DeliveryObjectSchema) + .min(1, 'Minimal harus ada 1 pengiriman!') + .required('Pengiriman wajib diisi!'), }); export const UpdateMovementFormSchema = MovementFormSchema; @@ -133,40 +128,41 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement ): MovementFormValues => ({ - alasan_transfer: initialValues?.alasan_transfer ?? '', - tanggal_transfer: initialValues?.tanggal_transfer ?? '', - warehouse_asal: initialValues?.warehouse_asal + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse ? { - value: initialValues.warehouse_asal.id, - label: initialValues.warehouse_asal.name, + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, } : null, - warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, - warehouse_tujuan: initialValues?.warehouse_tujuan + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse ? { - value: initialValues.warehouse_tujuan.id, - label: initialValues.warehouse_tujuan.name, + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, } : null, - warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, - product: - initialValues?.product?.map((p) => ({ + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.products?.map((p) => ({ product: { value: p.product.id, label: p.product.name }, product_id: p.product.id, - qty_product: p.qty_product, + product_qty: p.product_qty, })) ?? [], - ekspedisi: - initialValues?.ekspedisi?.map((e) => ({ - product: { value: e.product_id, label: '' }, - product_id: e.product_id, - qty: e.qty, - supplier: { value: e.supplier.id, label: e.supplier.name }, - supplier_id: e.supplier.id, - plat_nomor: e.plat_nomor, - no_surat_jalan: e.no_surat_jalan, - dokumen: e.dokumen, - biaya_ekspedisi: e.biaya_ekspedisi, - biaya_ekspedisi_per_item: e.biaya_ekspedisi, - nama_sopir: e.nama_sopir, + deliveries: + initialValues?.deliveries?.map((d) => ({ + delivery_cost: d.delivery_cost, + delivery_cost_per_item: d.delivery_cost_per_item, + document: d.document, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier: { value: d.supplier.id, label: d.supplier.name }, + supplier_id: d.supplier.id, + products: d.products.map((p) => ({ + product: { value: p.product.id, label: p.product.name }, + product_id: p.product.id, + product_qty: p.product_qty, + })), })) ?? [], }); diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index dd927665..183fe760 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -22,7 +22,7 @@ import { UpdateMovementFormSchema, getMovementFormInitialValues, ProductSchema, - EkspedisiSchema, + DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; import { @@ -41,7 +41,7 @@ interface MovementFormProps { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); - const [selectedEkspedisi, setSelectedEkspedisi] = useState([]); + const [selectedDeliveries, setSelectedDeliveries] = useState([]); const { deleteModal, @@ -64,31 +64,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, validateOnChange: true, validateOnBlur: true, - validateOnMount: true, + validateOnMount: false, + enableReinitialize: true, onSubmit: async (values) => { setMovementFormErrorMessage(''); const payload: CreateMovementPayload = { - alasan_transfer: values.alasan_transfer, - tanggal_transfer: values.tanggal_transfer, - warehouse_asal_id: values.warehouse_asal_id, - warehouse_tujuan_id: values.warehouse_tujuan_id, - product: (values.product ?? []).map((p) => ({ + transfer_reason: values.transfer_reason, + transfer_date: values.transfer_date, + source_warehouse_id: values.source_warehouse_id, + destination_warehouse_id: values.destination_warehouse_id, + products: values.products.map((p) => ({ product_id: p.product_id, - qty_product: p.qty_product, + product_qty: p.product_qty, })), - ekspedisi: (values.ekspedisi ?? []).map((e) => ({ - product_id: e.product_id, - qty: e.qty, - supplier_id: e.supplier_id, - plat_nomor: e.plat_nomor, - no_surat_jalan: e.no_surat_jalan, - dokumen: - e.dokumen instanceof File ? e.dokumen : (e.dokumen as string), - biaya_ekspedisi: e.biaya_ekspedisi, - biaya_ekspedisi_per_item: e.qty - ? e.biaya_ekspedisi / e.qty - : e.biaya_ekspedisi, - nama_sopir: e.nama_sopir, + deliveries: values.deliveries.map((d) => ({ + delivery_cost: d.delivery_cost, + delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + document: d.document instanceof File ? d.document : d.document, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier_id: d.supplier_id, + products: d.products.map((p) => ({ + product_id: p.product_id, + product_qty: p.product_qty, + })), })), }; @@ -105,65 +104,67 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const addProduct = () => { const newProducts = [ - ...(formik.values.product || []), + ...(formik.values.products || []), { product: null, product_id: 0, - qty_product: 0, + product_qty: 0, }, ]; - formik.setFieldValue('product', newProducts); + formik.setFieldValue('products', newProducts); }; const removeProduct = useCallback( (i: number) => { const updatedProducts = - formik.values.product?.reduce((acc: ProductSchema[], item, index) => { + formik.values.products?.reduce((acc: ProductSchema[], item, index) => { if (index !== i) { acc.push(item); } return acc; }, []) ?? []; - formik.setFieldValue('product', updatedProducts); + formik.setFieldValue('products', updatedProducts); }, [formik] ); const bulkRemoveProduct = useCallback(() => { const updatedProducts = - formik.values.product?.filter( + formik.values.products?.filter( (_, idx) => !selectedProducts.includes(idx) ) ?? []; - formik.setFieldValue('product', updatedProducts); + formik.setFieldValue('products', updatedProducts); setSelectedProducts([]); }, [formik, selectedProducts]); - const addEkspedisi = () => { - const newEkspedisi = [ - ...(formik.values.ekspedisi || []), + const addDelivery = () => { + formik.setFieldValue('deliveries', [ + ...(formik.values.deliveries || []), { - product: null, - product_id: 0, - qty: 0, + delivery_cost: 0, + delivery_cost_per_item: 0, + document: '', + driver_name: '', + vehicle_plate: '', supplier: null, supplier_id: 0, - plat_nomor: '', - no_surat_jalan: '', - dokumen: '', - biaya_ekspedisi: 0, - biaya_ekspedisi_per_item: 0, - nama_sopir: '', + products: [ + { + product: null, + product_id: 0, + product_qty: 0, + }, + ], }, - ]; - formik.setFieldValue('ekspedisi', newEkspedisi); + ]); }; - const removeEkspedisi = useCallback( + const removeDelivery = useCallback( (i: number) => { - const updatedEkspedisi = - formik.values.ekspedisi?.reduce( - (acc: EkspedisiSchema[], item, index) => { + const updatedDeliveries = + formik.values.deliveries?.reduce( + (acc: DeliverySchema[], item, index) => { if (index !== i) { acc.push(item); } @@ -172,23 +173,23 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [] ) ?? []; - formik.setFieldValue('ekspedisi', updatedEkspedisi); + formik.setFieldValue('deliveries', updatedDeliveries); }, [formik] ); - const bulkRemoveEkspedisi = useCallback(() => { - const updatedEkspedisi = - formik.values.ekspedisi?.filter( - (_, idx) => !selectedEkspedisi.includes(idx) + const bulkRemoveDelivery = useCallback(() => { + const updatedDeliveries = + formik.values.deliveries?.filter( + (_, idx) => !selectedDeliveries.includes(idx) ) ?? []; - formik.setFieldValue('ekspedisi', updatedEkspedisi); - setSelectedEkspedisi([]); - }, [formik, selectedEkspedisi]); + formik.setFieldValue('deliveries', updatedDeliveries); + setSelectedDeliveries([]); + }, [formik, selectedDeliveries]); - const isRepeaterInputError = ( + const isRepeaterInputError = ( arrayName: T, - column: T extends 'product' ? keyof ProductSchema : keyof EkspedisiSchema, + column: T extends 'products' ? keyof ProductSchema : keyof DeliverySchema, idx: number ) => { if ( @@ -260,57 +261,80 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) : []; - const { setValues: formikSetValues } = formik; - useEffect(() => { - formikSetValues(formikInitialValues); - }, [formikSetValues, formikInitialValues]); - - useEffect(() => { - formik.values.ekspedisi?.forEach((eks, idx) => { - if (eks.qty && eks.biaya_ekspedisi) { - const perItem = eks.biaya_ekspedisi / eks.qty; + formik.values.deliveries?.forEach((delivery, idx) => { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty && delivery.delivery_cost) { + const perItem = delivery.delivery_cost / productQty; formik.setFieldValue( - `ekspedisi.${idx}.biaya_ekspedisi_per_item`, + `deliveries.${idx}.delivery_cost_per_item`, perItem ); } }); - }, [formik.values.ekspedisi]); + }, [formik.values.deliveries]); const getFilteredProductOptions = useCallback(() => { return ( - formik.values.product + formik.values.products ?.filter((p) => p.product) .map((p) => ({ value: p.product_id, label: (p.product as OptionType)?.label, })) ?? [] ); - }, [formik.values.product]); + }, [formik.values.products]); - const validateEkspedisiQty = (ekspedisiIdx: number, qty: number) => { - const productId = formik.values.ekspedisi?.[ekspedisiIdx]?.product_id; - if (!productId) return true; - const relatedProduct = formik.values.product?.find( - (p) => p.product_id === productId - ); - if (!relatedProduct) return true; - const totalQtyUsed = - formik.values.ekspedisi?.reduce((total, eks, i) => { - if (eks.product_id === productId && i !== ekspedisiIdx) { - return total + (Number(eks.qty) || 0); - } - return total; - }, 0) || 0; - return totalQtyUsed + qty <= Number(relatedProduct.qty_product); - }; + const validateDeliveryQty = useCallback( + (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return true; - const invalidQtyRows = - formik.values.ekspedisi?.map((eks, idx) => { - const qty = Number(eks.qty) || 0; - return !validateEkspedisiQty(idx, qty); - }) ?? []; + const deliveryProduct = delivery.products[deliveryProductIdx]; + if (!deliveryProduct) return true; + + const productId = deliveryProduct.product_id; + if (!productId) return true; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return true; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === productId && + !(d === delivery && pIdx === deliveryProductIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + return totalQtyUsed + qty <= Number(relatedProduct.product_qty); + }, + [formik.values.deliveries, formik.values.products] + ); + + const invalidQtyRows = useMemo( + () => + formik.values.deliveries?.flatMap((delivery, deliveryIdx) => + delivery.products.map((product, productIdx) => { + const qty = Number(product.product_qty) || 0; + return !validateDeliveryQty(deliveryIdx, productIdx, qty); + }) + ) ?? [], + [formik.values.deliveries, formik.values.products, validateDeliveryQty] + ); + + const hasInvalidQty = invalidQtyRows.some(Boolean); return ( <> @@ -332,30 +356,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { @@ -370,11 +394,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { - formik.setFieldValue('warehouse_asal', val); + formik.setFieldValue('source_warehouse', val); formik.setFieldValue( - 'warehouse_asal_id', + 'source_warehouse_id', (val as WarehouseOptionType)?.value ); }} @@ -382,10 +406,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onInputChange={setWarehouseSelectInputValue} isLoading={isLoadingWarehouses} isError={ - formik.touched.warehouse_asal_id && - Boolean(formik.errors.warehouse_asal_id) + formik.touched.source_warehouse_id && + Boolean(formik.errors.source_warehouse_id) } - errorMessage={formik.errors.warehouse_asal_id as string} + errorMessage={formik.errors.source_warehouse_id as string} isDisabled={type === 'detail'} isClearable /> @@ -394,9 +418,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ /> { { - formik.setFieldValue('warehouse_tujuan', val); + formik.setFieldValue('destination_warehouse', val); formik.setFieldValue( - 'warehouse_tujuan_id', + 'destination_warehouse_id', (val as WarehouseOptionType)?.value ); }} @@ -440,10 +464,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { onInputChange={setWarehouseSelectInputValue} isLoading={isLoadingWarehouses} isError={ - formik.touched.warehouse_tujuan_id && - Boolean(formik.errors.warehouse_tujuan_id) + formik.touched.destination_warehouse_id && + Boolean(formik.errors.destination_warehouse_id) + } + errorMessage={ + formik.errors.destination_warehouse_id as string } - errorMessage={formik.errors.warehouse_tujuan_id as string} isDisabled={type === 'detail'} isClearable /> @@ -452,10 +478,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{ /> { type='checkbox' className='checkbox' checked={ - formik.values.product?.length === + formik.values.products?.length === selectedProducts.length && - formik.values.product?.length > 0 + formik.values.products?.length > 0 } onChange={(e) => { if (e.target.checked) { setSelectedProducts( - formik.values.product?.map((_, idx) => idx) ?? - [] + formik.values.products?.map( + (_, idx) => idx + ) ?? [] ); } else { setSelectedProducts([]); @@ -518,7 +549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
@@ -547,11 +578,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={product.product ?? undefined} onChange={(val) => { formik.setFieldValue( - `product.${idx}.product`, + `products.${idx}.product`, val ); formik.setFieldValue( - `product.${idx}.product_id`, + `products.${idx}.product_id`, (val as OptionType)?.value ); }} @@ -560,20 +591,24 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingProducts} isDisabled={type === 'detail'} isClearable - {...isRepeaterInputError('product', 'product', idx)} + {...isRepeaterInputError( + 'products', + 'product', + idx + )} /> { - {/* Ekspedisi table */} + {/* Deliveries table */}
-

Ekspedisi

+

Pengiriman

@@ -647,19 +682,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.ekspedisi?.length === - selectedEkspedisi.length && - formik.values.ekspedisi?.length > 0 + formik.values.deliveries?.length === + selectedDeliveries.length && + formik.values.deliveries?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedEkspedisi( - formik.values.ekspedisi?.map( + setSelectedDeliveries( + formik.values.deliveries?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedEkspedisi([]); + setSelectedDeliveries([]); } }} /> @@ -669,34 +704,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { - - - + + {type !== 'detail' && } - {formik.values.ekspedisi?.map((ekspedisi, idx) => ( - + {formik.values.deliveries?.map((delivery, idx) => ( + {type !== 'detail' && ( - {type !== 'detail' && ( @@ -911,7 +880,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { )} )} @@ -968,7 +936,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { : undefined } onDelete={deleteMovementClickHandler} - disableSubmit={invalidQtyRows.some(Boolean)} + disableSubmit={hasInvalidQty} /> {movementFormErrorMessage && ( diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index d7f2776a..11da41a5 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -5,47 +5,50 @@ import { Warehouse } from '@/types/api/master-data/warehouse'; export type BaseMovement = { id: number; - alasan_transfer: string; - tanggal_transfer: string; - warehouse_asal: Warehouse; - warehouse_tujuan: Warehouse; - product: { + transfer_reason: string; + transfer_date: string; + source_warehouse: Warehouse; + destination_warehouse: Warehouse; + products: { product: Product; - qty_product: number; + product_qty: number; }[]; - ekspedisi: { - product_id: number; - qty: number; + deliveries: { + delivery_cost: number; + delivery_cost_per_item: number; + document: string; + driver_name: string; + vehicle_plate: string; supplier: Supplier; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string; - biaya_ekspedisi: number; - nama_sopir: string; + products: { + product: Product; + product_qty: number; + }[]; }[]; }; export type Movement = BaseMetadata & BaseMovement; export type CreateMovementPayload = { - alasan_transfer: string; - tanggal_transfer: string; - warehouse_asal_id: number; - warehouse_tujuan_id: number; - product: { + transfer_reason: string; + transfer_date: string; + source_warehouse_id: number; + destination_warehouse_id: number; + products: { product_id: number; - qty_product: number; + product_qty: number; }[]; - ekspedisi: { - product_id: number; - qty: number; + deliveries: { + delivery_cost: number; + delivery_cost_per_item: number; + document: string | File; + driver_name: string; + vehicle_plate: string; supplier_id: number; - plat_nomor: string; - no_surat_jalan: string; - dokumen: string | File; - biaya_ekspedisi: number; - biaya_ekspedisi_per_item?: number; - nama_sopir: string; + products: { + product_id: number; + product_qty: number; + }[]; }[]; }; From 06dc869b846c396d568a58aa1d9cdb695c8be3d5 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 12:08:52 +0700 Subject: [PATCH 033/212] feat(FE-64): update MovementTable structure for improved data clarity and consistency --- .../inventory/movement/MovementTable.tsx | 244 +++++++++++++----- 1 file changed, 178 insertions(+), 66 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index b39906d3..a00f7111 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -31,23 +31,23 @@ const dummyMovements: Movement[] = [ { ...baseMetadata, id: 1, - alasan_transfer: 'Restock', - tanggal_transfer: '2024-06-01', - warehouse_asal: { + transfer_reason: 'Restock', + transfer_date: '2024-06-01', + source_warehouse: { ...baseMetadata, id: 1, name: 'Warehouse A', type: 'AREA', area: { id: 1, name: 'Area 1' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 2, name: 'Warehouse B', type: 'AREA', area: { id: 2, name: 'Area 2' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -73,13 +73,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 10, + product_qty: 10, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 1, - qty: 10, + delivery_cost: 50000, + delivery_cost_per_item: 5000, + document: 'doc1.pdf', + driver_name: 'Andi', + vehicle_plate: 'B 1234 CD', supplier: { ...baseMetadata, id: 1, @@ -97,34 +100,58 @@ const dummyMovements: Movement[] = [ balance: 0, due_date: 30, }, - plat_nomor: 'B 1234 CD', - no_surat_jalan: 'SJ-001', - dokumen: 'doc1.pdf', - biaya_ekspedisi: 50000, - nama_sopir: 'Andi', + products: [ + { + product: { + ...baseMetadata, + id: 1, + name: 'Product X', + brand: 'Brand X', + sku: 'SKU-X', + product_price: 10000, + selling_price: 12000, + tax: 10, + expiry_period: 365, + uom: { + ...baseMetadata, + id: 1, + name: 'PCS', + }, + product_category: { + ...baseMetadata, + id: 1, + code: 'CAT-1', + name: 'Category 1', + }, + suppliers: [], + flags: [], + }, + product_qty: 10, + }, + ], }, ], }, { ...baseMetadata, id: 2, - alasan_transfer: 'Mutasi Stok', - tanggal_transfer: '2024-06-02', - warehouse_asal: { + transfer_reason: 'Mutasi Stok', + transfer_date: '2024-06-02', + source_warehouse: { ...baseMetadata, id: 2, name: 'Warehouse B', type: 'AREA', area: { id: 2, name: 'Area 2' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 3, name: 'Warehouse C', type: 'AREA', area: { id: 3, name: 'Area 3' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -150,13 +177,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 5, + product_qty: 5, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 2, - qty: 5, + delivery_cost: 60000, + delivery_cost_per_item: 12000, + document: 'doc2.pdf', + driver_name: 'Budi', + vehicle_plate: 'D 5678 EF', supplier: { ...baseMetadata, id: 2, @@ -174,34 +204,58 @@ const dummyMovements: Movement[] = [ balance: 1000, due_date: 15, }, - plat_nomor: 'D 5678 EF', - no_surat_jalan: 'SJ-002', - dokumen: 'doc2.pdf', - biaya_ekspedisi: 60000, - nama_sopir: 'Budi', + products: [ + { + product: { + ...baseMetadata, + id: 2, + name: 'Product Y', + brand: 'Brand Y', + sku: 'SKU-Y', + product_price: 20000, + selling_price: 25000, + tax: 5, + expiry_period: 180, + uom: { + ...baseMetadata, + id: 2, + name: 'BOX', + }, + product_category: { + ...baseMetadata, + id: 2, + code: 'CAT-2', + name: 'Category 2', + }, + suppliers: [], + flags: [], + }, + product_qty: 5, + }, + ], }, ], }, { ...baseMetadata, id: 3, - alasan_transfer: 'Pengembalian', - tanggal_transfer: '2024-06-03', - warehouse_asal: { + transfer_reason: 'Pengembalian', + transfer_date: '2024-06-03', + source_warehouse: { ...baseMetadata, id: 3, name: 'Warehouse C', type: 'AREA', area: { id: 3, name: 'Area 3' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 1, name: 'Warehouse A', type: 'AREA', area: { id: 1, name: 'Area 1' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -227,13 +281,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 8, + product_qty: 8, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 3, - qty: 8, + delivery_cost: 40000, + delivery_cost_per_item: 5000, + document: 'doc3.pdf', + driver_name: 'Cici', + vehicle_plate: 'F 9101 GH', supplier: { ...baseMetadata, id: 3, @@ -251,34 +308,58 @@ const dummyMovements: Movement[] = [ balance: 500, due_date: 10, }, - plat_nomor: 'F 9101 GH', - no_surat_jalan: 'SJ-003', - dokumen: 'doc3.pdf', - biaya_ekspedisi: 40000, - nama_sopir: 'Cici', + products: [ + { + product: { + ...baseMetadata, + id: 3, + name: 'Product Z', + brand: 'Brand Z', + sku: 'SKU-Z', + product_price: 15000, + selling_price: 18000, + tax: 8, + expiry_period: 90, + uom: { + ...baseMetadata, + id: 3, + name: 'KG', + }, + product_category: { + ...baseMetadata, + id: 3, + code: 'CAT-3', + name: 'Category 3', + }, + suppliers: [], + flags: [], + }, + product_qty: 8, + }, + ], }, ], }, { ...baseMetadata, id: 4, - alasan_transfer: 'Transfer Internal', - tanggal_transfer: '2024-06-04', - warehouse_asal: { + transfer_reason: 'Transfer Internal', + transfer_date: '2024-06-04', + source_warehouse: { ...baseMetadata, id: 4, name: 'Warehouse D', type: 'AREA', area: { id: 4, name: 'Area 4' }, }, - warehouse_tujuan: { + destination_warehouse: { ...baseMetadata, id: 5, name: 'Warehouse E', type: 'AREA', area: { id: 5, name: 'Area 5' }, }, - product: [ + products: [ { product: { ...baseMetadata, @@ -304,13 +385,16 @@ const dummyMovements: Movement[] = [ suppliers: [], flags: [], }, - qty_product: 20, + product_qty: 20, }, ], - ekspedisi: [ + deliveries: [ { - product_id: 4, - qty: 20, + delivery_cost: 30000, + delivery_cost_per_item: 1500, + document: 'doc4.pdf', + driver_name: 'Dedi', + vehicle_plate: 'H 2345 IJ', supplier: { ...baseMetadata, id: 4, @@ -328,11 +412,35 @@ const dummyMovements: Movement[] = [ balance: 200, due_date: 20, }, - plat_nomor: 'H 2345 IJ', - no_surat_jalan: 'SJ-004', - dokumen: 'doc4.pdf', - biaya_ekspedisi: 30000, - nama_sopir: 'Dedi', + products: [ + { + product: { + ...baseMetadata, + id: 4, + name: 'Product A', + brand: 'Brand A', + sku: 'SKU-A', + product_price: 5000, + selling_price: 7000, + tax: 0, + expiry_period: 60, + uom: { + ...baseMetadata, + id: 4, + name: 'LITER', + }, + product_category: { + ...baseMetadata, + id: 4, + code: 'CAT-4', + name: 'Category 4', + }, + suppliers: [], + flags: [], + }, + product_qty: 20, + }, + ], }, ], }, @@ -401,30 +509,34 @@ const MovementTable = () => { cell: (props) => pageSize * (page - 1) + props.row.index + 1, }, { - accessorKey: 'warehouse_asal', + accessorKey: 'source_warehouse', header: 'Gudang Asal', - cell: (props) => props.row.original.warehouse_asal.name, + cell: (props) => props.row.original.source_warehouse.name, }, { - accessorKey: 'warehouse_tujuan', + accessorKey: 'destination_warehouse', header: 'Gudang Tujuan', - cell: (props) => props.row.original.warehouse_tujuan.name, + cell: (props) => props.row.original.destination_warehouse.name, }, { - accessorKey: 'product', + accessorKey: 'products', header: 'Nama Produk', cell: (props) => - props.row.original.product.map((p) => p.product.name), + props.row.original.products + .map((p) => p.product.name) + .join(', '), }, { - accessorKey: 'alasan_transfer', + accessorKey: 'transfer_reason', header: 'Catatan', }, { - accessorKey: 'biaya_ekspedisi', - header: 'Biaya Ekspedisi', + accessorKey: 'delivery_cost', + header: 'Biaya Pengiriman', cell: (props) => - props.row.original.ekspedisi.map((e) => e.biaya_ekspedisi), + props.row.original.deliveries + .reduce((sum, d) => sum + d.delivery_cost, 0) + .toLocaleString('id-ID'), }, { id: 'actions', From aa21088e99cf25be75961872e1d4d6c795b16af8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 12:30:59 +0700 Subject: [PATCH 034/212] feat(FE-62): enhance MovementForm with delivery product input error handling and validation --- .../inventory/movement/form/MovementForm.tsx | 86 ++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 183fe760..1af77cf7 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -214,6 +214,32 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }; }; + const isDeliveryProductInputError = ( + deliveryIdx: number, + productIdx: number, + column: keyof DeliverySchema['products'][number] + ) => { + const touchedDelivery = formik.touched.deliveries?.[deliveryIdx]; + const errorDelivery = formik.errors.deliveries?.[deliveryIdx] as + | { products: Array> } + | undefined; + + if (!touchedDelivery?.products || !errorDelivery?.products) { + return { + isError: false, + errorMessage: undefined, + }; + } + + const touchedField = touchedDelivery.products[productIdx]?.[column]; + const errorField = errorDelivery.products[productIdx]?.[column]; + + return { + isError: Boolean(touchedField && errorField), + errorMessage: touchedField ? errorField : undefined, + }; + }; + interface WarehouseOptionType extends OptionType { area?: string; location?: string; @@ -305,11 +331,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { if (!relatedProduct) return true; const totalQtyUsed = - formik.values.deliveries?.reduce((total, d) => { + formik.values.deliveries?.reduce((total, d, dIdx) => { const productQty = d.products.reduce((sum, p, pIdx) => { if ( p.product_id === productId && - !(d === delivery && pIdx === deliveryProductIdx) + !(dIdx === deliveryIdx && pIdx === deliveryProductIdx) ) { return sum + (Number(p.product_qty) || 0); } @@ -323,6 +349,47 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik.values.deliveries, formik.values.products] ); + const getDeliveryQtyError = useCallback( + (deliveryIdx: number, deliveryProductIdx: number) => { + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return null; + + const deliveryProduct = delivery.products[deliveryProductIdx]; + if (!deliveryProduct || !deliveryProduct.product_id) return null; + + const qty = Number(deliveryProduct.product_qty) || 0; + const productId = deliveryProduct.product_id; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return null; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d, dIdx) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === productId && + !(dIdx === deliveryIdx && pIdx === deliveryProductIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + const availableQty = Number(relatedProduct.product_qty) - totalQtyUsed; + + if (totalQtyUsed + qty > Number(relatedProduct.product_qty)) { + return `Qty melebihi stok produk! Tersedia: ${availableQty}, Total digunakan: ${totalQtyUsed + qty}`; + } + + return null; + }, + [formik.values.deliveries, formik.values.products] + ); + const invalidQtyRows = useMemo( () => formik.values.deliveries?.flatMap((delivery, deliveryIdx) => @@ -334,7 +401,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [formik.values.deliveries, formik.values.products, validateDeliveryQty] ); - const hasInvalidQty = invalidQtyRows.some(Boolean); + const hasInvalidQty = useMemo( + () => invalidQtyRows.some(Boolean), + [invalidQtyRows] + ); return ( <> @@ -762,6 +832,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { value={delivery.products[0]?.product_qty ?? ''} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={ + isDeliveryProductInputError(idx, 0, 'product_qty') + .isError || Boolean(getDeliveryQtyError(idx, 0)) + } + errorMessage={ + isDeliveryProductInputError(idx, 0, 'product_qty') + .errorMessage || + getDeliveryQtyError(idx, 0) || + undefined + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', From 2ee88a274249f47cf0edd34f1a99645ab3930dd0 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 13:45:48 +0700 Subject: [PATCH 035/212] refactor(FE-114): enhance tanggal_recording handling and improve error messaging in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 247 +++++++++++++----- 1 file changed, 179 insertions(+), 68 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index c0efe1fa..f53123f1 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -67,7 +67,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { flock_id: values.flock_id, - tanggal_recording: values.tanggal_recording.toISOString(), + tanggal_recording: + values.tanggal_recording instanceof Date + ? values.tanggal_recording.toISOString() + : new Date().toISOString(), data_pakan: (values.data_pakan ?? []).map((p) => ({ nama_pakan: p.nama_pakan, qty_pakan: p.qty_pakan, @@ -126,16 +129,31 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { idx: number ) => { const touched = formik.touched[arrayName] as - | Record[] - | undefined; - const errors = formik.errors[arrayName] as - | Record[] + | { + [key: string]: boolean | undefined; + }[] | undefined; - return ( - touched?.[idx]?.[field as string] && - Boolean(errors?.[idx]?.[field as string]) - ); + const errors = formik.errors[arrayName] as + | { + [key: string]: string | undefined; + }[] + | undefined; + + if (!touched || !Array.isArray(touched)) { + return { + isError: false, + errorMessage: undefined, + }; + } + + const touchedField = touched[idx]?.[field as string]; + const errorField = errors?.[idx]?.[field as string]; + + return { + isError: touchedField && Boolean(errorField), + errorMessage: touchedField ? errorField : undefined, + }; }; const addDataPakan = () => { @@ -312,11 +330,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { label='Tanggal Recording' type='date' name='tanggal_recording' - value={formik.values.tanggal_recording - .toISOString() - .substring(0, 10)} + value={ + formik.values.tanggal_recording instanceof Date + ? formik.values.tanggal_recording + .toISOString() + .substring(0, 10) + : '' + } onChange={(e) => { - const date = new Date(e.target.value); + const date = e.target.value + ? new Date(e.target.value) + : new Date(); formik.setFieldValue('tanggal_recording', date); }} onBlur={formik.handleBlur} @@ -397,11 +421,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.nama_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'data_pakan', - 'nama_pakan', - idx - )} + isError={ + isRepeaterInputError( + 'data_pakan', + 'nama_pakan', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'data_pakan', + 'nama_pakan', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -416,11 +449,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.qty_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'data_pakan', - 'qty_pakan', - idx - )} + isError={ + isRepeaterInputError( + 'data_pakan', + 'qty_pakan', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'data_pakan', + 'qty_pakan', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -435,11 +477,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.stock_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'data_pakan', - 'stock_pakan', - idx - )} + isError={ + isRepeaterInputError( + 'data_pakan', + 'stock_pakan', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'data_pakan', + 'stock_pakan', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -565,11 +616,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'bobot_badan', - 'berat_ayam', - idx - )} + isError={ + isRepeaterInputError( + 'bobot_badan', + 'berat_ayam', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'bobot_badan', + 'berat_ayam', + idx + ).errorMessage + } readOnly={type === 'detail'} /> @@ -581,11 +641,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.jumlah_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'bobot_badan', - 'jumlah_ayam', - idx - )} + isError={ + isRepeaterInputError( + 'bobot_badan', + 'jumlah_ayam', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'bobot_badan', + 'jumlah_ayam', + idx + ).errorMessage + } readOnly={type === 'detail'} /> @@ -597,11 +666,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.rata_rata_berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'bobot_badan', - 'rata_rata_berat_ayam', - idx - )} + isError={ + isRepeaterInputError( + 'bobot_badan', + 'rata_rata_berat_ayam', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'bobot_badan', + 'rata_rata_berat_ayam', + idx + ).errorMessage + } readOnly={type === 'detail'} /> @@ -723,11 +801,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.nama_vaksin} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'vaksinasi', - 'nama_vaksin', - idx - )} + isError={ + isRepeaterInputError( + 'vaksinasi', + 'nama_vaksin', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'vaksinasi', + 'nama_vaksin', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -742,11 +829,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.total_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'vaksinasi', - 'total_stock', - idx - )} + isError={ + isRepeaterInputError( + 'vaksinasi', + 'total_stock', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'vaksinasi', + 'total_stock', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -761,11 +857,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.jumlah_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'vaksinasi', - 'jumlah_stock', - idx - )} + isError={ + isRepeaterInputError( + 'vaksinasi', + 'jumlah_stock', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'vaksinasi', + 'jumlah_stock', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -894,11 +999,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { (val as OptionType)?.value ); }} - isError={isRepeaterInputError( - 'mortalitas', - 'kondisi', - idx - )} + isError={ + isRepeaterInputError('mortalitas', 'kondisi', idx) + .isError + } + errorMessage={ + isRepeaterInputError('mortalitas', 'kondisi', idx) + .errorMessage + } options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} /> @@ -911,11 +1019,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={mortal.jumlah} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'mortalitas', - 'jumlah', - idx - )} + isError={ + isRepeaterInputError('mortalitas', 'jumlah', idx) + .isError + } + errorMessage={ + isRepeaterInputError('mortalitas', 'jumlah', idx) + .errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', From 64a32fd214f41179e133055386794f3595a3265c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 17:39:27 +0700 Subject: [PATCH 036/212] refactor(FE-114,136): update RecordingForm schema and types to include location and coop fields --- .../recording/form/RecordingForm.schema.ts | 86 ++- .../flock/recording/form/RecordingForm.tsx | 610 ++++++++++-------- src/types/api/flock/recording.d.ts | 66 +- 3 files changed, 443 insertions(+), 319 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index 90d91c4f..394b08b0 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -8,63 +8,73 @@ export const RecordingFormSchema = Yup.object({ label: Yup.string().required(), }).nullable(), flock_id: Yup.number().required('Flock wajib diisi!'), - tanggal_recording: Yup.date() + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + location_id: Yup.number().required('Lokasi wajib diisi!'), + coop: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + coop_id: Yup.number().required('Kandang wajib diisi!'), + recording_date: Yup.date() .required('Tanggal recording wajib diisi') .typeError('Format tanggal tidak valid'), - data_pakan: Yup.array() + feed_data: Yup.array() .of( Yup.object({ - nama_pakan: Yup.string().required('Nama pakan wajib diisi!'), - qty_pakan: Yup.number() + feed_name: Yup.string().required('Nama pakan wajib diisi!'), + feed_qty: Yup.number() .required('Qty pakan wajib diisi!') .min(1, 'Qty minimal 1!'), - stock_pakan: Yup.number() + feed_stock: Yup.number() .required('Stock pakan wajib diisi!') .min(0, 'Stock minimal 0!'), }) ) .min(1, 'Minimal harus ada 1 data pakan!') .required('Data pakan wajib diisi!'), - bobot_badan: Yup.array() + body_weight: Yup.array() .of( Yup.object({ - berat_ayam: Yup.number() + chicken_weight: Yup.number() .required('Berat ayam wajib diisi!') .min(1, 'Berat minimal 1!'), - jumlah_ayam: Yup.number() + chicken_count: Yup.number() .required('Jumlah ayam wajib diisi!') .min(1, 'Jumlah minimal 1!'), - rata_rata_berat_ayam: Yup.number() + average_chicken_weight: Yup.number() .required('Rata-rata berat ayam wajib diisi!') .min(1, 'Rata-rata minimal 1!'), }) ) .min(1, 'Minimal harus ada 1 data bobot badan!') .required('Data bobot badan wajib diisi!'), - vaksinasi: Yup.array() + vaccination: Yup.array() .of( Yup.object({ - nama_vaksin: Yup.string().required('Nama vaksin wajib diisi!'), + vaccine_name: Yup.string().required('Nama vaksin wajib diisi!'), total_stock: Yup.number() .required('Total stock wajib diisi!') .min(0, 'Total stock minimal 0!'), - jumlah_stock: Yup.number() + used_stock: Yup.number() .required('Jumlah stock wajib diisi!') .min(0, 'Jumlah stock minimal 0!'), }) ) .min(1, 'Minimal harus ada 1 data vaksinasi!') .required('Data vaksinasi wajib diisi!'), - mortalitas: Yup.array() + mortality: Yup.array() .of( Yup.object({ - kondisi: Yup.mixed() + condition: Yup.mixed() .oneOf( RECORDING_FLAG_OPTIONS.map((opt) => opt.value), 'Kondisi tidak valid!' ) .required('Kondisi wajib diisi!'), - jumlah: Yup.number() + count: Yup.number() .required('Jumlah wajib diisi!') .min(1, 'Jumlah minimal 1!'), }) @@ -87,34 +97,48 @@ export const getRecordingFormInitialValues = ( } : null, flock_id: initialValues?.flock?.id ?? 0, - tanggal_recording: initialValues?.tanggal_recording - ? new Date(initialValues.tanggal_recording) + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + location_id: initialValues?.location?.id ?? 0, + coop: initialValues?.coop + ? { + value: initialValues.coop.id, + label: initialValues.coop.name, + } + : null, + coop_id: initialValues?.coop?.id ?? 0, + recording_date: initialValues?.recording_date + ? new Date(initialValues.recording_date) : new Date(), - data_pakan: initialValues?.data_pakan ?? [ + feed_data: initialValues?.feed_data ?? [ { - nama_pakan: '', - qty_pakan: 0, - stock_pakan: 0, + feed_name: '', + feed_qty: 0, + feed_stock: 0, }, ], - bobot_badan: initialValues?.bobot_badan ?? [ + body_weight: initialValues?.body_weight ?? [ { - berat_ayam: 0, - jumlah_ayam: 0, - rata_rata_berat_ayam: 0, + chicken_weight: 0, + chicken_count: 0, + average_chicken_weight: 0, }, ], - vaksinasi: initialValues?.vaksinasi ?? [ + vaccination: initialValues?.vaccination ?? [ { - nama_vaksin: '', + vaccine_name: '', total_stock: 0, - jumlah_stock: 0, + used_stock: 0, }, ], - mortalitas: initialValues?.mortalitas ?? [ + mortality: initialValues?.mortality ?? [ { - kondisi: '', - jumlah: 0, + condition: '', + count: 0, }, ], }); diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index f53123f1..afc9cd84 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -22,6 +22,7 @@ import { FlockApi } from '@/services/api/flock'; import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import useSWR from 'swr'; +import { KandangApi, LocationApi } from '@/services/api/master-data'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -37,10 +38,11 @@ const DUMMY_FLOCKS = [ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); - const [selectedPakan, setSelectedPakan] = useState([]); - const [selectedBobot, setSelectedBobot] = useState([]); - const [selectedVaksin, setSelectedVaksin] = useState([]); - const [selectedMortal, setSelectedMortal] = useState([]); + const [selectedFeed, setSelectedFeed] = useState([]); + const [selectedWeight, setSelectedWeight] = useState([]); + const [selectedVaccine, setSelectedVaccine] = useState([]); + const [selectedMortality, setSelectedMortality] = useState([]); + const [, setRecordingFormErrorMessage] = useState(''); const { deleteModal, @@ -67,28 +69,30 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { flock_id: values.flock_id, - tanggal_recording: - values.tanggal_recording instanceof Date - ? values.tanggal_recording.toISOString() + location_id: values.location_id, + coop_id: values.coop_id, + recording_date: + values.recording_date instanceof Date + ? values.recording_date.toISOString() : new Date().toISOString(), - data_pakan: (values.data_pakan ?? []).map((p) => ({ - nama_pakan: p.nama_pakan, - qty_pakan: p.qty_pakan, - stock_pakan: p.stock_pakan, + feed_data: (values.feed_data ?? []).map((p) => ({ + feed_name: p.feed_name, + feed_qty: p.feed_qty, + feed_stock: p.feed_stock, })), - bobot_badan: (values.bobot_badan ?? []).map((b) => ({ - berat_ayam: b.berat_ayam, - jumlah_ayam: b.jumlah_ayam, - rata_rata_berat_ayam: b.rata_rata_berat_ayam, + body_weight: (values.body_weight ?? []).map((b) => ({ + chicken_weight: b.chicken_weight, + chicken_count: b.chicken_count, + average_chicken_weight: b.average_chicken_weight, })), - vaksinasi: (values.vaksinasi ?? []).map((v) => ({ - nama_vaksin: v.nama_vaksin, + vaccination: (values.vaccination ?? []).map((v) => ({ + vaccine_name: v.vaccine_name, total_stock: v.total_stock, - jumlah_stock: v.jumlah_stock, + used_stock: v.used_stock, })), - mortalitas: (values.mortalitas ?? []).map((m) => ({ - kondisi: m.kondisi, - jumlah: m.jumlah, + mortality: (values.mortality ?? []).map((m) => ({ + condition: m.condition, + count: m.count, })), }; @@ -115,16 +119,64 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockOptions = DUMMY_FLOCKS; + // Location and Coop state/handlers + const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); + + // Location fetch + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationsUrl, + LocationApi.getAllFetcher + ); + const locationOptions = isResponseSuccess(locations) + ? locations?.data.map((loc) => ({ value: loc.id, label: loc.name })) + : []; + + // Coop fetch + const coopsUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: coopSelectInputValue ?? '' }).toString()}`; + const { data: coops, isLoading: isLoadingCoops } = useSWR( + coopsUrl, + KandangApi.getAllFetcher + ); + + // Filter coop options based on selected location + const coopOptions = useMemo(() => { + if (!isResponseSuccess(coops) || !formik.values.location_id) return []; + return coops.data + .filter((coop) => coop.location.id === formik.values.location_id) + .map((coop) => ({ value: coop.id, label: coop.name })); + }, [coops, formik.values.location_id]); + + // Handlers + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('location', true); + formik.setFieldValue('location', val); + formik.setFieldTouched('location_id', true); + formik.setFieldValue('location_id', (val as OptionType)?.value); + // Reset coop selection when location changes + formik.setFieldValue('coop', null); + formik.setFieldValue('coop_id', 0); + setCoopSelectInputValue(''); + }; + + const coopChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('coop', true); + formik.setFieldValue('coop', val); + formik.setFieldTouched('coop_id', true); + formik.setFieldValue('coop_id', (val as OptionType)?.value); + }; + const isRepeaterInputError = ( arrayName: T, - field: T extends 'data_pakan' - ? keyof CreateRecordingPayload['data_pakan'][0] - : T extends 'bobot_badan' - ? keyof CreateRecordingPayload['bobot_badan'][0] - : T extends 'vaksinasi' - ? keyof CreateRecordingPayload['vaksinasi'][0] - : T extends 'mortalitas' - ? keyof CreateRecordingPayload['mortalitas'][0] + field: T extends 'feed_data' + ? keyof CreateRecordingPayload['feed_data'][0] + : T extends 'body_weight' + ? keyof CreateRecordingPayload['body_weight'][0] + : T extends 'vaccination' + ? keyof CreateRecordingPayload['vaccination'][0] + : T extends 'mortality' + ? keyof CreateRecordingPayload['mortality'][0] : never, idx: number ) => { @@ -156,111 +208,111 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - const addDataPakan = () => { - const newDataPakan = [ - ...(formik.values.data_pakan || []), + const addFeedData = () => { + const newFeedData = [ + ...(formik.values.feed_data || []), { - nama_pakan: '', - qty_pakan: 0, - stock_pakan: 0, + feed_name: '', + feed_qty: 0, + feed_stock: 0, }, ]; - formik.setFieldValue('data_pakan', newDataPakan); + formik.setFieldValue('feed_data', newFeedData); }; - const removeDataPakan = (idx: number) => { - const updatedDataPakan = formik.values.data_pakan?.filter( + const removeFeedData = (idx: number) => { + const updatedFeedData = formik.values.feed_data?.filter( (_, i) => i !== idx ); - formik.setFieldValue('data_pakan', updatedDataPakan); + formik.setFieldValue('feed_data', updatedFeedData); }; - const removeSelectedDataPakan = () => { - const updatedDataPakan = formik.values.data_pakan?.filter( - (_, idx) => !selectedPakan.includes(idx) + const removeSelectedFeedData = () => { + const updatedFeedData = formik.values.feed_data?.filter( + (_, idx) => !selectedFeed.includes(idx) ); - formik.setFieldValue('data_pakan', updatedDataPakan); - setSelectedPakan([]); + formik.setFieldValue('feed_data', updatedFeedData); + setSelectedFeed([]); }; - const addBobotBadan = () => { - const newBobotBadan = [ - ...(formik.values.bobot_badan || []), + const addBodyWeight = () => { + const newBodyWeight = [ + ...(formik.values.body_weight || []), { - berat_ayam: 0, - jumlah_ayam: 0, - rata_rata_berat_ayam: 0, + chicken_weight: 0, + chicken_count: 0, + average_chicken_weight: 0, }, ]; - formik.setFieldValue('bobot_badan', newBobotBadan); + formik.setFieldValue('body_weight', newBodyWeight); }; - const removeBobotBadan = (idx: number) => { - const updatedBobotBadan = formik.values.bobot_badan?.filter( + const removeBodyWeight = (idx: number) => { + const updatedBodyWeight = formik.values.body_weight?.filter( (_, i) => i !== idx ); - formik.setFieldValue('bobot_badan', updatedBobotBadan); + formik.setFieldValue('body_weight', updatedBodyWeight); }; - const removeSelectedBobotBadan = () => { - const updatedBobotBadan = formik.values.bobot_badan?.filter( - (_, idx) => !selectedBobot.includes(idx) + const removeSelectedBodyWeight = () => { + const updatedBodyWeight = formik.values.body_weight?.filter( + (_, idx) => !selectedWeight.includes(idx) ); - formik.setFieldValue('bobot_badan', updatedBobotBadan); - setSelectedBobot([]); + formik.setFieldValue('body_weight', updatedBodyWeight); + setSelectedWeight([]); }; - const addVaksinasi = () => { - const newVaksinasi = [ - ...(formik.values.vaksinasi || []), + const addVaccination = () => { + const newVaccination = [ + ...(formik.values.vaccination || []), { - nama_vaksin: '', + vaccine_name: '', total_stock: 0, - jumlah_stock: 0, + used_stock: 0, }, ]; - formik.setFieldValue('vaksinasi', newVaksinasi); + formik.setFieldValue('vaccination', newVaccination); }; - const removeVaksinasi = (idx: number) => { - const updatedVaksinasi = formik.values.vaksinasi?.filter( + const removeVaccination = (idx: number) => { + const updatedVaccination = formik.values.vaccination?.filter( (_, i) => i !== idx ); - formik.setFieldValue('vaksinasi', updatedVaksinasi); + formik.setFieldValue('vaccination', updatedVaccination); }; - const removeSelectedVaksinasi = () => { - const updatedVaksinasi = formik.values.vaksinasi?.filter( - (_, idx) => !selectedVaksin.includes(idx) + const removeSelectedVaccination = () => { + const updatedVaccination = formik.values.vaccination?.filter( + (_, idx) => !selectedVaccine.includes(idx) ); - formik.setFieldValue('vaksinasi', updatedVaksinasi); - setSelectedVaksin([]); + formik.setFieldValue('vaccination', updatedVaccination); + setSelectedVaccine([]); }; - const addMortalitas = () => { - const newMortalitas = [ - ...(formik.values.mortalitas || []), + const addMortality = () => { + const newMortality = [ + ...(formik.values.mortality || []), { - kondisi: RECORDING_FLAG_OPTIONS[0].value, - jumlah: 0, + condition: RECORDING_FLAG_OPTIONS[0].value, + count: 0, }, ]; - formik.setFieldValue('mortalitas', newMortalitas); + formik.setFieldValue('mortality', newMortality); }; - const removeMortalitas = (idx: number) => { - const updatedMortalitas = formik.values.mortalitas?.filter( + const removeMortality = (idx: number) => { + const updatedMortality = formik.values.mortality?.filter( (_, i) => i !== idx ); - formik.setFieldValue('mortalitas', updatedMortalitas); + formik.setFieldValue('mortality', updatedMortality); }; - const removeSelectedMortalitas = () => { - const updatedMortalitas = formik.values.mortalitas?.filter( - (_, idx) => !selectedMortal.includes(idx) + const removeSelectedMortality = () => { + const updatedMortality = formik.values.mortality?.filter( + (_, idx) => !selectedMortality.includes(idx) ); - formik.setFieldValue('mortalitas', updatedMortalitas); - setSelectedMortal([]); + formik.setFieldValue('mortality', updatedMortality); + setSelectedMortality([]); }; return ( @@ -331,8 +383,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='date' name='tanggal_recording' value={ - formik.values.tanggal_recording instanceof Date - ? formik.values.tanggal_recording + formik.values.recording_date instanceof Date + ? formik.values.recording_date .toISOString() .substring(0, 10) : '' @@ -345,20 +397,59 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }} onBlur={formik.handleBlur} isError={ - formik.touched.tanggal_recording && - Boolean(formik.errors.tanggal_recording) + formik.touched.recording_date && + Boolean(formik.errors.recording_date) } - errorMessage={formik.errors.tanggal_recording as string} + errorMessage={formik.errors.recording_date as string} readOnly={type === 'detail'} /> +
+ + + +
{' '} - {/* Data Pakan Table */} + {/* Feed Data Table */}
-

Data Pakan

+

Feed Data

Qty Supplier Plat NomorNo Surat Jalan DokumenBiaya Ekspedisi (Rp.)Biaya Ekspedisi / Item (Rp.)Biaya Pengiriman (Rp.)Biaya Per Item (Rp.) Nama SopirAksi
{ if (e.target.checked) { - setSelectedEkspedisi([ - ...selectedEkspedisi, + setSelectedDeliveries([ + ...selectedDeliveries, idx, ]); } else { - setSelectedEkspedisi( - selectedEkspedisi.filter((i) => i !== idx) + setSelectedDeliveries( + selectedDeliveries.filter((i) => i !== idx) ); } }} @@ -706,24 +738,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { formik.setFieldValue( - `ekspedisi.${idx}.product`, + `deliveries.${idx}.products.0.product`, val ); formik.setFieldValue( - `ekspedisi.${idx}.product_id`, + `deliveries.${idx}.products.0.product_id`, (val as OptionType)?.value ); - formik.setFieldValue(`ekspedisi.${idx}.qty`, ''); }} options={getFilteredProductOptions()} - {...isRepeaterInputError( - 'ekspedisi', - 'product', - idx - )} isDisabled={type === 'detail'} isClearable /> @@ -732,11 +758,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { { formik.setFieldValue( - `ekspedisi.${idx}.supplier`, + `deliveries.${idx}.supplier`, val ); formik.setFieldValue( - `ekspedisi.${idx}.supplier_id`, + `deliveries.${idx}.supplier_id`, (val as OptionType)?.value ); }} @@ -762,128 +787,75 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { isLoading={isLoadingSuppliers} isDisabled={type === 'detail'} isClearable - {...isRepeaterInputError( - 'ekspedisi', - 'supplier', - idx - )} /> - - { const file = e.target.files?.[0]; if (file) { - const allowedTypes = [ - 'application/pdf', - 'image/jpeg', - 'image/jpg', - ]; - if (!allowedTypes.includes(file.type)) { - toast.error( - 'Mohon upload file berformat PDF atau JPEG/JPG.' - ); - return; - } if (file.size > 2 * 1024 * 1024) { toast.error('Ukuran dokumen maksimal 2 MB!'); return; } formik.setFieldValue( - `ekspedisi.${idx}.dokumen`, + `deliveries.${idx}.document`, file ); } }} {...isRepeaterInputError( - 'ekspedisi', - 'dokumen', + 'deliveries', + 'document', idx )} readOnly={type === 'detail'} - className={{ - wrapper: 'w-full min-w-24', - }} /> @@ -891,19 +863,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
@@ -369,45 +460,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.data_pakan?.length === - selectedPakan.length && - formik.values.data_pakan?.length > 0 + formik.values.feed_data?.length === + selectedFeed.length && + formik.values.feed_data?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedPakan( - formik.values.data_pakan?.map( + setSelectedFeed( + formik.values.feed_data?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedPakan([]); + setSelectedFeed([]); } }} /> )} - - - - {type !== 'detail' && } + + + + {type !== 'detail' && } - {formik.values.data_pakan?.map((pakan, idx) => ( - + {formik.values.feed_data?.map((feed, idx) => ( + {type !== 'detail' && (
Nama PakanQty PakanStock PakanAksiFeed NameFeed QtyFeed StockAction
{ if (e.target.checked) { - setSelectedPakan([...selectedPakan, idx]); + setSelectedFeed([...selectedFeed, idx]); } else { - setSelectedPakan( - selectedPakan.filter((i) => i !== idx) + setSelectedFeed( + selectedFeed.filter((i) => i !== idx) ); } }} @@ -417,21 +508,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { { )} )} - {/* Bobot Badan Table */} + {/* Body Weight Table */}
-

Bobot Badan

+

Body Weight

@@ -563,45 +648,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.bobot_badan?.length === - selectedBobot.length && - formik.values.bobot_badan?.length > 0 + formik.values.body_weight?.length === + selectedWeight.length && + formik.values.body_weight?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedBobot( - formik.values.bobot_badan?.map( + setSelectedWeight( + formik.values.body_weight?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedBobot([]); + setSelectedWeight([]); } }} /> )} - - - - {type !== 'detail' && } + + + + {type !== 'detail' && } - {formik.values.bobot_badan?.map((bobot, idx) => ( - + {formik.values.body_weight?.map((weight, idx) => ( + {type !== 'detail' && (
Berat AyamJumlah AyamRata-rata Berat AyamAksiChicken WeightChicken CountAverage WeightAction
{ if (e.target.checked) { - setSelectedBobot([...selectedBobot, idx]); + setSelectedWeight([...selectedWeight, idx]); } else { - setSelectedBobot( - selectedBobot.filter((i) => i !== idx) + setSelectedWeight( + selectedWeight.filter((i) => i !== idx) ); } }} @@ -612,21 +697,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { { )} )} - {/* Vaksinasi Table */} + {/* Vaccination Table */}
-

Vaksinasi

+

Vaccination

@@ -749,45 +834,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.vaksinasi?.length === - selectedVaksin.length && - formik.values.vaksinasi?.length > 0 + formik.values.vaccination?.length === + selectedVaccine.length && + formik.values.vaccination?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedVaksin( - formik.values.vaksinasi?.map( + setSelectedVaccine( + formik.values.vaccination?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedVaksin([]); + setSelectedVaccine([]); } }} /> )} - + - - {type !== 'detail' && } + + {type !== 'detail' && } - {formik.values.vaksinasi?.map((vaksin, idx) => ( - + {formik.values.vaccination?.map((vaccine, idx) => ( + {type !== 'detail' && (
Nama VaksinVaccine Name Total StockJumlah StockAksiUsed StockAction
{ if (e.target.checked) { - setSelectedVaksin([...selectedVaksin, idx]); + setSelectedVaccine([...selectedVaccine, idx]); } else { - setSelectedVaksin( - selectedVaksin.filter((i) => i !== idx) + setSelectedVaccine( + selectedVaccine.filter((i) => i !== idx) ); } }} @@ -797,21 +882,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { { )} )} - {/* Mortalitas Table */} + {/* Mortality Table */}
-

Mortalitas

+

Mortality

@@ -943,44 +1028,47 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.mortalitas?.length === - selectedMortal.length && - formik.values.mortalitas?.length > 0 + formik.values.mortality?.length === + selectedMortality.length && + formik.values.mortality?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedMortal( - formik.values.mortalitas?.map( + setSelectedMortality( + formik.values.mortality?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedMortal([]); + setSelectedMortality([]); } }} /> )} - - - {type !== 'detail' && } + + + {type !== 'detail' && } - {formik.values.mortalitas?.map((mortal, idx) => ( - + {formik.values.mortality?.map((mortality, idx) => ( + {type !== 'detail' && (
KondisiJumlahAksiConditionCountAction
{ if (e.target.checked) { - setSelectedMortal([...selectedMortal, idx]); + setSelectedMortality([ + ...selectedMortality, + idx, + ]); } else { - setSelectedMortal( - selectedMortal.filter((i) => i !== idx) + setSelectedMortality( + selectedMortality.filter((i) => i !== idx) ); } }} @@ -991,21 +1079,27 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { opt.value === mortal.kondisi + (opt) => opt.value === mortality.condition )} onChange={(val) => { formik.setFieldValue( - `mortalitas.${idx}.kondisi`, + `mortality.${idx}.condition`, (val as OptionType)?.value ); }} isError={ - isRepeaterInputError('mortalitas', 'kondisi', idx) - .isError + isRepeaterInputError( + 'mortality', + 'condition', + idx + ).isError } errorMessage={ - isRepeaterInputError('mortalitas', 'kondisi', idx) - .errorMessage + isRepeaterInputError( + 'mortality', + 'condition', + idx + ).errorMessage } options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} @@ -1015,16 +1109,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { )} )} diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 69c2b0b5..a39588b5 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -1,28 +1,32 @@ import { BaseMetadata } from '@/types/api/api-general'; import { Flock } from '@/types/api/flock/flock'; +import { Location } from '@/types/api/master-data/location'; +import { Kandang } from '@/types/api/master-data/kandang'; export type BaseRecording = { id: number; flock: Flock; - tanggal_recording: string; - data_pakan: { - nama_pakan: string; - qty_pakan: number; - stock_pakan: number; + recording_date: string; + location: Location; + coop: Kandang; + feed_data: { + feed_name: string; + feed_qty: number; + feed_stock: number; }[]; - bobot_badan: { - berat_ayam: number; - jumlah_ayam: number; - rata_rata_berat_ayam: number; + body_weight: { + chicken_weight: number; + chicken_count: number; + average_chicken_weight: number; }[]; - vaksinasi: { - nama_vaksin: string; + vaccination: { + vaccine_name: string; total_stock: number; - jumlah_stock: number; + used_stock: number; }[]; - mortalitas: { - kondisi: string; - jumlah: number; + mortality: { + condition: string; + count: number; }[]; }; @@ -30,25 +34,27 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { flock_id: number; - tanggal_recording: string; - data_pakan: { - nama_pakan: string; - qty_pakan: number; - stock_pakan: number; + recording_date: string; + location_id: number; + coop_id: number; + feed_data: { + feed_name: string; + feed_qty: number; + feed_stock: number; }[]; - bobot_badan: { - berat_ayam: number; - jumlah_ayam: number; - rata_rata_berat_ayam: number; + body_weight: { + chicken_weight: number; + chicken_count: number; + average_chicken_weight: number; }[]; - vaksinasi: { - nama_vaksin: string; + vaccination: { + vaccine_name: string; total_stock: number; - jumlah_stock: number; + used_stock: number; }[]; - mortalitas: { - kondisi: string; - jumlah: number; + mortality: { + condition: string; + count: number; }[]; }; From 8bfce061e6f2cc37cfc4fa039d3776a3024510e4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 17:53:08 +0700 Subject: [PATCH 037/212] refactor(FE-114,136): improve location and coop field handling in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index afc9cd84..d8656b2b 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -150,21 +150,42 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Handlers const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('location', true); + const locationValue = (val as OptionType)?.value; + formik.setFieldValue('location', val); - formik.setFieldTouched('location_id', true); - formik.setFieldValue('location_id', (val as OptionType)?.value); - // Reset coop selection when location changes + formik.setFieldValue('location_id', locationValue || 0); + + // Only set touched if there's a value + if (locationValue) { + formik.setFieldTouched('location', true); + formik.setFieldTouched('location_id', true); + } else { + formik.setFieldTouched('location', false); + formik.setFieldTouched('location_id', false); + } + + // Reset coop selection when location changes or is cleared formik.setFieldValue('coop', null); formik.setFieldValue('coop_id', 0); + formik.setFieldTouched('coop', false); + formik.setFieldTouched('coop_id', false); setCoopSelectInputValue(''); }; const coopChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('coop', true); + const coopValue = (val as OptionType)?.value; + formik.setFieldValue('coop', val); - formik.setFieldTouched('coop_id', true); - formik.setFieldValue('coop_id', (val as OptionType)?.value); + formik.setFieldValue('coop_id', coopValue || 0); + + // Only set touched if there's a value + if (coopValue) { + formik.setFieldTouched('coop', true); + formik.setFieldTouched('coop_id', true); + } else { + formik.setFieldTouched('coop', false); + formik.setFieldTouched('coop_id', false); + } }; const isRepeaterInputError = ( @@ -423,6 +444,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> Date: Wed, 15 Oct 2025 20:01:41 +0700 Subject: [PATCH 038/212] feat(FE-102) create master data flock and add LTI theme --- src/app/globals.css | 37 +++ src/app/layout.tsx | 2 +- src/app/master-data/flock/add/page.tsx | 11 + .../master-data/flock/detail/edit/page.tsx | 47 ++++ src/app/master-data/flock/detail/page.tsx | 44 +++ src/app/master-data/flock/page.tsx | 11 + src/components/Button.tsx | 2 +- .../pages/master-data/flock/FlocksTable.tsx | 264 ++++++++++++++++++ .../flock/form/FlockForm.schema.ts | 14 + .../master-data/flock/form/FlockForm.tsx | 217 ++++++++++++++ src/config/constant.ts | 5 + src/services/api/master-data.ts | 11 + src/types/api/master-data/flock.d.ts | 14 + 13 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 src/app/master-data/flock/add/page.tsx create mode 100644 src/app/master-data/flock/detail/edit/page.tsx create mode 100644 src/app/master-data/flock/detail/page.tsx create mode 100644 src/app/master-data/flock/page.tsx create mode 100644 src/components/pages/master-data/flock/FlocksTable.tsx create mode 100644 src/components/pages/master-data/flock/form/FlockForm.schema.ts create mode 100644 src/components/pages/master-data/flock/form/FlockForm.tsx create mode 100644 src/types/api/master-data/flock.d.ts diff --git a/src/app/globals.css b/src/app/globals.css index 386e7620..0fb52327 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,43 @@ @import 'tailwindcss'; @plugin "daisyui"; +@plugin "daisyui/theme" { + name: "corporate"; + default: false; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(98% 0.001 106.423); + --color-base-200: oklch(97% 0.001 106.424); + --color-base-300: oklch(92% 0.003 48.717); + --color-base-content: oklch(22.389% 0.031 278.072); + --color-primary: oklch(60% 0.126 221.723); + --color-primary-content: oklch(100% 0 0); + --color-secondary: oklch(52% 0.105 223.128); + --color-secondary-content: oklch(100% 0 0); + --color-accent: oklch(45% 0.085 224.283); + --color-accent-content: oklch(100% 0 0); + --color-neutral: oklch(39% 0.07 227.392); + --color-neutral-content: oklch(100% 0 0); + --color-info: oklch(58% 0.158 241.966); + --color-info-content: oklch(100% 0 0); + --color-success: oklch(62% 0.194 149.214); + --color-success-content: oklch(100% 0 0); + --color-warning: oklch(85% 0.199 91.936); + --color-warning-content: oklch(0% 0 0); + --color-error: oklch(57% 0.245 27.325); + --color-error-content: oklch(100% 0 0); + --radius-selector: 0rem; + --radius-field: 0.25rem; + --radius-box: 0.25rem; + --size-selector: 0.21875rem; + --size-field: 0.1875rem; + --border: 1px; + --depth: 0; + --noise: 0; +} + + + :root { --color-primary: #1f74bf; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef28da38..c19b8a77 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -28,7 +28,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/app/master-data/flock/add/page.tsx b/src/app/master-data/flock/add/page.tsx new file mode 100644 index 00000000..5ee3958e --- /dev/null +++ b/src/app/master-data/flock/add/page.tsx @@ -0,0 +1,11 @@ +import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; + +const AddFlock = () => { + return ( +
+ +
+ ); +} + +export default AddFlock; diff --git a/src/app/master-data/flock/detail/edit/page.tsx b/src/app/master-data/flock/detail/edit/page.tsx new file mode 100644 index 00000000..c3903555 --- /dev/null +++ b/src/app/master-data/flock/detail/edit/page.tsx @@ -0,0 +1,47 @@ +import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { FlockApi } from "@/services/api/master-data"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const FlockEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const flockId = searchParams.get('flockId'); + + // Fetch Data + const { data: flock, isLoading: isLoadingFlock } = useSWR( + flockId, + (id: number) => FlockApi.getSingle(id) + ); + + if (!flockId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingFlock && (!flock || isResponseError(flock))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFlock && ( + + )} + {!isLoadingFlock && isResponseSuccess(flock) && ( + + )} +
+ ); +} + +export default FlockEdit; \ No newline at end of file diff --git a/src/app/master-data/flock/detail/page.tsx b/src/app/master-data/flock/detail/page.tsx new file mode 100644 index 00000000..cedc3243 --- /dev/null +++ b/src/app/master-data/flock/detail/page.tsx @@ -0,0 +1,44 @@ +import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { FlockApi } from "@/services/api/master-data"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const FlockDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const flockId = searchParams.get('flockId'); + + // Fetch Data + const { data: flock, isLoading: isLoadingFlock } = useSWR(flockId, (id: number) => FlockApi.getSingle(id)); + + if(!flockId){ + router.back(); + + return ( +
+ +
+ ); + } + + if(!isLoadingFlock && (!flock || isResponseError(flock))){ + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFlock && ( + + )} + {!isLoadingFlock && isResponseSuccess(flock) && ( + + )} +
+ ); +} + +export default FlockDetail; \ No newline at end of file diff --git a/src/app/master-data/flock/page.tsx b/src/app/master-data/flock/page.tsx new file mode 100644 index 00000000..b317091a --- /dev/null +++ b/src/app/master-data/flock/page.tsx @@ -0,0 +1,11 @@ +import FlockTable from "@/components/pages/master-data/flock/FlocksTable"; + +const Flock = () => { + return ( +
+ +
+ ); +} + +export default Flock; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c67a29c2..5da6e5ad 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -43,7 +43,7 @@ const Button = ({ 'btn-warning': color === 'warning', 'btn-error': color === 'error', }, - 'h-fit justify-center items-center gap-2 rounded-xl p-2 text-base transition-all' + 'h-fit justify-center items-center gap-2 rounded p-2 text-base transition-all' ); return ( diff --git a/src/components/pages/master-data/flock/FlocksTable.tsx b/src/components/pages/master-data/flock/FlocksTable.tsx new file mode 100644 index 00000000..817eff40 --- /dev/null +++ b/src/components/pages/master-data/flock/FlocksTable.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { CellContext, ColumnDef } from '@tanstack/react-table'; +import { Flock } from '@/types/api/master-data/flock'; +import { cn } from '@/lib/helper'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { use, useState } from 'react'; +import useSWR from 'swr'; +import { FlockApi } from '@/services/api/master-data'; +import { useModal } from '@/components/Modal'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import toast from 'react-hot-toast'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { ROWS_OPTIONS } from '@/config/constant'; +import Table from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +const RowsOptions = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + +
+ ); +}; + +const FlockTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + }, + }); + + // Fetch Data + const { + data: flocks, + isLoading, + mutate: refreshFlocks, + } = useSWR( + `${FlockApi.basePath}${getTableFilterQueryString()}`, + FlockApi.getAllFetcher + ); + + // State + const deleteModal = useModal(); + const [selectedFlock, setSelectedFlock] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // Columns Definition + const flocksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'created_at', + header: 'Dibuat pada', + cell: (props) => + new Date(props.row.original.created_at).toLocaleDateString(), + }, + { + 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 = () => { + setSelectedFlock(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + // Handler + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await FlockApi.delete(selectedFlock?.id as number); + refreshFlocks(); + + deleteModal.closeModal(); + toast.success('Successfully delete Flock!'); + setIsDeleteLoading(false); + }; + const searchChangeHandler = (e: React.ChangeEvent) => { + updateFilter('search', e.target.value); + }; + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(flocks) ? flocks?.data : []} + columns={flocksColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(flocks) ? flocks?.meta?.page : 0} + totalItems={ + isResponseSuccess(flocks) ? flocks?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + className={{ + containerClassName: cn({ + 'mb-20': isResponseSuccess(flocks) && flocks?.data?.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', + }} + /> +
+ + + ); +}; + +export default FlockTable; \ No newline at end of file diff --git a/src/components/pages/master-data/flock/form/FlockForm.schema.ts b/src/components/pages/master-data/flock/form/FlockForm.schema.ts new file mode 100644 index 00000000..0a85b0fc --- /dev/null +++ b/src/components/pages/master-data/flock/form/FlockForm.schema.ts @@ -0,0 +1,14 @@ +import * as Yup from 'yup'; + +export const FlockFormSchema = Yup.object({ + name: Yup.string() + .required('Nama wajib diisi!') + .matches( + /^[a-zA-Z0-9]+$/, + 'Nama hanya boleh berisi huruf dan angka (tanpa spasi atau simbol)' + ), +}); + +export const UpdateFlockFormSchema = FlockFormSchema; + +export type FlockFormValues = Yup.InferType; \ No newline at end of file diff --git a/src/components/pages/master-data/flock/form/FlockForm.tsx b/src/components/pages/master-data/flock/form/FlockForm.tsx new file mode 100644 index 00000000..f73d47f0 --- /dev/null +++ b/src/components/pages/master-data/flock/form/FlockForm.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useModal } from '@/components/Modal'; +import { FlockApi } from '@/services/api/master-data'; +import { Flock } from '@/types/api/master-data/flock'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; +import { FlockFormSchema, FlockFormValues, UpdateFlockFormSchema } from './FlockForm.schema'; +import { useFormik } from 'formik'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import TextInput from '@/components/input/TextInput'; +import { cn } from '@/lib/helper'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +interface FlockCustomProps { + formType?: 'add' | 'edit' | 'detail'; + initialValues?: Flock; +} + +const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + // State + const [flockFormErrorMessage, setFlockFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // Handler + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await FlockApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + setIsDeleteLoading(false); + router.push('/master-data/flock'); + }; + + // Initital Value + const formikInitialValue = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + // Formik + const formik = useFormik({ + initialValues: formikInitialValue, + enableReinitialize: true, + validationSchema: formType === 'edit' ? UpdateFlockFormSchema : FlockFormSchema, + onSubmit: async (values) => { + // reset error message + setFlockFormErrorMessage(''); + + // create payload + const payload = { + name: values.name, + }; + + // cek type form yang disubmit + switch (formType) { + case 'add': + await FlockApi.create(payload); + break; + case 'edit': + await FlockApi.update(initialValues?.id as number, payload); + break; + default: + break; + } + + router.push('/master-data/flock'); + }, + }); + + // Initialize Formik + const { setValues: formikSetValues } = formik; + useEffect(() => { + formikSetValues(formikInitialValue); + }, [formikSetValues, formikInitialValue]); + + // Render + return ( + <> +
+
+ + +

+ {formType === 'add' && 'Tambah Flock'} + {formType === 'edit' && 'Ubah Flock'} + {formType === 'detail' && 'Detail Flock'} +

+
+
+ {/* Fields Form */} +
+ +
+ + {/* Action Button */} +
+ {formType !== 'add' && ( +
+ + {formType !== 'edit' && ( + + )} +
+ )} + + {formType !== 'detail' && ( +
+ + + +
+ )} +
+ + {flockFormErrorMessage && ( +
+ + {flockFormErrorMessage} +
+ )} +
+
+ + {formType !== 'add' && ( + + )} + + ); +}; + +export default FlockForm; diff --git a/src/config/constant.ts b/src/config/constant.ts index ed68adb5..97e4c285 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -77,6 +77,11 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ link: '/master-data/supplier', icon: 'material-symbols:add-business-outline-rounded', }, + { + title: 'Flock', + link: '/master-data/flock', + icon: 'material-symbols:raven-outline-rounded', + }, ], }, { diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts index dce528e7..854bb8f3 100644 --- a/src/services/api/master-data.ts +++ b/src/services/api/master-data.ts @@ -59,6 +59,11 @@ import { Fcr, UpdateFcrPayload, } from '@/types/api/master-data/fcr'; +import { + CreateFlockPayload, + Flock, + UpdateFlockPayload, +} from '@/types/api/master-data/flock'; export const UomApi = new BaseApiService< Uom, @@ -130,3 +135,9 @@ export const FcrApi = new BaseApiService< CreateFcrPayload, UpdateFcrPayload >('/master-data/fcrs'); + +export const FlockApi = new BaseApiService< + Flock, + CreateFlockPayload, + UpdateFlockPayload +>('/master-data/flocks'); \ No newline at end of file diff --git a/src/types/api/master-data/flock.d.ts b/src/types/api/master-data/flock.d.ts new file mode 100644 index 00000000..0c59b84c --- /dev/null +++ b/src/types/api/master-data/flock.d.ts @@ -0,0 +1,14 @@ +import { BaseMetadata } from "../api-general"; + +export type BaseFlock = { + id: number; + name: string; +} + +export type Flock = BaseMetadata & BaseFlock; + +export type CreateFlockPayload = { + name: string; +} + +export type UpdateFlockPayload = CreateFlockPayload; \ No newline at end of file From f319a9b5d10e88849b0967abcf94500629b9e737 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 08:39:32 +0700 Subject: [PATCH 039/212] feat(FE-114): implement RecordingEdit and RecordingDetail components with error handling and loading states --- src/app/flock/recording/detail/edit/page.tsx | 47 +++ src/app/flock/recording/detail/page.tsx | 47 +++ src/app/flock/recording/page.tsx | 14 +- .../pages/flock/recording/RecordingTable.tsx | 358 ++++++++++++++++++ src/config/constant.ts | 1 - 5 files changed, 460 insertions(+), 7 deletions(-) diff --git a/src/app/flock/recording/detail/edit/page.tsx b/src/app/flock/recording/detail/edit/page.tsx index e69de29b..0718731c 100644 --- a/src/app/flock/recording/detail/edit/page.tsx +++ b/src/app/flock/recording/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import RecordingForm from '@/components/pages/flock/recording/form/RecordingForm'; +import { RecordingApi } from '@/services/api/flock'; // Import RecordingApi +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const RecordingEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId, + (id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || isResponseError(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && isResponseSuccess(recording) && ( + + )} +
+ ); +}; + +export default RecordingEdit; diff --git a/src/app/flock/recording/detail/page.tsx b/src/app/flock/recording/detail/page.tsx index e69de29b..a25fe998 100644 --- a/src/app/flock/recording/detail/page.tsx +++ b/src/app/flock/recording/detail/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import RecordingForm from '@/components/pages/flock/recording/form/RecordingForm'; +import { RecordingApi } from '@/services/api/flock'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const RecordingDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId, + (id: number) => RecordingApi.getSingle(id) + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || isResponseError(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && isResponseSuccess(recording) && ( + + )} +
+ ); +}; + +export default RecordingDetail; diff --git a/src/app/flock/recording/page.tsx b/src/app/flock/recording/page.tsx index 01154025..06f42789 100644 --- a/src/app/flock/recording/page.tsx +++ b/src/app/flock/recording/page.tsx @@ -1,9 +1,11 @@ -import Link from 'next/link'; +import RecordingTable from '@/components/pages/flock/recording/RecordingTable'; -export default function Page() { +const Recording = () => { return ( - <> - Recording - +
+ +
); -} +}; + +export default Recording; diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx index e69de29b..ea7967a8 100644 --- a/src/components/pages/flock/recording/RecordingTable.tsx +++ b/src/components/pages/flock/recording/RecordingTable.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { Icon } from '@iconify/react'; +import { SortingState } from '@tanstack/react-table'; +import { cn } from '@/lib/helper'; +import { useModal } from '@/components/Modal'; +import Button from '@/components/Button'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { OptionType } from '@/components/input/SelectInput'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { TableToolbar } from '@/components/table/TableToolbar'; +import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; +import Table from '@/components/Table'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import { type CellContext } from '@tanstack/react-table'; +import { type Recording } from '@/types/api/flock/recording'; + +const dummyRecordings: Recording[] = [ + { + id: 1, + flock: { + id: 1, + name: 'Flock A', + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, + recording_date: '2024-01-01', + location: { + id: 1, + name: 'Location 1', + address: 'Jl. Contoh No. 1', + area: { + id: 1, + name: 'Area 1', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, + coop: { + id: 1, + name: 'Coop 1', + location: { + id: 1, + name: 'Location 1', + address: 'Jl. Contoh No. 1', + area: { + id: 1, + name: 'Area 1', + }, + }, + pic: { + id: 1, + id_user: 1, + email: 'pic@example.com', + name: 'PIC User', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, + feed_data: [ + { + feed_name: 'Feed 1', + feed_qty: 100, + feed_stock: 500, + }, + ], + body_weight: [ + { + chicken_weight: 2.5, + chicken_count: 1000, + average_chicken_weight: 2.5, + }, + ], + vaccination: [ + { + vaccine_name: 'Vaccine 1', + total_stock: 200, + used_stock: 150, + }, + ], + mortality: [ + { + condition: 'NORMAL', + count: 5, + }, + ], + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, +]; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + +
+ ); +}; + +const RecordingTable = () => { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [sorting, setSorting] = useState([]); + const [, setSelectedRecording] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const deleteModal = useModal(); + + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + setSearch(e.target.value); + setPage(1); + }, + [] + ); + + const pageSizeChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }, + [] + ); + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + setTimeout(() => { + setIsDeleteLoading(false); + deleteModal.closeModal(); + }, 1000); + }; + + const paginatedData = useMemo(() => { + const filteredData = dummyRecordings.filter( + (recording) => + recording.flock.name.toLowerCase().includes(search.toLowerCase()) || + recording.location.name.toLowerCase().includes(search.toLowerCase()) || + recording.coop.name.toLowerCase().includes(search.toLowerCase()) + ); + const start = (page - 1) * pageSize; + return filteredData.slice(start, start + pageSize); + }, [page, pageSize, search]); + + return ( +
+
+ + +
+ + pageSize * (page - 1) + props.row.index + 1, + }, + { + accessorKey: 'flock.name', + header: 'Flock', + }, + { + accessorKey: 'recording_date', + header: 'Tanggal Recording', + cell: (props) => + new Date(props.row.original.recording_date).toLocaleDateString(), + }, + { + accessorKey: 'location.name', + header: 'Lokasi', + }, + { + accessorKey: 'coop.name', + header: 'Kandang', + }, + { + accessorKey: 'mortality', + header: 'Total Mortality', + cell: (props) => + props.row.original.mortality.reduce( + (acc, curr) => acc + curr.count, + 0 + ), + }, + { + header: 'Aksi', + cell: (props: CellContext) => { + 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 = () => { + setSelectedRecording(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]} + pageSize={pageSize} + page={page} + totalItems={dummyRecordings.length} + onPageChange={setPage} + isLoading={false} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': paginatedData.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', + }} + /> + + + + ); +}; + +export default RecordingTable; diff --git a/src/config/constant.ts b/src/config/constant.ts index 87dcd927..668d7209 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -203,5 +203,4 @@ export const RECORDING_FLAG_OPTIONS = [ { label: 'Ayam Afkir', value: 'Ayam Afkir' }, { label: 'Ayam Culling', value: 'Ayam Culling' }, { label: 'Ayam Mati', value: 'Ayam Mati' }, - { label: 'DOC', value: 'DOC' }, ]; From 64e67246647a1fe9979606377fb86627fdeeb058 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 09:07:12 +0700 Subject: [PATCH 040/212] feat(FE-114): add bulk action functionality for approving, rejecting, and deleting recordings in RecordingTable --- .../pages/flock/recording/RecordingTable.tsx | 203 ++++++++++++++++-- 1 file changed, 191 insertions(+), 12 deletions(-) diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx index ea7967a8..f2031037 100644 --- a/src/components/pages/flock/recording/RecordingTable.tsx +++ b/src/components/pages/flock/recording/RecordingTable.tsx @@ -176,10 +176,16 @@ const RecordingTable = () => { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); + const [selectedRecordings, setSelectedRecordings] = useState([]); const [, setSelectedRecording] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); + const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); - const deleteModal = useModal(); + const singleDeleteModal = useModal(); + const bulkDeleteModal = useModal(); + const bulkApproveModal = useModal(); + const bulkRejectModal = useModal(); const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { @@ -198,14 +204,6 @@ const RecordingTable = () => { [] ); - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - setTimeout(() => { - setIsDeleteLoading(false); - deleteModal.closeModal(); - }, 1000); - }; - const paginatedData = useMemo(() => { const filteredData = dummyRecordings.filter( (recording) => @@ -217,6 +215,53 @@ const RecordingTable = () => { return filteredData.slice(start, start + pageSize); }, [page, pageSize, search]); + const bulkApproveHandler = async () => { + setIsBulkApproveLoading(true); + console.log( + 'Approved recordings:', + paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) + ); + setTimeout(() => { + setIsBulkApproveLoading(false); + setSelectedRecordings([]); + bulkApproveModal.closeModal(); + }, 1000); + }; + + const bulkRejectHandler = async () => { + setIsBulkRejectLoading(true); + console.log( + 'Rejected recordings:', + paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) + ); + setTimeout(() => { + setIsBulkRejectLoading(false); + setSelectedRecordings([]); + bulkRejectModal.closeModal(); + }, 1000); + }; + + const singleDeleteHandler = async () => { + setIsDeleteLoading(true); + setTimeout(() => { + setIsDeleteLoading(false); + singleDeleteModal.closeModal(); + }, 1000); + }; + + const bulkDeleteHandler = async () => { + setIsDeleteLoading(true); + console.log( + 'Deleted recordings:', + paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) + ); + setTimeout(() => { + setIsDeleteLoading(false); + setSelectedRecordings([]); + bulkDeleteModal.closeModal(); + }, 1000); + }; + return (
@@ -238,9 +283,143 @@ const RecordingTable = () => { />
+ {/* Bulk action buttons */} +
+ {selectedRecordings.length > 0 && ( +
+ + + +
+ )} + + + + + + +
+
( + 0 && + table + .getRowModel() + .rows.every((row) => selectedRecordings.includes(row.index)) + } + onChange={(e) => { + if (e.target.checked) { + setSelectedRecordings( + table.getRowModel().rows.map((row) => row.index) + ); + } else { + setSelectedRecordings([]); + } + }} + /> + ), + cell: ({ row }) => ( + { + if (e.target.checked) { + setSelectedRecordings([...selectedRecordings, row.index]); + } else { + setSelectedRecordings( + selectedRecordings.filter((i) => i !== row.index) + ); + } + }} + /> + ), + }, { header: '#', cell: (props) => pageSize * (page - 1) + props.row.index + 1, @@ -286,7 +465,7 @@ const RecordingTable = () => { const deleteClickHandler = () => { setSelectedRecording(props.row.original); - deleteModal.openModal(); + singleDeleteModal.openModal(); }; return ( @@ -338,7 +517,7 @@ const RecordingTable = () => { /> { text: 'Ya', color: 'error', isLoading: isDeleteLoading, - onClick: confirmationModalDeleteClickHandler, + onClick: singleDeleteHandler, }} /> From ec387637ed488dccbde2e22e266481f1ee2cb2d6 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 09:12:21 +0700 Subject: [PATCH 041/212] refactor(FE-114): remove bulk delete functionality from RecordingTable --- .../pages/flock/recording/RecordingTable.tsx | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx index f2031037..bcdcd4dd 100644 --- a/src/components/pages/flock/recording/RecordingTable.tsx +++ b/src/components/pages/flock/recording/RecordingTable.tsx @@ -183,7 +183,6 @@ const RecordingTable = () => { const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); const singleDeleteModal = useModal(); - const bulkDeleteModal = useModal(); const bulkApproveModal = useModal(); const bulkRejectModal = useModal(); @@ -249,19 +248,6 @@ const RecordingTable = () => { }, 1000); }; - const bulkDeleteHandler = async () => { - setIsDeleteLoading(true); - console.log( - 'Deleted recordings:', - paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) - ); - setTimeout(() => { - setIsDeleteLoading(false); - setSelectedRecordings([]); - bulkDeleteModal.closeModal(); - }, 1000); - }; - return (
@@ -313,20 +299,6 @@ const RecordingTable = () => { /> Reject ({selectedRecordings.length}) -
)} @@ -359,21 +331,6 @@ const RecordingTable = () => { onClick: bulkRejectHandler, }} /> - -
Date: Thu, 16 Oct 2025 09:24:00 +0700 Subject: [PATCH 042/212] refactor(FE-114): enhance layout and structure of RecordingForm component --- .../flock/recording/form/RecordingForm.tsx | 240 +++++++++--------- 1 file changed, 122 insertions(+), 118 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index d8656b2b..a133e437 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -346,125 +346,129 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { > {/* Basic Info Card */}
-
-
- {/* {*/} - {/* formik.setFieldValue(*/} - {/* 'flock_id',*/} - {/* (val as OptionType)?.value*/} - {/* );*/} - {/* }}*/} - {/* options={flockOptions}*/} - {/* onInputChange={setFlockSelectInputValue}*/} - {/* isLoading={isLoadingFlocks}*/} - {/* isError={*/} - {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} - {/* }*/} - {/* errorMessage={formik.errors.flock_id as string}*/} - {/* isDisabled={type === 'detail'}*/} - {/* isClearable*/} - {/*/>*/} - { - formik.setFieldValue('flock', val); - formik.setFieldValue( - 'flock_id', - (val as OptionType)?.value - ); - }} - options={flockOptions} - onInputChange={(val) => { - return val; - }} - isLoading={false} - isError={ - formik.touched.flock_id && Boolean(formik.errors.flock_id) - } - errorMessage={formik.errors.flock_id as string} - isDisabled={type === 'detail'} - isClearable - /> - { - const date = e.target.value - ? new Date(e.target.value) - : new Date(); - formik.setFieldValue('tanggal_recording', date); - }} - onBlur={formik.handleBlur} - isError={ - formik.touched.recording_date && - Boolean(formik.errors.recording_date) - } - errorMessage={formik.errors.recording_date as string} - readOnly={type === 'detail'} - /> -
-
- +
+

Flock

- -
{' '} +
+
+ {/* {*/} + {/* formik.setFieldValue(*/} + {/* 'flock_id',*/} + {/* (val as OptionType)?.value*/} + {/* );*/} + {/* }}*/} + {/* options={flockOptions}*/} + {/* onInputChange={setFlockSelectInputValue}*/} + {/* isLoading={isLoadingFlocks}*/} + {/* isError={*/} + {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} + {/* }*/} + {/* errorMessage={formik.errors.flock_id as string}*/} + {/* isDisabled={type === 'detail'}*/} + {/* isClearable*/} + {/*/>*/} + { + formik.setFieldValue('flock', val); + formik.setFieldValue( + 'flock_id', + (val as OptionType)?.value + ); + }} + options={flockOptions} + onInputChange={(val) => { + return val; + }} + isLoading={false} + isError={ + formik.touched.flock_id && Boolean(formik.errors.flock_id) + } + errorMessage={formik.errors.flock_id as string} + isDisabled={type === 'detail'} + isClearable + /> + { + const date = e.target.value + ? new Date(e.target.value) + : new Date(); + formik.setFieldValue('tanggal_recording', date); + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.recording_date && + Boolean(formik.errors.recording_date) + } + errorMessage={formik.errors.recording_date as string} + readOnly={type === 'detail'} + /> +
+
+ + + +
+
From eb0f04310ebe8f655f192b4d17dae7f17d4abf32 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 16 Oct 2025 10:01:00 +0700 Subject: [PATCH 043/212] chore(FE-91): create daisyui.css file for extending daisyUI style --- src/app/globals.css | 1 + src/styles/daisyui.css | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 src/styles/daisyui.css diff --git a/src/app/globals.css b/src/app/globals.css index 386e7620..79af241b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @plugin "daisyui"; +@import '../styles/daisyui.css'; :root { --color-primary: #1f74bf; diff --git a/src/styles/daisyui.css b/src/styles/daisyui.css new file mode 100644 index 00000000..9a148fb4 --- /dev/null +++ b/src/styles/daisyui.css @@ -0,0 +1,11 @@ +@layer utilities { + .step.step-success::before { + --step-bg: var(--color-success); + --step-fg: var(--color-success-content); + } + + .step.step-error::before { + --step-bg: var(--color-error); + --step-fg: var(--color-error-content); + } +} From 156de6112bb88d62cf41054b738a764ed024c94a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 16 Oct 2025 10:01:14 +0700 Subject: [PATCH 044/212] feat(FE-91): create Tooltip component --- src/components/Tooltip.tsx | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/components/Tooltip.tsx diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 00000000..02f86dca --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,60 @@ +import { ReactNode } from 'react'; + +import { cn } from '@/lib/helper'; +import { Color } from '@/types/theme'; + +interface TooltipProps { + children?: ReactNode; + content?: ReactNode; + className?: { + wrapper?: string; + content?: string; + }; + open?: boolean; + color?: Color; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +const Tooltip = ({ + children, + content, + className, + open, + color, + position, +}: TooltipProps) => { + const tooltipBaseClassName = cn('tooltip', { + 'tooltip-open': typeof open === 'boolean' && open, + + 'tooltip-top': position === 'top', + 'tooltip-bottom': position === 'bottom', + 'tooltip-left': position === 'left', + 'tooltip-right': position === 'right', + + 'tooltip-primary': color === 'primary', + 'tooltip-secondary': color === 'secondary', + 'tooltip-accent': color === 'accent', + 'tooltip-neutral': color === 'neutral', + 'tooltip-info': color === 'info', + 'tooltip-success': color === 'success', + 'tooltip-warning': color === 'warning', + 'tooltip-error': color === 'error', + }); + return ( +
+
+ {content} +
+ + {children} +
+ ); +}; + +export default Tooltip; From 76dd2e4c54a929103625a20dcc76ceb76d0263ea Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 16 Oct 2025 10:01:23 +0700 Subject: [PATCH 045/212] feat(FE-91): create Steps component --- src/components/steps/Steps.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/components/steps/Steps.tsx diff --git a/src/components/steps/Steps.tsx b/src/components/steps/Steps.tsx new file mode 100644 index 00000000..29d307e1 --- /dev/null +++ b/src/components/steps/Steps.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from 'react'; +import { cn } from '@/lib/helper'; + +interface StepsProps { + children?: ReactNode; + className?: string; + direction?: 'horizontal' | 'vertical'; +} + +const Steps = ({ children, className, direction }: StepsProps) => { + const stepsBaseClassName = cn('steps gap-2', { + 'steps-horizontal': direction === 'horizontal', + 'steps-vertical': direction === 'vertical', + }); + + return ( +
    + {children} +
+ ); +}; + +export default Steps; From 0577f6ce1d6875d33af23a691f0d61bf122fef2f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 16 Oct 2025 10:01:29 +0700 Subject: [PATCH 046/212] feat(FE-91): create StepItem component --- src/components/steps/StepItem.tsx | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/components/steps/StepItem.tsx diff --git a/src/components/steps/StepItem.tsx b/src/components/steps/StepItem.tsx new file mode 100644 index 00000000..85ec4f3e --- /dev/null +++ b/src/components/steps/StepItem.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from 'react'; + +import { cn } from '@/lib/helper'; +import { Color } from '@/types/theme'; + +interface StepItemProps { + children?: ReactNode; + icon?: ReactNode; + className?: string; + color?: Color; +} + +const StepItem = ({ children, icon, className, color }: StepItemProps) => { + const stepItemBaseClassName = cn('step', { + 'step-primary': color === 'primary', + 'step-secondary': color === 'secondary', + 'step-accent': color === 'accent', + 'step-neutral': color === 'neutral', + 'step-info': color === 'info', + 'step-success': color === 'success', + 'step-warning': color === 'warning', + 'step-error': color === 'error', + }); + + return ( +
  • + {icon} + +
    {children}
    +
  • + ); +}; + +export default StepItem; From 93beb86f910fe0bcadecb3c4ded5498893c79af1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 16 Oct 2025 10:01:40 +0700 Subject: [PATCH 047/212] feat(FE-91): create ApprovalSteps component --- src/components/pages/ApprovalSteps.tsx | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/components/pages/ApprovalSteps.tsx diff --git a/src/components/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx new file mode 100644 index 00000000..4022e254 --- /dev/null +++ b/src/components/pages/ApprovalSteps.tsx @@ -0,0 +1,64 @@ +import { Icon } from '@iconify/react'; +import Steps from '@/components/steps/Steps'; +import StepItem from '@/components/steps/StepItem'; +import Tooltip from '@/components/Tooltip'; + +import { formatDate } from '@/lib/helper'; +import { ApprovalsLine } from '@/types/api/api-general'; + +interface ApprovalStepsProps { + approvals: ApprovalsLine; +} + +const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => { + return ( + + {approvals.map((approval, idx) => { + const stepItemColor = + approval.status === 'approved' + ? 'success' + : approval.status === 'rejected' + ? 'error' + : undefined; + + const stepItemIcon = + approval.status === 'approved' + ? 'material-symbols:check-rounded' + : approval.status === 'rejected' + ? 'material-symbols:close-rounded' + : 'bxs:hourglass'; + + return ( + + {formatDate(approval.date, 'YYYY-MM-DD')} + Oleh: {approval.action_by} + Catatan: {approval.notes} +
    + } + > + + + ) + } + > + {approval.role} + + ); + })} + + ); +}; + +export default ApprovalSteps; From b7a30cc73adb2f9c874f8d5539cbf5cfe8d8e8b8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 16 Oct 2025 10:01:50 +0700 Subject: [PATCH 048/212] chore(FE-91): create ApprovalsLine type --- src/types/api/api-general.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index 6a3fc6be..cf3e57f7 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -66,3 +66,11 @@ export type flags = | 'STARTER' | 'FINISHER' | 'OVK'; + +export type ApprovalsLine = { + action_by?: string; + date?: string; + notes?: string; + role?: string; + status: 'approved' | 'rejected' | 'waiting'; +}[]; From 23d5a41d56fdb973c169e97f74f846f2fd387f03 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 10:08:49 +0700 Subject: [PATCH 049/212] refactor(FE-114): improve recording date handling in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index a133e437..8d145ca8 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -74,7 +74,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { recording_date: values.recording_date instanceof Date ? values.recording_date.toISOString() - : new Date().toISOString(), + : '', feed_data: (values.feed_data ?? []).map((p) => ({ feed_name: p.feed_name, feed_qty: p.feed_qty, @@ -119,11 +119,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockOptions = DUMMY_FLOCKS; - // Location and Coop state/handlers const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); - // Location fetch const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, @@ -133,14 +131,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ? locations?.data.map((loc) => ({ value: loc.id, label: loc.name })) : []; - // Coop fetch const coopsUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: coopSelectInputValue ?? '' }).toString()}`; const { data: coops, isLoading: isLoadingCoops } = useSWR( coopsUrl, KandangApi.getAllFetcher ); - // Filter coop options based on selected location const coopOptions = useMemo(() => { if (!isResponseSuccess(coops) || !formik.values.location_id) return []; return coops.data @@ -148,14 +144,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { .map((coop) => ({ value: coop.id, label: coop.name })); }, [coops, formik.values.location_id]); - // Handlers const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; formik.setFieldValue('location', val); formik.setFieldValue('location_id', locationValue || 0); - // Only set touched if there's a value if (locationValue) { formik.setFieldTouched('location', true); formik.setFieldTouched('location_id', true); @@ -164,7 +158,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('location_id', false); } - // Reset coop selection when location changes or is cleared formik.setFieldValue('coop', null); formik.setFieldValue('coop_id', 0); formik.setFieldTouched('coop', false); @@ -178,7 +171,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('coop', val); formik.setFieldValue('coop_id', coopValue || 0); - // Only set touched if there's a value if (coopValue) { formik.setFieldTouched('coop', true); formik.setFieldTouched('coop_id', true); @@ -229,6 +221,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; + const flockChangeHandler = (val: OptionType | OptionType[] | null) => { + const flockValue = (val as OptionType)?.value; + + formik.setFieldValue('flock', val); + formik.setFieldValue('flock_id', flockValue || 0); + + if (flockValue) { + formik.setFieldTouched('flock', true); + formik.setFieldTouched('flock_id', true); + } else { + formik.setFieldTouched('flock', false); + formik.setFieldTouched('flock_id', false); + } + }; + const addFeedData = () => { const newFeedData = [ ...(formik.values.feed_data || []), @@ -382,17 +389,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { required label='Flock' value={formik.values.flock ?? undefined} - onChange={(val) => { - formik.setFieldValue('flock', val); - formik.setFieldValue( - 'flock_id', - (val as OptionType)?.value - ); - }} + onChange={flockChangeHandler} options={flockOptions} - onInputChange={(val) => { - return val; - }} + onInputChange={setFlockSelectInputValue} isLoading={false} isError={ formik.touched.flock_id && Boolean(formik.errors.flock_id) @@ -405,7 +404,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { required label='Tanggal Recording' type='date' - name='tanggal_recording' + name='recording_date' value={ formik.values.recording_date instanceof Date ? formik.values.recording_date @@ -416,8 +415,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { onChange={(e) => { const date = e.target.value ? new Date(e.target.value) - : new Date(); - formik.setFieldValue('tanggal_recording', date); + : null; + formik.setFieldValue('recording_date', date); }} onBlur={formik.handleBlur} isError={ @@ -1129,6 +1128,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} + isClearable />
    From 19db9a4eacd5e37aeacea6f34d171429b21293fc Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 10:54:36 +0700 Subject: [PATCH 050/212] refactor(FE-114,136): enhance validation and default values in RecordingForm schema --- .../recording/form/RecordingForm.schema.ts | 42 +++++++++++++++---- .../flock/recording/form/RecordingForm.tsx | 42 ++++--------------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index 394b08b0..319482e7 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -7,17 +7,41 @@ export const RecordingFormSchema = Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - flock_id: Yup.number().required('Flock wajib diisi!'), + flock_id: Yup.number() + .default(0) + .typeError('Flock wajib diisi!') + .test( + 'is-valid-flock', + 'Flock wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Flock wajib diisi!'), location: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - location_id: Yup.number().required('Lokasi wajib diisi!'), + location_id: Yup.number() + .default(0) + .typeError('Lokasi wajib diisi!') + .test( + 'is-valid-location', + 'Lokasi wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Lokasi wajib diisi!'), coop: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - coop_id: Yup.number().required('Kandang wajib diisi!'), + coop_id: Yup.number() + .default(0) + .typeError('Kandang wajib diisi!') + .test( + 'is-valid-coop', + 'Kandang wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Kandang wajib diisi!'), recording_date: Yup.date() .required('Tanggal recording wajib diisi') .typeError('Format tanggal tidak valid'), @@ -27,10 +51,12 @@ export const RecordingFormSchema = Yup.object({ feed_name: Yup.string().required('Nama pakan wajib diisi!'), feed_qty: Yup.number() .required('Qty pakan wajib diisi!') - .min(1, 'Qty minimal 1!'), + .min(1, 'Qty minimal 1!') + .typeError('Qty pakan wajib diisi!'), feed_stock: Yup.number() .required('Stock pakan wajib diisi!') - .min(0, 'Stock minimal 0!'), + .min(1, 'Stock minimal 1!') + .typeError('Stock pakan wajib diisi!'), }) ) .min(1, 'Minimal harus ada 1 data pakan!') @@ -57,10 +83,12 @@ export const RecordingFormSchema = Yup.object({ vaccine_name: Yup.string().required('Nama vaksin wajib diisi!'), total_stock: Yup.number() .required('Total stock wajib diisi!') - .min(0, 'Total stock minimal 0!'), + .min(1, 'Total stock minimal 1!') + .typeError('Total stock wajib diisi!'), used_stock: Yup.number() .required('Jumlah stock wajib diisi!') - .min(0, 'Jumlah stock minimal 0!'), + .min(1, 'Jumlah stock minimal 1!') + .typeError('Jumlah stock wajib diisi!'), }) ) .min(1, 'Minimal harus ada 1 data vaksinasi!') diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 8d145ca8..228683fa 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -147,37 +147,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; - formik.setFieldValue('location', val); - formik.setFieldValue('location_id', locationValue || 0); + formik.setFieldValue('location', val, false); + formik.setFieldValue('location_id', locationValue || 0, false); - if (locationValue) { - formik.setFieldTouched('location', true); - formik.setFieldTouched('location_id', true); - } else { - formik.setFieldTouched('location', false); - formik.setFieldTouched('location_id', false); - } - - formik.setFieldValue('coop', null); - formik.setFieldValue('coop_id', 0); - formik.setFieldTouched('coop', false); - formik.setFieldTouched('coop_id', false); + formik.setFieldValue('coop', null, false); + formik.setFieldValue('coop_id', 0, false); setCoopSelectInputValue(''); }; const coopChangeHandler = (val: OptionType | OptionType[] | null) => { const coopValue = (val as OptionType)?.value; - formik.setFieldValue('coop', val); - formik.setFieldValue('coop_id', coopValue || 0); - - if (coopValue) { - formik.setFieldTouched('coop', true); - formik.setFieldTouched('coop_id', true); - } else { - formik.setFieldTouched('coop', false); - formik.setFieldTouched('coop_id', false); - } + formik.setFieldValue('coop', val, false); + formik.setFieldValue('coop_id', coopValue || 0, false); }; const isRepeaterInputError = ( @@ -224,16 +206,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockChangeHandler = (val: OptionType | OptionType[] | null) => { const flockValue = (val as OptionType)?.value; - formik.setFieldValue('flock', val); - formik.setFieldValue('flock_id', flockValue || 0); - - if (flockValue) { - formik.setFieldTouched('flock', true); - formik.setFieldTouched('flock_id', true); - } else { - formik.setFieldTouched('flock', false); - formik.setFieldTouched('flock_id', false); - } + formik.setFieldValue('flock', val, false); + formik.setFieldValue('flock_id', flockValue || 0, false); }; const addFeedData = () => { From c6a0c542aad1dcc2c4dc8c5361bfa95a6d9b01cd Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 14:33:49 +0700 Subject: [PATCH 051/212] refactor(FE-62,63,65): refactor Movement and ProductWarehouse APIs, update MovementForm schema, and enhance MovementTable functionality --- .../inventory/movement/MovementTable.tsx | 622 ++++-------------- .../movement/form/MovementForm.schema.ts | 53 +- .../movement/form/useMovementFormHandlers.ts | 80 ++- src/components/table/TableRowOptions.tsx | 11 +- src/services/api/inventory.ts | 13 +- src/types/api/inventory/movement.d.ts | 32 +- .../api/inventory/product-warehouse.d.ts | 22 + 7 files changed, 277 insertions(+), 556 deletions(-) create mode 100644 src/types/api/inventory/product-warehouse.d.ts diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index a00f7111..e0bc9541 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -1,463 +1,56 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState } from 'react'; +import useSWR from 'swr'; import { SortingState } from '@tanstack/react-table'; -import { Icon } from '@iconify/react'; + import Table from '@/components/Table'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import { ROWS_OPTIONS } from '@/config/constant'; import { Movement } from '@/types/api/inventory/movement'; +import { MovementApi } from '@/services/api/inventory'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; import { TableToolbar } from '@/components/table/TableToolbar'; import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; -import { TableRowOptions } from '@/components/table/TableRowOptions'; import { OptionType } from '@/components/input/SelectInput'; -import Button from '@/components/Button'; -import { cn } from '@/lib/helper'; - -// Dummy data -const baseMetadata = { - created_user: { - id: 1, - id_user: 1, - email: 'user@example.com', - name: 'User', - }, - created_at: '2024-06-01T00:00:00Z', - updated_at: '2024-06-01T00:00:00Z', -}; - -const dummyMovements: Movement[] = [ - { - ...baseMetadata, - id: 1, - transfer_reason: 'Restock', - transfer_date: '2024-06-01', - source_warehouse: { - ...baseMetadata, - id: 1, - name: 'Warehouse A', - type: 'AREA', - area: { id: 1, name: 'Area 1' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 2, - name: 'Warehouse B', - type: 'AREA', - area: { id: 2, name: 'Area 2' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 1, - name: 'Product X', - brand: 'Brand X', - sku: 'SKU-X', - product_price: 10000, - selling_price: 12000, - tax: 10, - expiry_period: 365, - uom: { - ...baseMetadata, - id: 1, - name: 'PCS', - }, - product_category: { - ...baseMetadata, - id: 1, - code: 'CAT-1', - name: 'Category 1', - }, - suppliers: [], - flags: [], - }, - product_qty: 10, - }, - ], - deliveries: [ - { - delivery_cost: 50000, - delivery_cost_per_item: 5000, - document: 'doc1.pdf', - driver_name: 'Andi', - vehicle_plate: 'B 1234 CD', - supplier: { - ...baseMetadata, - id: 1, - name: 'Supplier 1', - alias: 'S1', - category: 'General', - pic: 'PIC 1', - type: 'Type 1', - hatchery: 'Hatchery 1', - phone: '08123456789', - email: 'supplier1@example.com', - address: 'Address 1', - npwp: '1234567890123456', - account_number: '1234567890', - balance: 0, - due_date: 30, - }, - products: [ - { - product: { - ...baseMetadata, - id: 1, - name: 'Product X', - brand: 'Brand X', - sku: 'SKU-X', - product_price: 10000, - selling_price: 12000, - tax: 10, - expiry_period: 365, - uom: { - ...baseMetadata, - id: 1, - name: 'PCS', - }, - product_category: { - ...baseMetadata, - id: 1, - code: 'CAT-1', - name: 'Category 1', - }, - suppliers: [], - flags: [], - }, - product_qty: 10, - }, - ], - }, - ], - }, - { - ...baseMetadata, - id: 2, - transfer_reason: 'Mutasi Stok', - transfer_date: '2024-06-02', - source_warehouse: { - ...baseMetadata, - id: 2, - name: 'Warehouse B', - type: 'AREA', - area: { id: 2, name: 'Area 2' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 3, - name: 'Warehouse C', - type: 'AREA', - area: { id: 3, name: 'Area 3' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 2, - name: 'Product Y', - brand: 'Brand Y', - sku: 'SKU-Y', - product_price: 20000, - selling_price: 25000, - tax: 5, - expiry_period: 180, - uom: { - ...baseMetadata, - id: 2, - name: 'BOX', - }, - product_category: { - ...baseMetadata, - id: 2, - code: 'CAT-2', - name: 'Category 2', - }, - suppliers: [], - flags: [], - }, - product_qty: 5, - }, - ], - deliveries: [ - { - delivery_cost: 60000, - delivery_cost_per_item: 12000, - document: 'doc2.pdf', - driver_name: 'Budi', - vehicle_plate: 'D 5678 EF', - supplier: { - ...baseMetadata, - id: 2, - name: 'Supplier 2', - alias: 'S2', - category: 'Special', - pic: 'PIC 2', - type: 'Type 2', - hatchery: 'Hatchery 2', - phone: '08123456780', - email: 'supplier2@example.com', - address: 'Address 2', - npwp: '1234567890123457', - account_number: '1234567891', - balance: 1000, - due_date: 15, - }, - products: [ - { - product: { - ...baseMetadata, - id: 2, - name: 'Product Y', - brand: 'Brand Y', - sku: 'SKU-Y', - product_price: 20000, - selling_price: 25000, - tax: 5, - expiry_period: 180, - uom: { - ...baseMetadata, - id: 2, - name: 'BOX', - }, - product_category: { - ...baseMetadata, - id: 2, - code: 'CAT-2', - name: 'Category 2', - }, - suppliers: [], - flags: [], - }, - product_qty: 5, - }, - ], - }, - ], - }, - { - ...baseMetadata, - id: 3, - transfer_reason: 'Pengembalian', - transfer_date: '2024-06-03', - source_warehouse: { - ...baseMetadata, - id: 3, - name: 'Warehouse C', - type: 'AREA', - area: { id: 3, name: 'Area 3' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 1, - name: 'Warehouse A', - type: 'AREA', - area: { id: 1, name: 'Area 1' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 3, - name: 'Product Z', - brand: 'Brand Z', - sku: 'SKU-Z', - product_price: 15000, - selling_price: 18000, - tax: 8, - expiry_period: 90, - uom: { - ...baseMetadata, - id: 3, - name: 'KG', - }, - product_category: { - ...baseMetadata, - id: 3, - code: 'CAT-3', - name: 'Category 3', - }, - suppliers: [], - flags: [], - }, - product_qty: 8, - }, - ], - deliveries: [ - { - delivery_cost: 40000, - delivery_cost_per_item: 5000, - document: 'doc3.pdf', - driver_name: 'Cici', - vehicle_plate: 'F 9101 GH', - supplier: { - ...baseMetadata, - id: 3, - name: 'Supplier 3', - alias: 'S3', - category: 'Return', - pic: 'PIC 3', - type: 'Type 3', - hatchery: 'Hatchery 3', - phone: '08123456781', - email: 'supplier3@example.com', - address: 'Address 3', - npwp: '1234567890123458', - account_number: '1234567892', - balance: 500, - due_date: 10, - }, - products: [ - { - product: { - ...baseMetadata, - id: 3, - name: 'Product Z', - brand: 'Brand Z', - sku: 'SKU-Z', - product_price: 15000, - selling_price: 18000, - tax: 8, - expiry_period: 90, - uom: { - ...baseMetadata, - id: 3, - name: 'KG', - }, - product_category: { - ...baseMetadata, - id: 3, - code: 'CAT-3', - name: 'Category 3', - }, - suppliers: [], - flags: [], - }, - product_qty: 8, - }, - ], - }, - ], - }, - { - ...baseMetadata, - id: 4, - transfer_reason: 'Transfer Internal', - transfer_date: '2024-06-04', - source_warehouse: { - ...baseMetadata, - id: 4, - name: 'Warehouse D', - type: 'AREA', - area: { id: 4, name: 'Area 4' }, - }, - destination_warehouse: { - ...baseMetadata, - id: 5, - name: 'Warehouse E', - type: 'AREA', - area: { id: 5, name: 'Area 5' }, - }, - products: [ - { - product: { - ...baseMetadata, - id: 4, - name: 'Product A', - brand: 'Brand A', - sku: 'SKU-A', - product_price: 5000, - selling_price: 7000, - tax: 0, - expiry_period: 60, - uom: { - ...baseMetadata, - id: 4, - name: 'LITER', - }, - product_category: { - ...baseMetadata, - id: 4, - code: 'CAT-4', - name: 'Category 4', - }, - suppliers: [], - flags: [], - }, - product_qty: 20, - }, - ], - deliveries: [ - { - delivery_cost: 30000, - delivery_cost_per_item: 1500, - document: 'doc4.pdf', - driver_name: 'Dedi', - vehicle_plate: 'H 2345 IJ', - supplier: { - ...baseMetadata, - id: 4, - name: 'Supplier 4', - alias: 'S4', - category: 'Internal', - pic: 'PIC 4', - type: 'Type 4', - hatchery: 'Hatchery 4', - phone: '08123456782', - email: 'supplier4@example.com', - address: 'Address 4', - npwp: '1234567890123459', - account_number: '1234567893', - balance: 200, - due_date: 20, - }, - products: [ - { - product: { - ...baseMetadata, - id: 4, - name: 'Product A', - brand: 'Brand A', - sku: 'SKU-A', - product_price: 5000, - selling_price: 7000, - tax: 0, - expiry_period: 60, - uom: { - ...baseMetadata, - id: 4, - name: 'LITER', - }, - product_category: { - ...baseMetadata, - id: 4, - code: 'CAT-4', - name: 'Category 4', - }, - suppliers: [], - flags: [], - }, - product_qty: 20, - }, - ], - }, - ], - }, -]; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import { TableRowOptions } from '@/components/table/TableRowOptions'; const MovementTable = () => { - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '' }, + paramMap: { page: 'page', pageSize: 'limit' }, + }); + const [sorting, setSorting] = useState([]); - const [, setSelectedMovement] = useState(undefined); + const [selectedMovement, setSelectedMovement] = useState< + Movement | undefined + >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const deleteModal = useModal(); + const { + data: movements, + isLoading, + mutate: refreshMovements, + } = useSWR( + `${MovementApi.basePath}${getTableFilterQueryString()}`, + MovementApi.getAllFetcher + ); + const searchChangeHandler = (e: React.ChangeEvent) => { - setSearch(e.target.value); + updateFilter('search', e.target.value); setPage(1); }; @@ -469,17 +62,15 @@ const MovementTable = () => { const confirmationModalDeleteClickHandler = async () => { setIsDeleteLoading(true); - setTimeout(() => { - setIsDeleteLoading(false); + try { + await MovementApi.delete(selectedMovement?.id as number); + refreshMovements(); deleteModal.closeModal(); - }, 1000); + } finally { + setIsDeleteLoading(false); + } }; - const paginatedData = useMemo(() => { - const start = (page - 1) * pageSize; - return dummyMovements.slice(start, start + pageSize); - }, [page, pageSize]); - return (
    @@ -489,85 +80,118 @@ const MovementTable = () => { label: 'Tambah Movement', }} search={{ - value: search, + value: tableFilterState.search, onChange: searchChangeHandler, placeholder: 'Cari Movement', }} />
    - + data={isResponseSuccess(movements) ? movements?.data : []} columns={[ { header: '#', - cell: (props) => pageSize * (page - 1) + props.row.index + 1, - }, - { - accessorKey: 'source_warehouse', - header: 'Gudang Asal', - cell: (props) => props.row.original.source_warehouse.name, - }, - { - accessorKey: 'destination_warehouse', - header: 'Gudang Tujuan', - cell: (props) => props.row.original.destination_warehouse.name, - }, - { - accessorKey: 'products', - header: 'Nama Produk', cell: (props) => - props.row.original.products - .map((p) => p.product.name) - .join(', '), + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: (row) => row.source_warehouse?.name, + header: 'Gudang Asal', + }, + { + accessorFn: (row) => row.destination_warehouse?.name, + header: 'Gudang Tujuan', }, { accessorKey: 'transfer_reason', header: 'Catatan', }, { - accessorKey: 'delivery_cost', - header: 'Biaya Pengiriman', + accessorKey: 'transfer_date', + header: 'Tanggal', cell: (props) => - props.row.original.deliveries - .reduce((sum, d) => sum + d.delivery_cost, 0) - .toLocaleString('id-ID'), + new Date(props.row.original.transfer_date).toLocaleDateString( + 'id-ID' + ), }, { - id: 'actions', - cell: (props) => ( -
    - - { - setSelectedMovement(props.row.original); - deleteModal.openModal(); - }} - /> -
    - ), + accessorFn: (row) => { + const totalCost = row.deliveries?.reduce( + (sum, d) => sum + (d.shipping_cost_total || 0), + 0 + ); + return totalCost?.toLocaleString('id-ID'); + }, + header: 'Biaya Pengiriman', + }, + { + 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 = () => { + setSelectedMovement(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, }, ]} - pageSize={pageSize} - page={page} - totalItems={dummyMovements.length} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(movements) ? movements?.meta?.page : 0} + totalItems={ + isResponseSuccess(movements) ? movements?.meta?.total_results : 0 + } onPageChange={setPage} - isLoading={false} + isLoading={isLoading} sorting={sorting} setSorting={setSorting} className={{ containerClassName: cn({ - 'mb-20': paginatedData.length === 0, + 'mb-20': + isResponseSuccess(movements) && movements?.data?.length === 0, }), tableWrapperClassName: 'overflow-x-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!', diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index cb0d228d..148a7dce 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -13,7 +13,7 @@ export type ProductSchema = { export type DeliverySchema = { delivery_cost: number; delivery_cost_per_item?: number | undefined; - document: string | File; + document?: File | string | null; driver_name: string; vehicle_plate: string; supplier: { @@ -64,23 +64,15 @@ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ .transform((value) => (isNaN(value) ? undefined : value)) .min(0, 'Biaya per item minimal 0!') .typeError('Biaya per item harus berupa angka!'), - document: Yup.mixed() - .required('Dokumen wajib diisi!') - .test( - 'fileType', - 'Mohon upload file berformat PDF atau JPEG/JPG.', - (value) => - typeof value === 'string' || - (value instanceof File && - ['application/pdf', 'image/jpeg', 'image/jpg'].includes(value.type)) - ) - .test( - 'fileSize', - 'Ukuran dokumen maksimal 2 MB!', - (value) => - typeof value === 'string' || - (value instanceof File && value.size <= 2 * 1024 * 1024) - ), + document_index: Yup.number().optional(), + document: Yup.mixed() + .nullable() + .test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => { + if (!value) return true; + if (typeof value === 'string') return true; + if (value instanceof File) return value.size <= 2 * 1024 * 1024; + return false; + }), driver_name: Yup.string().required('Nama sopir wajib diisi!'), vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), supplier: Yup.object({ @@ -145,24 +137,25 @@ export const getMovementFormInitialValues = ( : null, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, products: - initialValues?.products?.map((p) => ({ - product: { value: p.product.id, label: p.product.name }, - product_id: p.product.id, - product_qty: p.product_qty, + initialValues?.details?.map((p) => ({ + product: { value: p.product_id, label: '' }, + product_id: p.product_id, + product_qty: p.quantity, })) ?? [], deliveries: initialValues?.deliveries?.map((d) => ({ - delivery_cost: d.delivery_cost, - delivery_cost_per_item: d.delivery_cost_per_item, - document: d.document, + delivery_cost: d.shipping_cost_total, + delivery_cost_per_item: d.shipping_cost_item, + document_index: 0, + document: d.document_path || null, driver_name: d.driver_name, vehicle_plate: d.vehicle_plate, supplier: { value: d.supplier.id, label: d.supplier.name }, - supplier_id: d.supplier.id, - products: d.products.map((p) => ({ - product: { value: p.product.id, label: p.product.name }, - product_id: p.product.id, - product_qty: p.product_qty, + supplier_id: d.supplier_id, + products: d.items.map((p) => ({ + product: { value: 0, label: '' }, + product_id: 0, + product_qty: p.quantity, })), })) ?? [], }); diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index 0b6b0962..1894b1a7 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -8,7 +8,6 @@ import { UpdateMovementPayload, } from '@/types/api/inventory/movement'; import { isResponseError } from '@/lib/api-helper'; -import { containsFile, toFormData } from '@/lib/form-data'; export const useMovementFormHandlers = (initialValuesId?: number) => { const router = useRouter(); @@ -17,13 +16,44 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const createMovementHandler = useCallback( - async (payload: CreateMovementPayload) => { - const finalPayload = containsFile(payload) - ? (toFormData(payload) as unknown as CreateMovementPayload) - : payload; + async (payload: CreateMovementPayload, documents: File[] = []) => { + console.log('=== CREATE HANDLER DEBUG ==='); + console.log('1. Received payload:', payload); + console.log('2. Documents count:', documents.length); + + let finalPayload: CreateMovementPayload | FormData; + + if (documents.length > 0) { + // Ada dokumen: kirim sebagai FormData dengan "data" field + console.log('3. Creating FormData (has documents)'); + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + console.log('4. FormData entries:'); + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + } else { + console.log(` ${key}: ${value}`); + } + } + + finalPayload = formData as unknown as CreateMovementPayload; + } else { + // Tidak ada dokumen: kirim sebagai JSON biasa + console.log('3. Sending as JSON (no documents)'); + console.log('4. Payload:', JSON.stringify(payload, null, 2)); + finalPayload = payload; + } + + console.log('=== END CREATE HANDLER DEBUG ==='); const res = await MovementApi.create(finalPayload); if (isResponseError(res)) { + console.error('API Error:', res); setMovementFormErrorMessage(res.message); return; } @@ -34,13 +64,45 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { ); const updateMovementHandler = useCallback( - async (movementId: number, payload: UpdateMovementPayload) => { - const finalPayload = containsFile(payload) - ? (toFormData(payload) as unknown as UpdateMovementPayload) - : payload; + async (movementId: number, payload: UpdateMovementPayload, documents: File[] = []) => { + console.log('=== UPDATE HANDLER DEBUG ==='); + console.log('1. Received payload:', payload); + console.log('2. Movement ID:', movementId); + console.log('3. Documents count:', documents.length); + + let finalPayload: UpdateMovementPayload | FormData; + + if (documents.length > 0) { + // Ada dokumen: kirim sebagai FormData dengan "data" field + console.log('4. Creating FormData (has documents)'); + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + console.log('5. FormData entries:'); + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + } else { + console.log(` ${key}: ${value}`); + } + } + + finalPayload = formData as unknown as UpdateMovementPayload; + } else { + // Tidak ada dokumen: kirim sebagai JSON biasa + console.log('4. Sending as JSON (no documents)'); + console.log('5. Payload:', JSON.stringify(payload, null, 2)); + finalPayload = payload; + } + + console.log('=== END UPDATE HANDLER DEBUG ==='); const res = await MovementApi.update(movementId, finalPayload); if (res?.status === 'error') { + console.error('API Error:', res); setMovementFormErrorMessage(res.message); return; } diff --git a/src/components/table/TableRowOptions.tsx b/src/components/table/TableRowOptions.tsx index 61332b4f..e34f2ad4 100644 --- a/src/components/table/TableRowOptions.tsx +++ b/src/components/table/TableRowOptions.tsx @@ -7,6 +7,7 @@ interface TableRowOptionsProps { recordId: string | number; basePath: string; onDelete?: () => void; + queryParam?: string; } export const TableRowOptions = ({ @@ -14,6 +15,7 @@ export const TableRowOptions = ({ recordId, basePath, onDelete, + queryParam = 'id', }: TableRowOptionsProps) => (
    {onDelete && ( @@ -51,9 +53,10 @@ export const TableRowOptions = ({ className='text-error hover:text-inherit justify-start text-sm' > Delete diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts index cf799442..ec58f6f2 100644 --- a/src/services/api/inventory.ts +++ b/src/services/api/inventory.ts @@ -1,4 +1,9 @@ import { BaseApiService } from '@/services/api/base'; +import { + CreateProductWarehousePayload, + ProductWarehouse, + UpdateProductWarehousePayload, +} from '@/types/api/inventory/product-warehouse'; import { CreateMovementPayload, Movement, @@ -9,11 +14,17 @@ import { InventoryAdjustment, } from '@/types/api/inventory/adjustment'; +export const ProductWarehouseApi = new BaseApiService< + ProductWarehouse, + CreateProductWarehousePayload, + UpdateProductWarehousePayload +>('/inventory/product-warehouses'); + export const MovementApi = new BaseApiService< Movement, CreateMovementPayload, UpdateMovementPayload ->('/inventory/movements'); +>('/inventory/transfers'); export const inventoryAdjustmentApi = new BaseApiService< InventoryAdjustment, diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index 11da41a5..9e156a1e 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -1,5 +1,4 @@ import { BaseMetadata } from '@/types/api/api-general'; -import { Product } from '@/types/api/master-data/product'; import { Supplier } from '@/types/api/master-data/supplier'; import { Warehouse } from '@/types/api/master-data/warehouse'; @@ -9,20 +8,27 @@ export type BaseMovement = { transfer_date: string; source_warehouse: Warehouse; destination_warehouse: Warehouse; - products: { - product: Product; - product_qty: number; + details: { + id: number; + product_id: number; + quantity: number; + before_quantity: number; + after_quantity: number; }[]; deliveries: { - delivery_cost: number; - delivery_cost_per_item: number; - document: string; - driver_name: string; - vehicle_plate: string; + id: number; + supplier_id: number; supplier: Supplier; - products: { - product: Product; - product_qty: number; + vehicle_plate: string; + driver_name: string; + document_number: string; + document_path: string; + shipping_cost_item: number; + shipping_cost_total: number; + items: { + id: number; + stock_transfer_detail_id: number; + quantity: number; }[]; }[]; }; @@ -41,7 +47,7 @@ export type CreateMovementPayload = { deliveries: { delivery_cost: number; delivery_cost_per_item: number; - document: string | File; + document_index?: number; driver_name: string; vehicle_plate: string; supplier_id: number; diff --git a/src/types/api/inventory/product-warehouse.d.ts b/src/types/api/inventory/product-warehouse.d.ts new file mode 100644 index 00000000..eda8d1b8 --- /dev/null +++ b/src/types/api/inventory/product-warehouse.d.ts @@ -0,0 +1,22 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { Product } from '@/types/api/master-data/product'; + +export type BaseProductWarehouse = { + id: number; + product_id: number; + warehouse_id: number; + quantity: number; + product: Product; + warehouse: Warehouse; +}; + +export type ProductWarehouse = BaseMetadata & BaseProductWarehouse; + +export type CreateProductWarehousePayload = { + product_id: number; + warehouse_id: number; + quantity: number; +}; + +export type UpdateProductWarehousePayload = CreateProductWarehousePayload; From f5ce898bd2f7ee07e0f12573d7f0fa514cead015 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 15:29:26 +0700 Subject: [PATCH 052/212] feat(FE-62,63,65): enhance MovementForm with product warehouse selection, delivery document handling, and stock validation --- .../inventory/movement/form/MovementForm.tsx | 239 ++++++++++++++---- .../movement/form/useMovementFormHandlers.ts | 18 +- 2 files changed, 197 insertions(+), 60 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 1af77cf7..898a1d56 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,11 +25,8 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { - ProductApi, - SupplierApi, - WarehouseApi, -} from '@/services/api/master-data'; +import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -40,6 +37,10 @@ interface MovementFormProps { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const [, setMovementFormErrorMessage] = useState(''); + const [ + productWarehouseSelectInputValue, + setProductWarehouseSelectInputValue, + ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); @@ -67,7 +68,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { validateOnMount: false, enableReinitialize: true, onSubmit: async (values) => { + console.log('=== FORM SUBMIT DEBUG ==='); + console.log('1. Form values received:', values); + setMovementFormErrorMessage(''); + const documents: File[] = []; + const deliveriesPayload = values.deliveries.map((d, idx) => { + let documentIndex = 0; + + console.log(`2. Processing delivery ${idx}:`, { + driver_name: d.driver_name, + document: d.document, + documentType: d.document instanceof File ? 'File' : typeof d.document, + documentSize: d.document instanceof File ? d.document.size : 'N/A', + }); + + if (d.document && d.document instanceof File) { + documents.push(d.document); + documentIndex = documents.length - 1; + console.log(` → Document added at index ${documentIndex}`); + } else { + console.log(` → No document for delivery ${idx}, using index 0`); + } + + return { + delivery_cost: d.delivery_cost, + delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + document_index: documentIndex, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier_id: d.supplier_id, + products: d.products.map((p) => ({ + product_id: p.product_id, + product_qty: p.product_qty, + })), + }; + }); + const payload: CreateMovementPayload = { transfer_reason: values.transfer_reason, transfer_date: values.transfer_date, @@ -77,26 +114,34 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { product_id: p.product_id, product_qty: p.product_qty, })), - deliveries: values.deliveries.map((d) => ({ - delivery_cost: d.delivery_cost, - delivery_cost_per_item: d.delivery_cost_per_item ?? 0, - document: d.document instanceof File ? d.document : d.document, - driver_name: d.driver_name, - vehicle_plate: d.vehicle_plate, - supplier_id: d.supplier_id, - products: d.products.map((p) => ({ - product_id: p.product_id, - product_qty: p.product_qty, - })), - })), + deliveries: deliveriesPayload, }; + console.log('3. Final payload structure:', { + ...payload, + }); + + console.log( + '4. Document indices in deliveries:', + deliveriesPayload.map((d, i) => ({ + delivery: i, + document_index: d.document_index, + })) + ); + + console.log('5. Total documents:', documents.length); + console.log('=== END SUBMIT DEBUG ==='); + switch (type) { case 'add': - await createMovementHandler(payload); + await createMovementHandler(payload, documents); break; case 'edit': - await updateMovementHandler(initialValues?.id as number, payload); + await updateMovementHandler( + initialValues?.id as number, + payload, + documents + ); break; } }, @@ -144,7 +189,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { delivery_cost: 0, delivery_cost_per_item: 0, - document: '', + document: null, driver_name: '', vehicle_plate: '', supplier: null, @@ -265,15 +310,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { })) : []; - // Product selection - const [productSelectInputValue, setProductSelectInputValue] = useState(''); - const productsUrl = `${ProductApi.basePath}?${new URLSearchParams({ search: productSelectInputValue }).toString()}`; - const { data: products, isLoading: isLoadingProducts } = useSWR( - productsUrl, - ProductApi.getAllFetcher - ); - const productOptions = isResponseSuccess(products) - ? products?.data.map((p) => ({ value: p.id, label: p.name })) + // Product Warehouse selection - Filter by source warehouse + const productWarehouseParams = new URLSearchParams({ + search: productWarehouseSelectInputValue, + }); + if (formik.values.source_warehouse_id) { + productWarehouseParams.append( + 'warehouse_id', + formik.values.source_warehouse_id.toString() + ); + } + const productWarehousesUrl = `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; + const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = + useSWR( + formik.values.source_warehouse_id ? productWarehousesUrl : null, + ProductWarehouseApi.getAllFetcher + ); + const productWarehouseOptions = isResponseSuccess(productWarehouses) + ? productWarehouses?.data.map((pw) => ({ + value: pw.id, + label: pw.product.name, + product_id: pw.product.id, + warehouse_id: pw.warehouse.id, + warehouse_name: pw.warehouse.name, + quantity: pw.quantity, + })) : []; // Supplier selection @@ -303,7 +364,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }); }, [formik.values.deliveries]); - const getFilteredProductOptions = useCallback(() => { + useEffect(() => { + if (formik.values.source_warehouse_id && type !== 'edit') { + formik.setFieldValue('products', []); + formik.setFieldValue('deliveries', []); + } + }, [formik.values.source_warehouse_id]); + + const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products ?.filter((p) => p.product) @@ -314,6 +382,33 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); }, [formik.values.products]); + const getAvailableStock = useCallback( + (productId: number) => { + const productWarehouse = productWarehouseOptions.find( + (pw) => pw.product_id === productId + ); + return productWarehouse?.quantity ?? 0; + }, + [productWarehouseOptions] + ); + + const getProductQtyError = useCallback( + (productIdx: number) => { + const product = formik.values.products?.[productIdx]; + if (!product || !product.product_id) return null; + + const availableStock = getAvailableStock(product.product_id); + const requestedQty = Number(product.product_qty) || 0; + + if (requestedQty > availableStock) { + return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`; + } + + return null; + }, + [formik.values.products, getAvailableStock] + ); + const validateDeliveryQty = useCallback( (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { const delivery = formik.values.deliveries?.[deliveryIdx]; @@ -406,6 +501,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [invalidQtyRows] ); + const hasExceededStock = useMemo(() => { + return ( + formik.values.products?.some((product, idx) => { + return getProductQtyError(idx) !== null; + }) ?? false + ); + }, [formik.values.products, getProductQtyError]); + return ( <>
    @@ -656,10 +759,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { (val as OptionType)?.value ); }} - options={productOptions} - onInputChange={setProductSelectInputValue} - isLoading={isLoadingProducts} - isDisabled={type === 'detail'} + options={productWarehouseOptions} + onInputChange={setProductWarehouseSelectInputValue} + isLoading={isLoadingProductWarehouses} + isDisabled={ + type === 'detail' || + !formik.values.source_warehouse_id + } + placeholder={ + !formik.values.source_warehouse_id + ? 'Pilih gudang asal terlebih dahulu' + : 'Pilih produk' + } isClearable {...isRepeaterInputError( 'products', @@ -669,23 +780,46 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { />
    {type !== 'detail' && ( - - + + {type !== 'detail' && } @@ -500,27 +520,47 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} - + {type !== 'detail' && } @@ -874,27 +914,53 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} {type !== 'detail' && ( + @@ -583,10 +633,15 @@ const ProjectFlockForm = ({ ? () => {} : kandangChangeHandler } + disabled={ + formType === 'detail' || + kandang.status != 'NON_ACTIVE' + } /> + ))} diff --git a/src/config/constant.ts b/src/config/constant.ts index 2d15c62d..ed4386a9 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -13,9 +13,9 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ }, { - title: 'Flock', + title: 'Production', link: '/production', - icon: 'material-symbols:raven-outline-rounded', + icon: 'material-symbols:conveyor-belt-outline-rounded', submenu: [ { title: 'List Flock', diff --git a/src/services/api/base.ts b/src/services/api/base.ts index d1ac4729..af1eaed7 100644 --- a/src/services/api/base.ts +++ b/src/services/api/base.ts @@ -79,4 +79,40 @@ export class BaseApiService { return undefined; } } + + async customRequest( + endpoint: string, + options?: { + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + payload?: PayloadType; + params?: Record; + } + ): Promise { + try { + const urlBase = endpoint.startsWith('http') + ? endpoint + : `${this.basePath.replace(/\/$/, '')}/${endpoint.replace(/^\//, '')}`; + + const url = options?.params + ? `${urlBase}?${new URLSearchParams( + Object.entries(options.params).reduce((acc, [key, value]) => { + if (value !== undefined) acc[key] = String(value); + return acc; + }, {} as Record) + )}` + : urlBase; + + const res = await httpClient(url, { + method: options?.method || 'GET', + body: options?.payload, + }); + + return res; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + return undefined; + } + } } diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts index 230bb60a..127020f4 100644 --- a/src/services/api/inventory.ts +++ b/src/services/api/inventory.ts @@ -2,7 +2,7 @@ import { InventoryAdjustment, CreateInventoryAdjustmentPayload, } from '@/types/api/inventory/adjustment'; -import { BaseApiService } from './base'; +import { BaseApiService } from '@/services/api/base'; export const inventoryAdjustmentApi = new BaseApiService< InventoryAdjustment, diff --git a/src/services/api/production.ts b/src/services/api/production.ts index 16234161..06e51c2c 100644 --- a/src/services/api/production.ts +++ b/src/services/api/production.ts @@ -2,7 +2,7 @@ import { ProjectFlock, CreateProjectFlockPayload, } from '@/types/api/production/project-flock'; -import { BaseApiService } from './base'; +import { BaseApiService } from '@/services/api/base'; export const ProjectFlockApi = new BaseApiService< ProjectFlock, diff --git a/src/stores/ui/ui.store.ts b/src/stores/ui/ui.store.ts index 2e64dcc1..49554bc9 100644 --- a/src/stores/ui/ui.store.ts +++ b/src/stores/ui/ui.store.ts @@ -4,7 +4,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { UIStore } from '@/types/stores'; -import { createMainUiSlice } from './slices/main.slice'; +import { createMainUiSlice } from '@/stores/ui/slices/main.slice'; export const useUiStore = create()( devtools( diff --git a/src/types/api/inventory/adjustment.d.ts b/src/types/api/inventory/adjustment.d.ts index 9d995919..852389fe 100644 --- a/src/types/api/inventory/adjustment.d.ts +++ b/src/types/api/inventory/adjustment.d.ts @@ -1,5 +1,5 @@ import { Product } from '@/types/api/master-data/product'; -import { Warehouse } from '../master-data/warehouse'; +import { Warehouse } from '@/types/api/master-data/warehouse'; export type BaseInventoryAdjustment = { id: number; diff --git a/src/types/api/master-data/flock.d.ts b/src/types/api/master-data/flock.d.ts index 0c59b84c..3ac5d390 100644 --- a/src/types/api/master-data/flock.d.ts +++ b/src/types/api/master-data/flock.d.ts @@ -1,4 +1,4 @@ -import { BaseMetadata } from "../api-general"; +import { BaseMetadata } from "@/types/api/api-general"; export type BaseFlock = { id: number; diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts index e05006d1..17cbbee7 100644 --- a/src/types/api/master-data/kandang.d.ts +++ b/src/types/api/master-data/kandang.d.ts @@ -5,6 +5,7 @@ import { BaseUser } from '@/types/api/user'; export type BaseKandang = { id: number; name: string; + status: string; location: BaseLocation; pic: BaseUser; }; diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index fd28ab91..1fb71563 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -1,9 +1,9 @@ -import { Area } from "../master-data/area"; -import { Fcr } from "../master-data/fcr"; -import { Flock } from "../master-data/flock"; -import { Kandang } from "../master-data/kandang"; -import { Location } from "../master-data/location"; -import { ProductCategory } from "../master-data/product-category"; +import { Area } from "@/types/api/master-data/area"; +import { Fcr } from "@/types/api/master-data/fcr"; +import { Flock } from "@/types/api/master-data/flock"; +import { Kandang } from "@/types/api/master-data/kandang"; +import { Location } from "@/types/api/master-data/location"; +import { ProductCategory } from "@/types/api/master-data/product-category"; export type BaseProjectFlock = { name: string; @@ -22,10 +22,15 @@ export type BaseProjectFlock = { kandangs: Kandang[]; } +export type PeriodFlock = { + flock: Flock; + next_period: number; +} + + export type ProjectFlock = BaseMetadata & BaseProjectFlock export type CreateProjectFlockPayload = { - name: string; flock_id: number; area_id: number; product_category_id: number; From c25b49c179dbff5f6394f263f45a8ccf4f64581e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 18 Oct 2025 11:39:18 +0700 Subject: [PATCH 071/212] feat(FE-114): add NumberInput component and integrate into RecordingForm for enhanced numeric input handling --- src/components/input/NumberInput.tsx | 444 ++++++++++++++++++ .../flock/recording/form/RecordingForm.tsx | 42 +- 2 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 src/components/input/NumberInput.tsx diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx new file mode 100644 index 00000000..a9b8d9a0 --- /dev/null +++ b/src/components/input/NumberInput.tsx @@ -0,0 +1,444 @@ +'use client'; + +import { + ChangeEvent, + ChangeEventHandler, + FocusEventHandler, + ReactNode, + useEffect, + useState, +} from 'react'; + +import { Icon } from '@iconify/react'; +import { cn } from '@/lib/helper'; + +// Utility Functions +const formatNumber = ( + value: number | string, + decimals: number = 0, + thousandSeparator: string = '.', + decimalSeparator: string = ',' +): string => { + if (value === '' || value === null || value === undefined) return ''; + + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return ''; + + const parts = numValue.toFixed(decimals).split('.'); + const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator); + const decimalPart = parts[1]; + + return decimals > 0 && decimalPart + ? `${integerPart}${decimalSeparator}${decimalPart}` + : integerPart; +}; + +const parseNumber = ( + value: string, + thousandSeparator: string = '.', + decimalSeparator: string = ',' +): number => { + if (!value) return 0; + + // Remove thousand separators and replace decimal separator with dot + const cleaned = value + .replace(new RegExp(`\\${thousandSeparator}`, 'g'), '') + .replace(decimalSeparator, '.'); + + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? 0 : parsed; +}; + +const formatCurrency = ( + value: number | string, + prefix: string = 'Rp ', + decimals: number = 0 +): string => { + if (value === '' || value === null || value === undefined) return ''; + const formatted = formatNumber(value, decimals); + return formatted ? `${prefix}${formatted}` : ''; +}; + +const formatWeight = ( + value: number | string, + unit: string = 'kg', + decimals: number = 2 +): string => { + if (value === '' || value === null || value === undefined) return ''; + const formatted = formatNumber(value, decimals); + return formatted ? `${formatted} ${unit}` : ''; +}; + +const cleanNumericInput = ( + value: string, + allowDecimal: boolean = false, + decimalSeparator: string = ',' +): string => { + // Only allow numbers, decimal separator (if allowed), and minus sign at the start + let cleaned = value.replace(/[^\d,.-]/g, ''); + + // Handle decimal separator + if (allowDecimal) { + const parts = cleaned.split(decimalSeparator); + if (parts.length > 2) { + // Keep only first decimal separator + cleaned = parts[0] + decimalSeparator + parts.slice(1).join(''); + } + } else { + cleaned = cleaned.replace(new RegExp(decimalSeparator, 'g'), ''); + } + + // Handle minus sign (only at start) + const hasMinusAtStart = cleaned.startsWith('-'); + cleaned = cleaned.replace(/-/g, ''); + if (hasMinusAtStart) cleaned = '-' + cleaned; + + return cleaned; +}; + +// Types +export type MaskType = 'currency' | 'weight' | 'decimal' | 'number'; + +export interface NumberInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: number | string; + placeholder?: string; + className?: { + wrapper?: string; + label?: string; + inputWrapper?: string; + input?: string; + }; + isError?: boolean; + isValid?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + isLoading?: boolean; + errorMessage?: string; + startAdornment?: ReactNode; + endAdornment?: ReactNode; + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; + onFocus?: FocusEventHandler; + + // Masking Options + maskType?: MaskType; + decimals?: number; + thousandSeparator?: string; + decimalSeparator?: string; + + // Currency specific + currencyPrefix?: string; + + // Weight specific + weightUnit?: string; + + // Validation + min?: number; + max?: number; + allowNegative?: boolean; + + // Stepper (Increment/Decrement buttons) + showSteppers?: boolean; + step?: number; +} + +const NumberInput = ({ + label, + bottomLabel, + name, + value, + placeholder, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + onFocus, + readOnly = false, + isLoading = false, + maskType = 'number', + decimals = 0, + thousandSeparator = '.', + decimalSeparator = ',', + currencyPrefix = 'Rp ', + weightUnit = 'kg', + min, + max, + allowNegative = false, + showSteppers = false, + step = 1, +}: NumberInputProps) => { + const [displayValue, setDisplayValue] = useState(''); + + // Determine if decimals are allowed based on maskType + const allowDecimal = maskType === 'decimal' || maskType === 'weight' || decimals > 0; + + // Format value for display based on maskType + const getFormattedValue = (rawValue: number | string): string => { + if (rawValue === '' || rawValue === null || rawValue === undefined) return ''; + + switch (maskType) { + case 'currency': + return formatCurrency(rawValue, currencyPrefix, decimals); + case 'weight': + return formatWeight(rawValue, weightUnit, decimals); + case 'decimal': + case 'number': + return formatNumber(rawValue, decimals, thousandSeparator, decimalSeparator); + default: + return String(rawValue); + } + }; + + // Initialize display value when value prop changes + useEffect(() => { + setDisplayValue(getFormattedValue(value || '')); + }, [value]); + + const handleInputChange = (e: ChangeEvent) => { + let inputValue = e.target.value; + + // Remove prefix/suffix for editing + if (maskType === 'currency' && inputValue.startsWith(currencyPrefix)) { + inputValue = inputValue.slice(currencyPrefix.length); + } + if (maskType === 'weight' && inputValue.endsWith(` ${weightUnit}`)) { + inputValue = inputValue.slice(0, -weightUnit.length - 1); + } + + // Clean input + const cleaned = cleanNumericInput(inputValue, allowDecimal, decimalSeparator); + + // Parse to number + let numericValue = parseNumber(cleaned, thousandSeparator, decimalSeparator); + + // Apply validation + if (!allowNegative && numericValue < 0) { + numericValue = 0; + } + if (min !== undefined && numericValue < min) { + numericValue = min; + } + if (max !== undefined && numericValue > max) { + numericValue = max; + } + + // Update display value + const formattedForDisplay = formatNumber( + numericValue, + decimals, + thousandSeparator, + decimalSeparator + ); + + setDisplayValue(formattedForDisplay); + + // Call onChange with modified event + if (onChange) { + // Create a synthetic event with the numeric value + const syntheticEvent = { + ...e, + target: { + ...e.target, + name, + value: numericValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + // Handle Increment + const handleIncrement = () => { + if (disabled || readOnly) return; + + const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + let newValue = currentValue + step; + + // Apply max validation + if (max !== undefined && newValue > max) { + newValue = max; + } + + // Update display + const formattedForDisplay = formatNumber( + newValue, + decimals, + thousandSeparator, + decimalSeparator + ); + setDisplayValue(formattedForDisplay); + + // Call onChange with synthetic event + if (onChange) { + const syntheticEvent = { + target: { + name, + value: newValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + // Handle Decrement + const handleDecrement = () => { + if (disabled || readOnly) return; + + const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + let newValue = currentValue - step; + + // Apply min validation (prevent negative if not allowed) + if (!allowNegative && newValue < 0) { + newValue = 0; + } + if (min !== undefined && newValue < min) { + newValue = min; + } + + // Update display + const formattedForDisplay = formatNumber( + newValue, + decimals, + thousandSeparator, + decimalSeparator + ); + setDisplayValue(formattedForDisplay); + + // Call onChange with synthetic event + if (onChange) { + const syntheticEvent = { + target: { + name, + value: newValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + return ( +
    + {label && ( + + )} + +
    + {/* Decrement Button */} + {showSteppers && ( + + )} + + {startAdornment && startAdornment} + + + + {(isLoading || endAdornment) && ( +
    + {isLoading && } + + {endAdornment && endAdornment} +
    + )} + + {/* Increment Button */} + {showSteppers && ( + + )} +
    + + {!isError && bottomLabel && ( +

    {bottomLabel}

    + )} + {isError && errorMessage && ( +

    {errorMessage}

    + )} +
    + ); +}; + +export default NumberInput; diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 65c8073b..773baab6 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -6,6 +6,7 @@ import { Icon } from '@iconify/react'; import { toast } from 'react-hot-toast'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; @@ -589,13 +590,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { />
    )} {type !== 'detail' && ( @@ -995,13 +1015,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { />
    - + + {product.product_id && ( +
    + + Stok tersedia: + {' '} + {getAvailableStock( + product.product_id + ).toLocaleString('id-ID')} +
    )} - readOnly={type === 'detail'} - className={{ - wrapper: 'w-full min-w-24', - }} - /> +
    @@ -819,7 +953,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { (val as OptionType)?.value ); }} - options={getFilteredProductOptions()} + options={getFilteredProductWarehouseOptions()} isDisabled={type === 'detail'} isClearable /> @@ -886,7 +1020,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { { const file = e.target.files?.[0]; @@ -1016,7 +1149,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { : undefined } onDelete={deleteMovementClickHandler} - disableSubmit={hasInvalidQty} + disableSubmit={hasInvalidQty || hasExceededStock} /> {movementFormErrorMessage && ( diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index 1894b1a7..3f46b71a 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -24,7 +24,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { let finalPayload: CreateMovementPayload | FormData; if (documents.length > 0) { - // Ada dokumen: kirim sebagai FormData dengan "data" field console.log('3. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); @@ -35,7 +34,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { console.log('4. FormData entries:'); for (const [key, value] of formData.entries()) { if (value instanceof File) { - console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + console.log( + ` ${key}: [File] ${value.name} (${value.size} bytes)` + ); } else { console.log(` ${key}: ${value}`); } @@ -43,7 +44,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { finalPayload = formData as unknown as CreateMovementPayload; } else { - // Tidak ada dokumen: kirim sebagai JSON biasa console.log('3. Sending as JSON (no documents)'); console.log('4. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; @@ -64,7 +64,11 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { ); const updateMovementHandler = useCallback( - async (movementId: number, payload: UpdateMovementPayload, documents: File[] = []) => { + async ( + movementId: number, + payload: UpdateMovementPayload, + documents: File[] = [] + ) => { console.log('=== UPDATE HANDLER DEBUG ==='); console.log('1. Received payload:', payload); console.log('2. Movement ID:', movementId); @@ -73,7 +77,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { let finalPayload: UpdateMovementPayload | FormData; if (documents.length > 0) { - // Ada dokumen: kirim sebagai FormData dengan "data" field console.log('4. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); @@ -84,7 +87,9 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { console.log('5. FormData entries:'); for (const [key, value] of formData.entries()) { if (value instanceof File) { - console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`); + console.log( + ` ${key}: [File] ${value.name} (${value.size} bytes)` + ); } else { console.log(` ${key}: ${value}`); } @@ -92,7 +97,6 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { finalPayload = formData as unknown as UpdateMovementPayload; } else { - // Tidak ada dokumen: kirim sebagai JSON biasa console.log('4. Sending as JSON (no documents)'); console.log('5. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; From 157dfc75ed4317df8b5b1118c7cdbe701342c1a4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 15:47:29 +0700 Subject: [PATCH 053/212] refactor(FE-63): remove debug logging from form submission and movement handlers --- .../inventory/movement/form/MovementForm.tsx | 27 ------------ .../movement/form/useMovementFormHandlers.ts | 42 ------------------- src/services/http/client.ts | 5 ++- 3 files changed, 4 insertions(+), 70 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 898a1d56..a6a75394 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -68,27 +68,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { validateOnMount: false, enableReinitialize: true, onSubmit: async (values) => { - console.log('=== FORM SUBMIT DEBUG ==='); - console.log('1. Form values received:', values); - setMovementFormErrorMessage(''); const documents: File[] = []; const deliveriesPayload = values.deliveries.map((d, idx) => { let documentIndex = 0; - console.log(`2. Processing delivery ${idx}:`, { - driver_name: d.driver_name, - document: d.document, - documentType: d.document instanceof File ? 'File' : typeof d.document, - documentSize: d.document instanceof File ? d.document.size : 'N/A', - }); - if (d.document && d.document instanceof File) { documents.push(d.document); documentIndex = documents.length - 1; - console.log(` → Document added at index ${documentIndex}`); } else { - console.log(` → No document for delivery ${idx}, using index 0`); } return { @@ -117,21 +105,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { deliveries: deliveriesPayload, }; - console.log('3. Final payload structure:', { - ...payload, - }); - - console.log( - '4. Document indices in deliveries:', - deliveriesPayload.map((d, i) => ({ - delivery: i, - document_index: d.document_index, - })) - ); - - console.log('5. Total documents:', documents.length); - console.log('=== END SUBMIT DEBUG ==='); - switch (type) { case 'add': await createMovementHandler(payload, documents); diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts index 3f46b71a..5c3d80d1 100644 --- a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -17,40 +17,20 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { const createMovementHandler = useCallback( async (payload: CreateMovementPayload, documents: File[] = []) => { - console.log('=== CREATE HANDLER DEBUG ==='); - console.log('1. Received payload:', payload); - console.log('2. Documents count:', documents.length); - let finalPayload: CreateMovementPayload | FormData; if (documents.length > 0) { - console.log('3. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); documents.forEach((file, index) => { formData.append(`documents[${index}]`, file); }); - console.log('4. FormData entries:'); - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - console.log( - ` ${key}: [File] ${value.name} (${value.size} bytes)` - ); - } else { - console.log(` ${key}: ${value}`); - } - } - finalPayload = formData as unknown as CreateMovementPayload; } else { - console.log('3. Sending as JSON (no documents)'); - console.log('4. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; } - console.log('=== END CREATE HANDLER DEBUG ==='); - const res = await MovementApi.create(finalPayload); if (isResponseError(res)) { console.error('API Error:', res); @@ -69,44 +49,22 @@ export const useMovementFormHandlers = (initialValuesId?: number) => { payload: UpdateMovementPayload, documents: File[] = [] ) => { - console.log('=== UPDATE HANDLER DEBUG ==='); - console.log('1. Received payload:', payload); - console.log('2. Movement ID:', movementId); - console.log('3. Documents count:', documents.length); - let finalPayload: UpdateMovementPayload | FormData; if (documents.length > 0) { - console.log('4. Creating FormData (has documents)'); const formData = new FormData(); formData.append('data', JSON.stringify(payload)); documents.forEach((file, index) => { formData.append(`documents[${index}]`, file); }); - console.log('5. FormData entries:'); - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - console.log( - ` ${key}: [File] ${value.name} (${value.size} bytes)` - ); - } else { - console.log(` ${key}: ${value}`); - } - } - finalPayload = formData as unknown as UpdateMovementPayload; } else { - console.log('4. Sending as JSON (no documents)'); - console.log('5. Payload:', JSON.stringify(payload, null, 2)); finalPayload = payload; } - console.log('=== END UPDATE HANDLER DEBUG ==='); - const res = await MovementApi.update(movementId, finalPayload); if (res?.status === 'error') { - console.error('API Error:', res); setMovementFormErrorMessage(res.message); return; } diff --git a/src/services/http/client.ts b/src/services/http/client.ts index adba75e9..9dd382ca 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -14,6 +14,9 @@ export async function httpClient( (!opts.auth && opts.auth !== 'none' && opts.auth !== 'bearer'); const isBearerAuth = opts.auth === 'bearer' && !!opts.token; + const isFormData = + typeof FormData !== 'undefined' && opts.body instanceof FormData; + const config: AxiosRequestConfig = { url: path, method: opts.method ?? 'GET', @@ -22,7 +25,7 @@ export async function httpClient( timeout: opts.timeoutMs ?? 10_000, withCredentials: isCookieAuth && !isBearerAuth, headers: { - 'Content-Type': 'application/json', + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...(opts.headers ?? {}), ...(isBearerAuth && !isCookieAuth ? { Authorization: `Bearer ${opts.token}` } From a2a57f758c0c7744dd9431aa85dc3e018bf8d5d2 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 16:24:42 +0700 Subject: [PATCH 054/212] refactor(FE-63,63,65): enhance MovementForm to fetch and display product details from ProductWarehouse --- .../movement/form/MovementForm.schema.ts | 96 +++++---- .../inventory/movement/form/MovementForm.tsx | 182 +++++++++++++++++- 2 files changed, 237 insertions(+), 41 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 148a7dce..d5d660ae 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -119,43 +119,61 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement -): MovementFormValues => ({ - transfer_reason: initialValues?.transfer_reason ?? '', - transfer_date: initialValues?.transfer_date ?? '', - source_warehouse: initialValues?.source_warehouse - ? { - value: initialValues.source_warehouse.id, - label: initialValues.source_warehouse.name, - } - : null, - source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, - destination_warehouse: initialValues?.destination_warehouse - ? { - value: initialValues.destination_warehouse.id, - label: initialValues.destination_warehouse.name, - } - : null, - destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, - products: - initialValues?.details?.map((p) => ({ - product: { value: p.product_id, label: '' }, - product_id: p.product_id, - product_qty: p.quantity, - })) ?? [], - deliveries: - initialValues?.deliveries?.map((d) => ({ - delivery_cost: d.shipping_cost_total, - delivery_cost_per_item: d.shipping_cost_item, - document_index: 0, - document: d.document_path || null, - driver_name: d.driver_name, - vehicle_plate: d.vehicle_plate, - supplier: { value: d.supplier.id, label: d.supplier.name }, - supplier_id: d.supplier_id, - products: d.items.map((p) => ({ - product: { value: 0, label: '' }, - product_id: 0, +): MovementFormValues => { + const detailIdToProductId = new Map(); + initialValues?.details?.forEach((detail) => { + detailIdToProductId.set(detail.id, detail.product_id); + }); + + return { + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse + ? { + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, + } + : null, + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse + ? { + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, + } + : null, + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.details?.map((p) => ({ + product: { value: p.product_id, label: `Product ID: ${p.product_id}` }, + product_id: p.product_id, product_qty: p.quantity, - })), - })) ?? [], -}); + })) ?? [], + deliveries: + initialValues?.deliveries?.map((d) => { + return { + delivery_cost: d.shipping_cost_total, + delivery_cost_per_item: d.shipping_cost_item, + document_index: 0, + document: 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, + products: d.items.map((item) => { + const productId = + detailIdToProductId.get(item.stock_transfer_detail_id) ?? 0; + return { + product: + productId > 0 + ? { value: productId, label: `Product ID: ${productId}` } + : null, + product_id: productId, + product_qty: item.quantity, + }; + }), + }; + }) ?? [], + }; +}; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index a6a75394..adf6658d 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,7 +25,11 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { + SupplierApi, + WarehouseApi, + ProductApi, +} from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -43,6 +47,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); + const [fetchedProductIds, setFetchedProductIds] = useState>( + new Set() + ); const { deleteModal, @@ -338,12 +345,183 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, [formik.values.deliveries]); useEffect(() => { - if (formik.values.source_warehouse_id && type !== 'edit') { + if ( + formik.values.source_warehouse_id && + type !== 'edit' && + type !== 'detail' + ) { formik.setFieldValue('products', []); formik.setFieldValue('deliveries', []); } }, [formik.values.source_warehouse_id]); + // Effect to populate product labels from ProductWarehouse data + useEffect(() => { + if (!productWarehouses || !isResponseSuccess(productWarehouses)) return; + if (type !== 'edit' && type !== 'detail') return; + + let hasUpdates = false; + const updatedProducts = formik.values.products?.map((product) => { + if (product.product && product.product.label.startsWith('Product ID:')) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === product.product_id + ); + if (productWarehouse) { + hasUpdates = true; + return { + ...product, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return product; + }); + + if (hasUpdates && updatedProducts) { + formik.setFieldValue('products', updatedProducts); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') + ) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === deliveryProduct.product_id + ); + if (productWarehouse) { + return { + ...deliveryProduct, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + formik.setFieldValue('deliveries', updatedDeliveries); + } + }, [productWarehouses, type]); + + useEffect(() => { + if (type !== 'edit' && type !== 'detail') return; + + const productIdsToFetch: number[] = []; + + formik.values.products?.forEach((product) => { + if ( + product.product && + product.product.label.startsWith('Product ID:') && + product.product_id > 0 && + !fetchedProductIds.has(product.product_id) + ) { + productIdsToFetch.push(product.product_id); + } + }); + + formik.values.deliveries?.forEach((delivery) => { + delivery.products.forEach((deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') && + deliveryProduct.product_id > 0 && + !fetchedProductIds.has(deliveryProduct.product_id) + ) { + if (!productIdsToFetch.includes(deliveryProduct.product_id)) { + productIdsToFetch.push(deliveryProduct.product_id); + } + } + }); + }); + + if (productIdsToFetch.length === 0) return; + + const fetchProducts = async () => { + const productMap = new Map(); + const newFetchedIds = new Set(fetchedProductIds); + + for (const productId of productIdsToFetch) { + try { + const response = await ProductApi.getSingle(productId); + if (isResponseSuccess(response)) { + const product = response.data; + productMap.set(product.id, { id: product.id, name: product.name }); + newFetchedIds.add(productId); + } + } catch (error) { + console.error(`Failed to fetch product ${productId}:`, error); + newFetchedIds.add(productId); // Mark as fetched to avoid retry loops + } + } + + if (productMap.size > 0) { + const updatedProducts = formik.values.products?.map((p) => { + const productData = productMap.get(p.product_id); + if (productData) { + return { + ...p, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return p; + }); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + const productData = productMap.get(deliveryProduct.product_id); + if (productData) { + return { + ...deliveryProduct, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + + if (updatedProducts) { + formik.setFieldValue('products', updatedProducts); + } + if (updatedDeliveries) { + formik.setFieldValue('deliveries', updatedDeliveries); + } + } + + setFetchedProductIds(newFetchedIds); + }; + + fetchProducts(); + }, [ + formik.values.products, + formik.values.deliveries, + type, + fetchedProductIds, + ]); + const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products From 501a68267e0bbaa820b64fa819d1c601e568a37a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 16:24:42 +0700 Subject: [PATCH 055/212] refactor(FE-63,63,65): enhance MovementForm to fetch and display product details from ProductWarehouse --- .../movement/form/MovementForm.schema.ts | 96 +++++---- .../inventory/movement/form/MovementForm.tsx | 184 +++++++++++++++++- 2 files changed, 238 insertions(+), 42 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 148a7dce..d5d660ae 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -119,43 +119,61 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement -): MovementFormValues => ({ - transfer_reason: initialValues?.transfer_reason ?? '', - transfer_date: initialValues?.transfer_date ?? '', - source_warehouse: initialValues?.source_warehouse - ? { - value: initialValues.source_warehouse.id, - label: initialValues.source_warehouse.name, - } - : null, - source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, - destination_warehouse: initialValues?.destination_warehouse - ? { - value: initialValues.destination_warehouse.id, - label: initialValues.destination_warehouse.name, - } - : null, - destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, - products: - initialValues?.details?.map((p) => ({ - product: { value: p.product_id, label: '' }, - product_id: p.product_id, - product_qty: p.quantity, - })) ?? [], - deliveries: - initialValues?.deliveries?.map((d) => ({ - delivery_cost: d.shipping_cost_total, - delivery_cost_per_item: d.shipping_cost_item, - document_index: 0, - document: d.document_path || null, - driver_name: d.driver_name, - vehicle_plate: d.vehicle_plate, - supplier: { value: d.supplier.id, label: d.supplier.name }, - supplier_id: d.supplier_id, - products: d.items.map((p) => ({ - product: { value: 0, label: '' }, - product_id: 0, +): MovementFormValues => { + const detailIdToProductId = new Map(); + initialValues?.details?.forEach((detail) => { + detailIdToProductId.set(detail.id, detail.product_id); + }); + + return { + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse + ? { + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, + } + : null, + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse + ? { + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, + } + : null, + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.details?.map((p) => ({ + product: { value: p.product_id, label: `Product ID: ${p.product_id}` }, + product_id: p.product_id, product_qty: p.quantity, - })), - })) ?? [], -}); + })) ?? [], + deliveries: + initialValues?.deliveries?.map((d) => { + return { + delivery_cost: d.shipping_cost_total, + delivery_cost_per_item: d.shipping_cost_item, + document_index: 0, + document: 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, + products: d.items.map((item) => { + const productId = + detailIdToProductId.get(item.stock_transfer_detail_id) ?? 0; + return { + product: + productId > 0 + ? { value: productId, label: `Product ID: ${productId}` } + : null, + product_id: productId, + product_qty: item.quantity, + }; + }), + }; + }) ?? [], + }; +}; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index a6a75394..5c5138bf 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,7 +25,11 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { + SupplierApi, + WarehouseApi, + ProductApi, +} from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -43,6 +47,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); + const [fetchedProductIds, setFetchedProductIds] = useState>( + new Set() + ); const { deleteModal, @@ -338,12 +345,183 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { }, [formik.values.deliveries]); useEffect(() => { - if (formik.values.source_warehouse_id && type !== 'edit') { + if ( + formik.values.source_warehouse_id && + type !== 'edit' && + type !== 'detail' + ) { formik.setFieldValue('products', []); formik.setFieldValue('deliveries', []); } }, [formik.values.source_warehouse_id]); + // Effect to populate product labels from ProductWarehouse data + useEffect(() => { + if (!productWarehouses || !isResponseSuccess(productWarehouses)) return; + if (type !== 'edit' && type !== 'detail') return; + + let hasUpdates = false; + const updatedProducts = formik.values.products?.map((product) => { + if (product.product && product.product.label.startsWith('Product ID:')) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === product.product_id + ); + if (productWarehouse) { + hasUpdates = true; + return { + ...product, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return product; + }); + + if (hasUpdates && updatedProducts) { + formik.setFieldValue('products', updatedProducts); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') + ) { + const productWarehouse = productWarehouses.data.find( + (pw) => pw.product.id === deliveryProduct.product_id + ); + if (productWarehouse) { + return { + ...deliveryProduct, + product: { + value: productWarehouse.product.id, + label: productWarehouse.product.name, + }, + }; + } + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + formik.setFieldValue('deliveries', updatedDeliveries); + } + }, [productWarehouses, type]); + + useEffect(() => { + if (type !== 'edit' && type !== 'detail') return; + + const productIdsToFetch: number[] = []; + + formik.values.products?.forEach((product) => { + if ( + product.product && + product.product.label.startsWith('Product ID:') && + product.product_id > 0 && + !fetchedProductIds.has(product.product_id) + ) { + productIdsToFetch.push(product.product_id); + } + }); + + formik.values.deliveries?.forEach((delivery) => { + delivery.products.forEach((deliveryProduct) => { + if ( + deliveryProduct.product && + deliveryProduct.product.label.startsWith('Product ID:') && + deliveryProduct.product_id > 0 && + !fetchedProductIds.has(deliveryProduct.product_id) + ) { + if (!productIdsToFetch.includes(deliveryProduct.product_id)) { + productIdsToFetch.push(deliveryProduct.product_id); + } + } + }); + }); + + if (productIdsToFetch.length === 0) return; + + const fetchProducts = async () => { + const productMap = new Map(); + const newFetchedIds = new Set(fetchedProductIds); + + for (const productId of productIdsToFetch) { + try { + const response = await ProductApi.getSingle(productId); + if (isResponseSuccess(response)) { + const product = response.data; + productMap.set(product.id, { id: product.id, name: product.name }); + newFetchedIds.add(productId); + } + } catch (error) { + console.error(`Failed to fetch product ${productId}:`, error); + newFetchedIds.add(productId); // Mark as fetched to avoid retry loops + } + } + + if (productMap.size > 0) { + const updatedProducts = formik.values.products?.map((p) => { + const productData = productMap.get(p.product_id); + if (productData) { + return { + ...p, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return p; + }); + + const updatedDeliveries = formik.values.deliveries?.map((delivery) => { + const updatedDeliveryProducts = delivery.products.map( + (deliveryProduct) => { + const productData = productMap.get(deliveryProduct.product_id); + if (productData) { + return { + ...deliveryProduct, + product: { + value: productData.id, + label: productData.name, + }, + }; + } + return deliveryProduct; + } + ); + return { + ...delivery, + products: updatedDeliveryProducts, + }; + }); + + if (updatedProducts) { + formik.setFieldValue('products', updatedProducts); + } + if (updatedDeliveries) { + formik.setFieldValue('deliveries', updatedDeliveries); + } + } + + setFetchedProductIds(newFetchedIds); + }; + + fetchProducts(); + }, [ + formik.values.products, + formik.values.deliveries, + type, + fetchedProductIds, + ]); + const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products @@ -484,7 +662,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return ( <> -
    +
    Date: Thu, 16 Oct 2025 16:46:33 +0700 Subject: [PATCH 056/212] refactor(FE-62,63): update MovementForm to handle 'detail' type with appropriate validations and stock checks --- .../inventory/movement/form/MovementForm.tsx | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 5c5138bf..0483b4c1 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -462,7 +462,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } } catch (error) { console.error(`Failed to fetch product ${productId}:`, error); - newFetchedIds.add(productId); // Mark as fetched to avoid retry loops + newFetchedIds.add(productId); } } @@ -535,16 +535,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const getAvailableStock = useCallback( (productId: number) => { + if (type === 'detail') return 0; const productWarehouse = productWarehouseOptions.find( (pw) => pw.product_id === productId ); return productWarehouse?.quantity ?? 0; }, - [productWarehouseOptions] + [productWarehouseOptions, type] ); const getProductQtyError = useCallback( (productIdx: number) => { + if (type === 'detail') return null; const product = formik.values.products?.[productIdx]; if (!product || !product.product_id) return null; @@ -557,11 +559,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return null; }, - [formik.values.products, getAvailableStock] + [formik.values.products, getAvailableStock, type] ); const validateDeliveryQty = useCallback( (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { + if (type === 'detail') return true; const delivery = formik.values.deliveries?.[deliveryIdx]; if (!delivery) return true; @@ -592,11 +595,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return totalQtyUsed + qty <= Number(relatedProduct.product_qty); }, - [formik.values.deliveries, formik.values.products] + [formik.values.deliveries, formik.values.products, type] ); const getDeliveryQtyError = useCallback( (deliveryIdx: number, deliveryProductIdx: number) => { + if (type === 'detail') return null; const delivery = formik.values.deliveries?.[deliveryIdx]; if (!delivery) return null; @@ -633,32 +637,40 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { return null; }, - [formik.values.deliveries, formik.values.products] + [formik.values.deliveries, formik.values.products, type] ); const invalidQtyRows = useMemo( () => - formik.values.deliveries?.flatMap((delivery, deliveryIdx) => - delivery.products.map((product, productIdx) => { - const qty = Number(product.product_qty) || 0; - return !validateDeliveryQty(deliveryIdx, productIdx, qty); - }) - ) ?? [], - [formik.values.deliveries, formik.values.products, validateDeliveryQty] + type === 'detail' + ? [] + : (formik.values.deliveries?.flatMap((delivery, deliveryIdx) => + delivery.products.map((product, productIdx) => { + const qty = Number(product.product_qty) || 0; + return !validateDeliveryQty(deliveryIdx, productIdx, qty); + }) + ) ?? []), + [ + formik.values.deliveries, + formik.values.products, + validateDeliveryQty, + type, + ] ); const hasInvalidQty = useMemo( - () => invalidQtyRows.some(Boolean), - [invalidQtyRows] + () => (type === 'detail' ? false : invalidQtyRows.some(Boolean)), + [invalidQtyRows, type] ); const hasExceededStock = useMemo(() => { + if (type === 'detail') return false; return ( formik.values.products?.some((product, idx) => { return getProductQtyError(idx) !== null; }) ?? false ); - }, [formik.values.products, getProductQtyError]); + }, [formik.values.products, getProductQtyError, type]); return ( <> @@ -960,7 +972,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { wrapper: 'w-full min-w-24', }} /> - {product.product_id && ( + {type !== 'detail' && product.product_id && (
    Stok tersedia: From 5113bf4d3f507eea485afb270f9c6f123840eee4 Mon Sep 17 00:00:00 2001 From: randy-ar Date: Thu, 16 Oct 2025 16:49:44 +0700 Subject: [PATCH 057/212] feat(84-85-86-87-88-89-102): create feature project flocks and adjust master data flock feature --- src/app/globals.css | 2 +- .../master-data/flock/detail/edit/page.tsx | 2 + src/app/master-data/flock/detail/page.tsx | 2 + src/app/production/project-flock/add/page.tsx | 13 + .../project-flock/detail/edit/page.tsx | 0 .../production/project-flock/detail/page.tsx | 46 ++ src/app/production/project-flock/page.tsx | 12 + .../pages/master-data/flock/FlocksTable.tsx | 16 +- .../flock/form/FlockForm.schema.ts | 6 +- .../master-data/flock/form/FlockForm.tsx | 2 +- .../project-flock/ProjectFlockTable.tsx | 312 +++++++++ .../form/ProjectFlockForm.schema.ts | 57 ++ .../project-flock/form/ProjectFlockForm.tsx | 646 ++++++++++++++++++ src/config/constant.ts | 61 +- src/services/api/production.ts | 11 + src/types/api/production/project-flock.d.ts | 38 ++ 16 files changed, 1207 insertions(+), 19 deletions(-) create mode 100644 src/app/production/project-flock/add/page.tsx create mode 100644 src/app/production/project-flock/detail/edit/page.tsx create mode 100644 src/app/production/project-flock/detail/page.tsx create mode 100644 src/app/production/project-flock/page.tsx create mode 100644 src/components/pages/production/project-flock/ProjectFlockTable.tsx create mode 100644 src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts create mode 100644 src/components/pages/production/project-flock/form/ProjectFlockForm.tsx create mode 100644 src/services/api/production.ts create mode 100644 src/types/api/production/project-flock.d.ts diff --git a/src/app/globals.css b/src/app/globals.css index 0fb52327..d2351a24 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,7 +2,7 @@ @plugin "daisyui"; @plugin "daisyui/theme" { - name: "corporate"; + name: "lti"; default: false; prefersdark: false; color-scheme: "light"; diff --git a/src/app/master-data/flock/detail/edit/page.tsx b/src/app/master-data/flock/detail/edit/page.tsx index c3903555..c9651727 100644 --- a/src/app/master-data/flock/detail/edit/page.tsx +++ b/src/app/master-data/flock/detail/edit/page.tsx @@ -1,3 +1,5 @@ +'use client' + import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; import { FlockApi } from "@/services/api/master-data"; diff --git a/src/app/master-data/flock/detail/page.tsx b/src/app/master-data/flock/detail/page.tsx index cedc3243..8a805911 100644 --- a/src/app/master-data/flock/detail/page.tsx +++ b/src/app/master-data/flock/detail/page.tsx @@ -1,3 +1,5 @@ +'use client' + import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; import { FlockApi } from "@/services/api/master-data"; diff --git a/src/app/production/project-flock/add/page.tsx b/src/app/production/project-flock/add/page.tsx new file mode 100644 index 00000000..60141d80 --- /dev/null +++ b/src/app/production/project-flock/add/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; + +const AddProjectFlock = () => { + return ( +
    + +
    + ); +} + +export default AddProjectFlock; \ No newline at end of file diff --git a/src/app/production/project-flock/detail/edit/page.tsx b/src/app/production/project-flock/detail/edit/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/production/project-flock/detail/page.tsx b/src/app/production/project-flock/detail/page.tsx new file mode 100644 index 00000000..5efe83d8 --- /dev/null +++ b/src/app/production/project-flock/detail/page.tsx @@ -0,0 +1,46 @@ +'use client' + + +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { ProjectFlockApi } from "@/services/api/production"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const ProjectFlockDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const projectFlockId = searchParams.get("projectFlockId"); + + const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( + projectFlockId, + (id: number) => ProjectFlockApi.getSingle(id) + ); + + if(!projectFlockId){ + router.back(); + + return ( +
    + +
    + ); + } + + if(!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))){ + router.replace("/404"); + return; + } + + return ( +
    + {isLoadingCostumer && } + {!isLoadingCostumer && isResponseSuccess(projectFlock) && ( + + )} +
    + ) +} + +export default ProjectFlockDetail; \ No newline at end of file diff --git a/src/app/production/project-flock/page.tsx b/src/app/production/project-flock/page.tsx new file mode 100644 index 00000000..fdb8775d --- /dev/null +++ b/src/app/production/project-flock/page.tsx @@ -0,0 +1,12 @@ +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm" +import ProjectFlockTable from "@/components/pages/production/project-flock/ProjectFlockTable"; + +const ProjectFlock = () => { + return ( +
    + +
    + ); +} + +export default ProjectFlock; diff --git a/src/components/pages/master-data/flock/FlocksTable.tsx b/src/components/pages/master-data/flock/FlocksTable.tsx index 817eff40..60b392de 100644 --- a/src/components/pages/master-data/flock/FlocksTable.tsx +++ b/src/components/pages/master-data/flock/FlocksTable.tsx @@ -41,7 +41,7 @@ const RowsOptions = ({ )} > + + +
    + ); +}; + +const ProjectFlockTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + + // Fetch Data + const { + data: projectFlocks, + isLoading, + mutate: refreshProjectFlocks, + } = useSWR( + `${ProjectFlockApi.basePath}${getTableFilterQueryString()}`, + ProjectFlockApi.getAllFetcher + ); + + // State + const [sorting, setSorting] = useState([]); + const [selectedProjectFlock, setSelectedProjectFlock] = + useState(); + const deleteModal = useModal(); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // Columns + const projectFlocksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'flock.name', + header: 'Flock', + }, + { + accessorKey: 'area.name', + header: 'Area', + }, + { + accessorKey: 'location.name', + header: 'Lokasi', + }, + { + accessorKey: 'fcr.name', + header: 'FCR', + }, + { + accessorKey: 'product_category.name', + header: 'Kategori Produk', + }, + { + header: 'Kandang', + cell: (props) => { + const kandang = props.row.original.kandangs; + const kandangNames = kandang.map((k: Kandang) => k.name); + console.log('kandang'); + console.log(kandang); + return ( +
    + {kandangNames.length > 0 ? kandangNames.join(', ') : 'Tidak ada'} +
    + ); + }, + }, + { + accessorKey: 'period', + header: 'Periode', + }, + { + accessorKey: 'created_at', + header: 'Dibuat pada', + cell: (props) => + new Date(props.row.original.created_at).toLocaleDateString(), + }, + { + 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 = () => { + setSelectedProjectFlock(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + // Handler + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await ProjectFlockApi.delete(selectedProjectFlock?.id as number); + refreshProjectFlocks(); + + deleteModal.closeModal(); + toast.success('Successfully delete Project Flock!'); + setIsDeleteLoading(false); + }; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + return ( + <> +
    +
    +
    +
    + +
    +
    + +
    +
    + + + data={isResponseSuccess(projectFlocks) ? projectFlocks?.data : []} + columns={projectFlocksColumns} + pageSize={tableFilterState.pageSize} + page={ + isResponseSuccess(projectFlocks) ? projectFlocks?.meta?.page : 0 + } + totalItems={ + isResponseSuccess(projectFlocks) + ? projectFlocks?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(projectFlocks) && + projectFlocks?.data?.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', + }} + /> +
    +
    + + + + ); +}; + +export default ProjectFlockTable; diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts b/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts new file mode 100644 index 00000000..2c00e6ef --- /dev/null +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts @@ -0,0 +1,57 @@ +import * as Yup from 'yup'; + +export const ProjectFlockFormSchema = Yup.object({ + name: Yup.string().required('Nama Proyek wajib diisi!'), + + // Flock + flock: Yup.object({ + value: Yup.number().required('ID Flock wajib diisi!'), + label: Yup.string().required('Nama Flock wajib diisi!'), + }).nullable(), + flock_id: Yup.number().required('Flock wajib diisi!'), + + // Area + area: Yup.object({ + value: Yup.number().required('ID Area wajib diisi!'), + label: Yup.string().required('Nama Area wajib diisi!'), + }).nullable(), + area_id: Yup.number().required('Area wajib diisi!'), + + //Product Category + product_category: Yup.object({ + value: Yup.number().required('ID Kategori Produk wajib diisi!'), + label: Yup.string().required('Nama Kategori Produk wajib diisi!'), + }).nullable(), + product_category_id: Yup.number().required('Kategori Produk wajib diisi!'), + + // FCR + fcr: Yup.object({ + value: Yup.number().required('ID FCR wajib diisi!'), + label: Yup.string().required('Nama FCR wajib diisi!'), + }).nullable(), + fcr_id: Yup.number().required('FCR wajib diisi!'), + + // Location + location: Yup.object({ + value: Yup.number().required('ID Lokasi wajib diisi!'), + label: Yup.string().required('Nama Lokasi wajib diisi!'), + }).nullable(), + location_id: Yup.number().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!') + .required('Kandang wajib diisi!'), +}); + +export type ProjectFlockFormValues = Yup.InferType< + typeof ProjectFlockFormSchema +>; + +export const UpdateProjectFlockFormSchema = ProjectFlockFormSchema; + diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx new file mode 100644 index 00000000..5ca4c9b6 --- /dev/null +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx @@ -0,0 +1,646 @@ +'use client'; + +import Button from '@/components/Button'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + AreaApi, + FcrApi, + FlockApi, + KandangApi, + LocationApi, + ProductCategoryApi, +} from '@/services/api/master-data'; +import { Icon } from '@iconify/react'; +import { useFormik } from 'formik'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import useSWR from 'swr'; +import { + ProjectFlockFormSchema, + ProjectFlockFormValues, + UpdateProjectFlockFormSchema, +} from './ProjectFlockForm.schema'; +import { + CreateProjectFlockPayload, + ProjectFlock, +} from '@/types/api/production/project-flock'; +import toast from 'react-hot-toast'; +import TextInput from '@/components/input/TextInput'; +import Table from '@/components/Table'; +import { Kandang } from '@/types/api/master-data/kandang'; +import Collapse from '@/components/Collapse'; +import { ProjectFlockApi } from '@/services/api/production'; + +interface ProjectFlockFormProps { + formType?: 'add' | 'edit' | 'detail'; + initialValues?: ProjectFlock; +} + +const ProjectFlockForm = ({ + formType = 'add', + initialValues, +}: ProjectFlockFormProps) => { + // State + const router = useRouter(); + const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] = + useState(''); + const [selectedArea, setSelectedArea] = useState(''); + + const [selectedLocation, setSelectedLocation] = useState(''); + const [disabledLocation, setDisabledLocation] = useState(true); + const [optionsLocation, setOptionsLocation] = useState([]); + + const [openSelectKandangs, setOpenSelectKandangs] = useState( + initialValues?.kandangs?.length > 0 + ); + const [optionsKandang, setOptionsKandang] = useState( + initialValues?.kandangs ?? [] + ); + + // Fetch Data + const flockUrl = `${FlockApi.basePath}?${new URLSearchParams({ + search: '', + }).toString()}`; + const { data: flocks, isLoading: isLoadingFlocks } = useSWR( + flockUrl, + FlockApi.getAllFetcher + ); + + const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ + search: '', + }).toString()}`; + const { data: areas, isLoading: isLoadingAreas } = useSWR( + areaUrl, + AreaApi.getAllFetcher + ); + + const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({ + search: '', + area_id: selectedArea, + }).toString()}`; + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationUrl, + LocationApi.getAllFetcher + ); + + const fcrUrl = `${FcrApi.basePath}?${new URLSearchParams({ + search: '', + }).toString()}`; + const { data: fcrs, isLoading: isLoadingFcrs } = useSWR( + fcrUrl, + FcrApi.getAllFetcher + ); + + const productCategoryUrl = `${ + ProductCategoryApi.basePath + }?${new URLSearchParams({ + search: '', + }).toString()}`; + const { data: productCategories, isLoading: isLoadingProductCategories } = + useSWR(productCategoryUrl, ProductCategoryApi.getAllFetcher); + + const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ + search: '', + location_id: selectedLocation == '' ? '0' : selectedLocation, + }).toString()}`; + const { data: kandang, isLoading: isLoadingKandang } = useSWR( + kandangUrl, + KandangApi.getAllFetcher + ); + + // Map Data to Options + const optionsArea = isResponseSuccess(areas) + ? areas?.data.map((area) => ({ + value: area.id, + label: area.name, + })) + : []; + const optionsFcr = isResponseSuccess(fcrs) + ? fcrs?.data.map((fcr) => ({ + value: fcr.id, + label: fcr.name, + })) + : []; + const optionsFlock = isResponseSuccess(flocks) + ? flocks?.data.map((flock) => ({ + value: flock.id, + label: flock.name, + })) + : []; + const optionsProductCategory = isResponseSuccess(productCategories) + ? productCategories?.data.map((productCategory) => ({ + value: productCategory.id, + label: productCategory.name, + })) + : []; + + useEffect(() => { + if (isResponseSuccess(locations)) { + const options = locations.data.map((location) => ({ + value: location.id, + label: location.name, + })); + setOptionsLocation(options); + } + }, [locations]); + + useEffect(() => { + if (isResponseSuccess(kandang)) { + if (kandang.data.length > 0 && selectedLocation != '') { + setOptionsKandang(kandang.data); + setOpenSelectKandangs(true); + } else { + setOptionsKandang([]); + setOpenSelectKandangs(false); + } + } + }, [kandang]); + + // Options Handler + const areaChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('area_id', true); + formik.setFieldValue('area_id', (val as OptionType)?.value); + + formik.setFieldValue('area', val); + + setSelectedArea((val as OptionType)?.value as string); + const disabled = (val as OptionType)?.value == null; + setDisabledLocation(disabled); + + formik.setFieldValue('location', null); + formik.setFieldValue('location_id', 0); + formik.setFieldTouched('location', false); + formik.setFieldTouched('location_id', false); + }; + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation((val as OptionType)?.value as string); + optionChangeHandler(val, 'location'); + formik.setFieldValue('kandang_ids', []); + }; + + const optionChangeHandler = ( + val: OptionType | OptionType[] | null, + inputName: string + ) => { + formik.setFieldValue(inputName, val); + + formik.setFieldValue( + `${inputName}_id`, + val ? (val as OptionType)?.value : 0 + ); + formik.setFieldTouched(`${inputName}_id`, true); + }; + + const kandangChangeHandler = (event: React.ChangeEvent) => { + const { value, checked } = event.target; + if (checked) { + formik.setFieldValue( + 'kandang_ids', + formik.values.kandang_ids.concat(parseInt(value)) + ); + } else { + formik.setFieldValue( + 'kandang_ids', + formik.values.kandang_ids.filter((id) => id !== parseInt(value)) + ); + } + }; + const kandangCheckAll = (event: React.ChangeEvent) => { + const { checked } = event.target; + if (checked) { + formik.setFieldValue( + 'kandang_ids', + optionsKandang.map((kandang) => kandang.id) + ); + } else { + formik.setFieldValue('kandang_ids', []); + } + }; + + // Submit Handler + const createProjectFlockHandler = async ( + payload: CreateProjectFlockPayload + ) => { + const createProjectFlockRes = await ProjectFlockApi.create(payload); + + if (isResponseSuccess(createProjectFlockRes)) { + toast.success(createProjectFlockRes?.message as string); + router.push('/production/project-flock'); + } + if (isResponseError(createProjectFlockRes)) { + setProjectFlockFormErrorMessage(createProjectFlockRes?.message as string); + // toast.ersror(createProjectFlockRes?.message as string); + } + }; + + // Formik InitialValue + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + flock: initialValues?.flock + ? { + value: initialValues.flock.id, + label: initialValues.flock.name, + } + : null, + area: initialValues?.area + ? { + value: initialValues.area.id, + label: initialValues.area.name, + } + : null, + product_category: initialValues?.product_category + ? { + value: initialValues.product_category.id, + label: initialValues.product_category.name, + } + : null, + fcr: initialValues?.fcr + ? { + value: initialValues.fcr.id, + label: initialValues.fcr.name, + } + : null, + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + flock_id: initialValues?.flock_id ?? 0, + area_id: 0, + product_category_id: 0, + fcr_id: 0, + location_id: 0, + period: initialValues?.period ?? 0, + kandang_ids: [], + }; + }, [initialValues]); + + // Formik + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, + validateOnBlur: true, + validateOnChange: true, + validateOnMount: true, + onSubmit: async (values) => { + setProjectFlockFormErrorMessage(''); + const payload: CreateProjectFlockPayload = { + name: values.name as string, + flock_id: values.flock_id as number, + area_id: values.area_id as number, + product_category_id: values.product_category_id as number, + 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[], + }; + + switch (formType) { + case 'add': + await createProjectFlockHandler(payload); + break; + case 'detail': + break; + default: + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + // Effect Initial + useEffect(() => { + console.log('Initial Value'); + console.log(initialValues); + if(formType == 'detail'){ + formik.setFieldValue('area', { + value: initialValues.area.id, + label: initialValues.area.name, + }); + formik.setFieldValue('area_id', initialValues.area_id); + setSelectedArea(initialValues.area?.id); + + formik.setFieldValue('period', initialValues.period); + } + }, [initialValues, setSelectedArea, formType]); + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + // Aktifkan lokasi jika formType = 'detail' + useEffect(() => { + if (formType === 'detail') { + setDisabledLocation(false); + } + }, [formType]); + + // Set lokasi otomatis berdasarkan initialValues saat formType = 'detail' + useEffect(() => { + if (formType === 'detail' && initialValues?.location?.id) { + setSelectedLocation(initialValues.location?.id.toString()); + setDisabledLocation(false); // biar dropdown lokasi aktif juga + } + }, [formType, initialValues]); + + // Setelah data kandang difetch, centang otomatis kandang yang ada di initialValues + useEffect(() => { + if (formType === 'detail' && isResponseSuccess(kandang)) { + setOptionsKandang(kandang.data); + setOpenSelectKandangs(true); + + // Ambil ID dari initialValues.kandangs + const kandangIds = + initialValues?.kandangs?.map((k: Kandang) => k.id) ?? []; + + // Set nilai ke formik + formik.setFieldValue('kandang_ids', kandangIds); + } + }, [formType, kandang, initialValues]); + + return ( + <> +
    +
    + + +

    + {formType === 'add' && 'Tambah Project Flock'} + {formType === 'detail' && 'Detail Project Flock'} +

    +
    + {projectFlockFormErrorMessage && ( +
    +
    + + {projectFlockFormErrorMessage} + +
    +
    + )} +
    +
    +
    +
    Informasi Umum
    + +
    + {formType != 'detail' && ( +
    + +
    + )} + + { + optionChangeHandler(val, 'flock'); + }} + options={optionsFlock} + isLoading={isLoadingFlocks} + isError={formik.touched.flock && Boolean(formik.errors.flock)} + errorMessage={formik.errors.flock as string} + isClearable + isDisabled={formType === 'detail'} + + /> + + { + optionChangeHandler(val, 'fcr'); + }} + options={optionsFcr} + isLoading={isLoadingFcrs} + isError={formik.touched.fcr && Boolean(formik.errors.fcr)} + errorMessage={formik.errors.fcr as string} + isClearable + isDisabled={formType === 'detail'} + /> + { + optionChangeHandler(val, 'product_category'); + }} + options={optionsProductCategory} + isLoading={isLoadingProductCategories} + isError={ + formik.touched.product_category && + Boolean(formik.errors.product_category) + } + errorMessage={formik.errors.product_category as string} + isClearable + isDisabled={formType === 'detail'} + + /> + +
    +
    +
    +
    +
    + +
    Pilih Kandang
    + +
    + } + className='w-full size-full' + titleClassName='w-full p-0!' + onOpenChange={setOpenSelectKandangs} + open={openSelectKandangs} + > +
    + + {/* head */} + + + + + + + + + {/* rows */} + {selectedLocation != '' && + optionsKandang.map((kandang) => ( + + + + + + ))} + {selectedLocation == '' && ( + + + + )} + + {/* foot */} + {selectedLocation != '' && ( + + + + + + + + )} +
    + + KandangPenanggung Jawab
    + + {kandang.name}{kandang.pic?.name}
    + Data tidak tersedia +
    KandangPenanggung Jawab
    +
    + +
    + + +
    + {formType !== 'detail' && ( +
    + + +
    + )} +
    +
    +
    + + ); +}; + +export default ProjectFlockForm; diff --git a/src/config/constant.ts b/src/config/constant.ts index 97e4c285..2d15c62d 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -12,6 +12,52 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ icon: 'gg:chart', }, + { + title: 'Flock', + link: '/production', + icon: 'material-symbols:raven-outline-rounded', + submenu: [ + { + title: 'List Flock', + link: '/production/project-flock', + icon: 'material-symbols:list-alt-add-outline-rounded', + }, + { + title: 'Chick In', + link: '/production/chick-in', + icon: 'mdi:home-import-outline', + }, + { + title: 'Recording', + link: '/production/recording', + icon: 'mdi:clipboard-text', + }, + ], + }, + + { + title: 'Persediaan', + link: '/inventory', + icon: 'mdi:warehouse', + submenu: [ + { + title: 'Product', + link: '/inventory/product', + icon: 'mdi:package-variant-closed', + }, + { + title: 'Penyesuaian Stok', + link: '/inventory/adjustment', + icon: 'mdi:database-edit', + }, + { + title: 'Transfer Stok', + link: '/inventory/movement', + icon: 'mdi:swap-horizontal', + }, + ], + }, + { title: 'Master Data', link: '/master-data', @@ -80,24 +126,13 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ { title: 'Flock', link: '/master-data/flock', - icon: 'material-symbols:raven-outline-rounded', + icon: 'material-symbols:raven-outline-rounded' }, ], }, - { - title: 'Persediaan', - link: '/inventory', - icon: 'material-symbols:box-outline-rounded', - submenu: [ - { - title: 'Penyesuaian Persediaan', - link: '/inventory/adjustment', - icon: 'material-symbols:box-edit-outline-rounded', - } - ] - }, ] as const; + export const ROWS_OPTIONS = [ { label: '10', diff --git a/src/services/api/production.ts b/src/services/api/production.ts new file mode 100644 index 00000000..16234161 --- /dev/null +++ b/src/services/api/production.ts @@ -0,0 +1,11 @@ +import { + ProjectFlock, + CreateProjectFlockPayload, +} from '@/types/api/production/project-flock'; +import { BaseApiService } from './base'; + +export const ProjectFlockApi = new BaseApiService< + ProjectFlock, + CreateProjectFlockPayload, + unknown +>('/production/project_flocks'); \ No newline at end of file diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts new file mode 100644 index 00000000..fd28ab91 --- /dev/null +++ b/src/types/api/production/project-flock.d.ts @@ -0,0 +1,38 @@ +import { Area } from "../master-data/area"; +import { Fcr } from "../master-data/fcr"; +import { Flock } from "../master-data/flock"; +import { Kandang } from "../master-data/kandang"; +import { Location } from "../master-data/location"; +import { ProductCategory } from "../master-data/product-category"; + +export type BaseProjectFlock = { + name: string; + flock: Flock; + flock_id: number; + area: Area; + area_id: number; + product_category: ProductCategory; + product_category_id: number; + fcr: Fcr; + fcr_id: number; + location: Location; + location_id: number; + period: number; + kandang_ids: number[]; + kandangs: Kandang[]; +} + +export type ProjectFlock = BaseMetadata & BaseProjectFlock + +export type CreateProjectFlockPayload = { + name: string; + flock_id: number; + area_id: number; + product_category_id: number; + fcr_id: number; + location_id: number; + period: number; + kandang_ids: number[]; +} + +export type UpdateProjectFlockPayload = CreateProjectFlockPayload; \ No newline at end of file From 8c662a51528b404f08cc82744600b2edc2e599f8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 09:22:49 +0700 Subject: [PATCH 058/212] refactor(FE-65): update DeliveryObjectSchema to enforce minimum delivery costs of 1 --- .../pages/inventory/movement/form/MovementForm.schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index d5d660ae..f33888e2 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -58,11 +58,11 @@ const DeliveryProductObjectSchema = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ delivery_cost: Yup.number() .required('Biaya pengiriman wajib diisi!') - .min(0, 'Biaya minimal 0!') + .min(1, 'Biaya minimal 1!') .typeError('Biaya harus berupa angka!'), delivery_cost_per_item: Yup.number() .transform((value) => (isNaN(value) ? undefined : value)) - .min(0, 'Biaya per item minimal 0!') + .min(1, 'Biaya per item minimal 1!') .typeError('Biaya per item harus berupa angka!'), document_index: Yup.number().optional(), document: Yup.mixed() From 8bf7603f6652cff01dcbb1ca292fb0e7b9eb7903 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 09:46:07 +0700 Subject: [PATCH 059/212] refactor(FE-64): update MovementTable and TableRowOptions to conditionally show edit and delete options --- .../inventory/movement/MovementTable.tsx | 6 +++-- src/components/table/TableRowOptions.tsx | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx index e0bc9541..61be40f8 100644 --- a/src/components/pages/inventory/movement/MovementTable.tsx +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -157,8 +157,9 @@ const MovementTable = () => { type='dropdown' recordId={props.row.original.id} basePath='/inventory/movement' - onDelete={deleteClickHandler} queryParam='movementId' + showEdit={false} + showDelete={false} /> )} @@ -169,8 +170,9 @@ const MovementTable = () => { type='collapse' recordId={props.row.original.id} basePath='/inventory/movement' - onDelete={deleteClickHandler} queryParam='movementId' + showEdit={false} + showDelete={false} /> )} diff --git a/src/components/table/TableRowOptions.tsx b/src/components/table/TableRowOptions.tsx index e34f2ad4..4e2e2c93 100644 --- a/src/components/table/TableRowOptions.tsx +++ b/src/components/table/TableRowOptions.tsx @@ -8,6 +8,8 @@ interface TableRowOptionsProps { basePath: string; onDelete?: () => void; queryParam?: string; + showEdit?: boolean; + showDelete?: boolean; } export const TableRowOptions = ({ @@ -16,6 +18,8 @@ export const TableRowOptions = ({ basePath, onDelete, queryParam = 'id', + showEdit = true, + showDelete = true, }: TableRowOptionsProps) => (
    Detail - - {onDelete && ( + {showEdit && ( + + )} + {showDelete && onDelete && (
    Feed NameFeed QtyFeed StockFeed Qty (Available Stock)Feed Stock (Used)Action
    - + Number(opt.value) === Number(feed.feed_id) + ) ?? null + } + onChange={(val) => { + const productWarehouseId = + (val as OptionType)?.value ?? 0; + const stock = + pakanStockMap.get( + productWarehouseId as number + ) ?? 0; + + formik.setFieldValue( + `feed_data.${idx}.feed`, + val + ); + formik.setFieldValue( + `feed_data.${idx}.feed_id`, + productWarehouseId + ); + formik.setFieldValue( + `feed_data.${idx}.feed_qty`, + stock + ); + }} + options={pakanOptions} + isLoading={false} isError={ - isRepeaterInputError( - 'feed_data', - 'feed_name', - idx - ).isError + isRepeaterInputError('feed_data', 'feed_id', idx) + .isError } errorMessage={ - isRepeaterInputError( - 'feed_data', - 'feed_name', - idx - ).errorMessage + isRepeaterInputError('feed_data', 'feed_id', idx) + .errorMessage } - readOnly={type === 'detail'} + isDisabled={type === 'detail'} + isClearable className={{ wrapper: 'w-full min-w-24', }} @@ -542,7 +582,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isRepeaterInputError('feed_data', 'feed_qty', idx) .errorMessage } - readOnly={type === 'detail'} + readOnly={true} className={{ wrapper: 'w-full min-w-24', }} @@ -847,7 +887,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} Vaccine NameTotal StockTotal Stock (Available Stock) Used StockAction
    - + Number(opt.value) === + Number(vaccine.vaccine_id) + ) ?? null + } + onChange={(val) => { + const productWarehouseId = + (val as OptionType)?.value ?? 0; + const stock = + ovkStockMap.get(productWarehouseId as number) ?? + 0; + + formik.setFieldValue( + `vaccination.${idx}.vaccine`, + val + ); + formik.setFieldValue( + `vaccination.${idx}.vaccine_id`, + productWarehouseId + ); + formik.setFieldValue( + `vaccination.${idx}.total_stock`, + stock + ); + }} + options={ovkOptions} + isLoading={false} isError={ isRepeaterInputError( 'vaccination', - 'vaccine_name', + 'vaccine_id', idx ).isError } errorMessage={ isRepeaterInputError( 'vaccination', - 'vaccine_name', + 'vaccine_id', idx ).errorMessage } - readOnly={type === 'detail'} + isDisabled={type === 'detail'} + isClearable className={{ wrapper: 'w-full min-w-24', }} @@ -922,7 +988,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { idx ).errorMessage } - readOnly={type === 'detail'} + readOnly={true} className={{ wrapper: 'w-full min-w-24', }} diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 58fed128..d4bf90ee 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -38,7 +38,7 @@ export type CreateRecordingPayload = { location_id: number; coop_id: number; feed_data: { - feed_name: string; + feed_id: string; feed_qty: number; feed_stock: number; }[]; @@ -48,7 +48,7 @@ export type CreateRecordingPayload = { average_chicken_weight: number; }[]; vaccination: { - vaccine_name: string; + vaccine_id: string; total_stock: number; used_stock: number; }[]; From 7abe9b7dc6ac11f806dd5a8aeebd42f1acec643e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 16:48:35 +0700 Subject: [PATCH 064/212] refactor(FE-63,65): update Movement types and schema to include area and location for warehouses --- .../movement/form/MovementForm.schema.ts | 42 ++++++++++++------- src/types/api/inventory/movement.d.ts | 24 ++++++++--- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index f33888e2..11404782 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -92,6 +92,8 @@ export const MovementFormSchema = Yup.object({ source_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), }).nullable(), source_warehouse_id: Yup.number() .required('Gudang asal wajib diisi!') @@ -99,6 +101,8 @@ export const MovementFormSchema = Yup.object({ destination_warehouse: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), }).nullable(), destination_warehouse_id: Yup.number() .required('Gudang tujuan wajib diisi!') @@ -120,9 +124,12 @@ export type MovementFormValues = Yup.InferType; export const getMovementFormInitialValues = ( initialValues?: Movement ): MovementFormValues => { - const detailIdToProductId = new Map(); + const detailIdToProductId = new Map(); initialValues?.details?.forEach((detail) => { - detailIdToProductId.set(detail.id, detail.product_id); + detailIdToProductId.set(detail.id, { + id: detail.product.id, + name: detail.product.name, + }); }); return { @@ -132,6 +139,8 @@ export const getMovementFormInitialValues = ( ? { value: initialValues.source_warehouse.id, label: initialValues.source_warehouse.name, + area: initialValues.source_warehouse.area?.name, + location: initialValues.source_warehouse.location?.name, } : null, source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, @@ -139,14 +148,19 @@ export const getMovementFormInitialValues = ( ? { value: initialValues.destination_warehouse.id, label: initialValues.destination_warehouse.name, + area: initialValues.destination_warehouse.area?.name, + location: initialValues.destination_warehouse.location?.name, } : null, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, products: - initialValues?.details?.map((p) => ({ - product: { value: p.product_id, label: `Product ID: ${p.product_id}` }, - product_id: p.product_id, - product_qty: p.quantity, + 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) => { @@ -160,16 +174,16 @@ export const getMovementFormInitialValues = ( supplier: d.supplier ? { value: d.supplier.id, label: d.supplier.name } : null, - supplier_id: d.supplier_id, + supplier_id: d.supplier?.id ?? 0, products: d.items.map((item) => { - const productId = - detailIdToProductId.get(item.stock_transfer_detail_id) ?? 0; + const productData = detailIdToProductId.get( + item.stock_transfer_detail_id + ); return { - product: - productId > 0 - ? { value: productId, label: `Product ID: ${productId}` } - : null, - product_id: productId, + product: productData + ? { value: productData.id, label: productData.name } + : null, + product_id: productData?.id ?? 0, product_qty: item.quantity, }; }), diff --git a/src/types/api/inventory/movement.d.ts b/src/types/api/inventory/movement.d.ts index 9e156a1e..87a03f95 100644 --- a/src/types/api/inventory/movement.d.ts +++ b/src/types/api/inventory/movement.d.ts @@ -1,23 +1,37 @@ import { BaseMetadata } from '@/types/api/api-general'; import { Supplier } from '@/types/api/master-data/supplier'; -import { Warehouse } from '@/types/api/master-data/warehouse'; + +type MovementWarehouse = { + id: number; + name: string; + location: { + id: number; + name: string; + } | null; + area: { + id: number; + name: string; + }; +}; export type BaseMovement = { id: number; transfer_reason: string; transfer_date: string; - source_warehouse: Warehouse; - destination_warehouse: Warehouse; + source_warehouse: MovementWarehouse; + destination_warehouse: MovementWarehouse; details: { id: number; - product_id: number; + product: { + id: number; + name: string; + }; quantity: number; before_quantity: number; after_quantity: number; }[]; deliveries: { id: number; - supplier_id: number; supplier: Supplier; vehicle_plate: string; driver_name: string; From edb5f30d6cc673e47479c342aea2cf2866b44c64 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 16:48:52 +0700 Subject: [PATCH 065/212] refactor(FE-62): remove unused product fetching logic from MovementForm --- .../inventory/movement/form/MovementForm.tsx | 171 ------------------ 1 file changed, 171 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 3ff448f8..d29ae851 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -28,7 +28,6 @@ import { useMovementFormHandlers } from './useMovementFormHandlers'; import { SupplierApi, WarehouseApi, - ProductApi, } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; @@ -47,9 +46,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ] = useState(''); const [selectedProducts, setSelectedProducts] = useState([]); const [selectedDeliveries, setSelectedDeliveries] = useState([]); - const [fetchedProductIds, setFetchedProductIds] = useState>( - new Set() - ); const { deleteModal, @@ -355,173 +351,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } }, [formik.values.source_warehouse_id]); - // Effect to populate product labels from ProductWarehouse data - useEffect(() => { - if (!productWarehouses || !isResponseSuccess(productWarehouses)) return; - if (type !== 'edit' && type !== 'detail') return; - - let hasUpdates = false; - const updatedProducts = formik.values.products?.map((product) => { - if (product.product && product.product.label.startsWith('Product ID:')) { - const productWarehouse = productWarehouses.data.find( - (pw) => pw.product.id === product.product_id - ); - if (productWarehouse) { - hasUpdates = true; - return { - ...product, - product: { - value: productWarehouse.product.id, - label: productWarehouse.product.name, - }, - }; - } - } - return product; - }); - - if (hasUpdates && updatedProducts) { - formik.setFieldValue('products', updatedProducts); - - const updatedDeliveries = formik.values.deliveries?.map((delivery) => { - const updatedDeliveryProducts = delivery.products.map( - (deliveryProduct) => { - if ( - deliveryProduct.product && - deliveryProduct.product.label.startsWith('Product ID:') - ) { - const productWarehouse = productWarehouses.data.find( - (pw) => pw.product.id === deliveryProduct.product_id - ); - if (productWarehouse) { - return { - ...deliveryProduct, - product: { - value: productWarehouse.product.id, - label: productWarehouse.product.name, - }, - }; - } - } - return deliveryProduct; - } - ); - return { - ...delivery, - products: updatedDeliveryProducts, - }; - }); - formik.setFieldValue('deliveries', updatedDeliveries); - } - }, [productWarehouses, type]); - - useEffect(() => { - if (type !== 'edit' && type !== 'detail') return; - - const productIdsToFetch: number[] = []; - - formik.values.products?.forEach((product) => { - if ( - product.product && - product.product.label.startsWith('Product ID:') && - product.product_id > 0 && - !fetchedProductIds.has(product.product_id) - ) { - productIdsToFetch.push(product.product_id); - } - }); - - formik.values.deliveries?.forEach((delivery) => { - delivery.products.forEach((deliveryProduct) => { - if ( - deliveryProduct.product && - deliveryProduct.product.label.startsWith('Product ID:') && - deliveryProduct.product_id > 0 && - !fetchedProductIds.has(deliveryProduct.product_id) - ) { - if (!productIdsToFetch.includes(deliveryProduct.product_id)) { - productIdsToFetch.push(deliveryProduct.product_id); - } - } - }); - }); - - if (productIdsToFetch.length === 0) return; - - const fetchProducts = async () => { - const productMap = new Map(); - const newFetchedIds = new Set(fetchedProductIds); - - for (const productId of productIdsToFetch) { - try { - const response = await ProductApi.getSingle(productId); - if (isResponseSuccess(response)) { - const product = response.data; - productMap.set(product.id, { id: product.id, name: product.name }); - newFetchedIds.add(productId); - } - } catch (error) { - console.error(`Failed to fetch product ${productId}:`, error); - newFetchedIds.add(productId); - } - } - - if (productMap.size > 0) { - const updatedProducts = formik.values.products?.map((p) => { - const productData = productMap.get(p.product_id); - if (productData) { - return { - ...p, - product: { - value: productData.id, - label: productData.name, - }, - }; - } - return p; - }); - - const updatedDeliveries = formik.values.deliveries?.map((delivery) => { - const updatedDeliveryProducts = delivery.products.map( - (deliveryProduct) => { - const productData = productMap.get(deliveryProduct.product_id); - if (productData) { - return { - ...deliveryProduct, - product: { - value: productData.id, - label: productData.name, - }, - }; - } - return deliveryProduct; - } - ); - return { - ...delivery, - products: updatedDeliveryProducts, - }; - }); - - if (updatedProducts) { - formik.setFieldValue('products', updatedProducts); - } - if (updatedDeliveries) { - formik.setFieldValue('deliveries', updatedDeliveries); - } - } - - setFetchedProductIds(newFetchedIds); - }; - - fetchProducts(); - }, [ - formik.values.products, - formik.values.deliveries, - type, - fetchedProductIds, - ]); - const getFilteredProductWarehouseOptions = useCallback(() => { return ( formik.values.products From f05d367a5da1a1d4e94365ae7c6aa7c668e0bc46 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 18:51:35 +0700 Subject: [PATCH 066/212] refactor(FE-65): enhance delivery cost validation and calculation in MovementForm --- .../movement/form/MovementForm.schema.ts | 32 ++++- .../inventory/movement/form/MovementForm.tsx | 110 ++++++++++++++---- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts index 11404782..2cc5d910 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.schema.ts +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -11,7 +11,7 @@ export type ProductSchema = { }; export type DeliverySchema = { - delivery_cost: number; + delivery_cost?: number | undefined; delivery_cost_per_item?: number | undefined; document?: File | string | null; driver_name: string; @@ -57,13 +57,35 @@ const DeliveryProductObjectSchema = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ delivery_cost: Yup.number() - .required('Biaya pengiriman wajib diisi!') + .transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .min(1, 'Biaya minimal 1!') - .typeError('Biaya harus berupa angka!'), + .typeError('Biaya harus berupa angka!') + .test( + 'one-of-cost-fields', + 'Biaya pengiriman atau biaya per item wajib diisi!', + function (value) { + const { delivery_cost_per_item } = this.parent; + return ( + (value !== undefined && value > 0) || + (delivery_cost_per_item !== undefined && delivery_cost_per_item > 0) + ); + } + ), delivery_cost_per_item: Yup.number() - .transform((value) => (isNaN(value) ? undefined : value)) + .transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .min(1, 'Biaya per item minimal 1!') - .typeError('Biaya per item harus berupa angka!'), + .typeError('Biaya per item harus berupa angka!') + .test( + 'one-of-cost-fields', + 'Biaya pengiriman atau biaya per item wajib diisi!', + function (value) { + const { delivery_cost } = this.parent; + return ( + (value !== undefined && value > 0) || + (delivery_cost !== undefined && delivery_cost > 0) + ); + } + ), document_index: Yup.number().optional(), document: Yup.mixed() .nullable() diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index d29ae851..2ec06e66 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -83,7 +83,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } return { - delivery_cost: d.delivery_cost, + delivery_cost: d.delivery_cost ?? 0, delivery_cost_per_item: d.delivery_cost_per_item ?? 0, document_index: documentIndex, driver_name: d.driver_name, @@ -163,8 +163,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { formik.setFieldValue('deliveries', [ ...(formik.values.deliveries || []), { - delivery_cost: 0, - delivery_cost_per_item: 0, + delivery_cost: undefined, + delivery_cost_per_item: undefined, document: null, driver_name: '', vehicle_plate: '', @@ -324,21 +324,86 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) : []; + // Handle cost calculation when delivery_cost changes + const handleDeliveryCostChange = useCallback( + (idx: number, value: string) => { + const numValue = parseFloat(value) || 0; + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, numValue); + + const delivery = formik.values.deliveries?.[idx]; + if (delivery) { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty > 0 && numValue > 0) { + const perItem = numValue / productQty; + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } else if (numValue === 0) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0); + } + } + }, + [formik] + ); + + // Handle cost calculation when delivery_cost_per_item changes + const handleDeliveryCostPerItemChange = useCallback( + (idx: number, value: string) => { + const numValue = parseFloat(value) || 0; + formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, numValue); + + const delivery = formik.values.deliveries?.[idx]; + if (delivery) { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty > 0 && numValue > 0) { + const totalCost = numValue * productQty; + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } else if (numValue === 0) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0); + } + } + }, + [formik] + ); + + // Auto-recalculate when product quantity changes useEffect(() => { formik.values.deliveries?.forEach((delivery, idx) => { const productQty = delivery.products.reduce( (sum, p) => sum + p.product_qty, 0 ); - if (productQty && delivery.delivery_cost) { + + // If delivery_cost is set, recalculate delivery_cost_per_item + if (delivery.delivery_cost && delivery.delivery_cost > 0 && productQty > 0) { const perItem = delivery.delivery_cost / productQty; - formik.setFieldValue( - `deliveries.${idx}.delivery_cost_per_item`, - perItem - ); + if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } + } + // If delivery_cost_per_item is set, recalculate delivery_cost + else if ( + delivery.delivery_cost_per_item && + delivery.delivery_cost_per_item > 0 && + productQty > 0 + ) { + const totalCost = delivery.delivery_cost_per_item * productQty; + if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } } }); - }, [formik.values.deliveries]); + }, [formik.values.deliveries?.map(d => d.products.reduce((sum, p) => sum + p.product_qty, 0)).join(',')]); useEffect(() => { if ( @@ -1039,8 +1104,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { required type='number' name={`deliveries.${idx}.delivery_cost`} - value={delivery.delivery_cost} - onChange={formik.handleChange} + value={delivery.delivery_cost || ''} + onChange={(e) => + handleDeliveryCostChange(idx, e.target.value) + } onBlur={formik.handleBlur} {...isRepeaterInputError( 'deliveries', @@ -1052,17 +1119,20 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + handleDeliveryCostPerItemChange(idx, e.target.value) } - readOnly - className={{ - input: 'bg-base-200', - }} + onBlur={formik.handleBlur} + {...isRepeaterInputError( + 'deliveries', + 'delivery_cost_per_item', + idx + )} + readOnly={type === 'detail'} /> From 0676411dd5d65344e5340ef747889d04e5f11327 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 19:23:19 +0700 Subject: [PATCH 067/212] refactor(FE-62,65): enhance product quantity display and stock information in MovementForm --- .../inventory/movement/form/MovementForm.tsx | 109 +++++++++++------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index 2ec06e66..d77f2de4 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -266,6 +266,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { location?: string; } + interface ProductWarehouseOptionType extends OptionType { + product_id: number; + warehouse_id: number; + warehouse_name: string; + quantity: number; + } + // Warehouse selection const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState(''); @@ -304,8 +311,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); const productWarehouseOptions = isResponseSuccess(productWarehouses) ? productWarehouses?.data.map((pw) => ({ - value: pw.id, - label: pw.product.name, + value: pw.product.id, + label: `${pw.product.name} - ${pw.warehouse.name} (Stock: ${pw.quantity.toLocaleString('id-ID')})`, product_id: pw.product.id, warehouse_id: pw.warehouse.id, warehouse_name: pw.warehouse.name, @@ -438,6 +445,33 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { [productWarehouseOptions, type] ); + const getProductQtyAdornment = useCallback( + (productIdx: number) => { + if (type === 'detail') return null; + const product = formik.values.products?.[productIdx]; + if (!product || !product.product_id) return null; + + const availableStock = getAvailableStock(product.product_id); + const requestedQty = Number(product.product_qty) || 0; + const remainingStock = availableStock - requestedQty; + + if (requestedQty > 0) { + return ( + + (sisa: {remainingStock.toLocaleString('id-ID')}) + + ); + } + + return ( + + (tersedia: {availableStock.toLocaleString('id-ID')}) + + ); + }, + [formik.values.products, getAvailableStock, type] + ); + const getProductQtyError = useCallback( (productIdx: number) => { if (type === 'detail') return null; @@ -813,7 +847,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); formik.setFieldValue( `products.${idx}.product_id`, - (val as OptionType)?.value + (val as ProductWarehouseOptionType)?.value ); }} options={productWarehouseOptions} @@ -837,46 +871,35 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { /> -
    - - {type !== 'detail' && product.product_id && ( -
    - - Stok tersedia: - {' '} - {getAvailableStock( - product.product_id - ).toLocaleString('id-ID')} -
    - )} -
    +
    From c4de085e111f4f3abcd41916ff2bc3a48a1b0d8e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 18 Oct 2025 08:40:06 +0700 Subject: [PATCH 068/212] feat(FE-62,63): enhance warehouse stock information display in MovementForm --- .../inventory/movement/form/MovementForm.tsx | 82 +++++++++++++++---- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx index d77f2de4..a35937f7 100644 --- a/src/components/pages/inventory/movement/form/MovementForm.tsx +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -25,10 +25,7 @@ import { DeliverySchema, } from '@/components/pages/inventory/movement/form/MovementForm.schema'; import { useMovementFormHandlers } from './useMovementFormHandlers'; -import { - SupplierApi, - WarehouseApi, -} from '@/services/api/master-data'; +import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; import { toast } from 'react-hot-toast'; import FileInput from '@/components/input/FileInput'; @@ -273,6 +270,36 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { quantity: number; } + const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`; + const { data: allProductWarehouses } = useSWR( + allProductWarehousesUrl, + ProductWarehouseApi.getAllFetcher + ); + + const warehouseStockMap = useMemo(() => { + if (!isResponseSuccess(allProductWarehouses)) return new Map(); + + const stockMap = new Map< + number, + { totalQty: number; productCount: number } + >(); + + allProductWarehouses.data.forEach((pw) => { + const warehouseId = pw.warehouse.id; + const existing = stockMap.get(warehouseId) || { + totalQty: 0, + productCount: 0, + }; + + stockMap.set(warehouseId, { + totalQty: existing.totalQty + pw.quantity, + productCount: existing.productCount + 1, + }); + }); + + return stockMap; + }, [allProductWarehouses]); + // Warehouse selection const [warehouseSelectInputValue, setWarehouseSelectInputValue] = useState(''); @@ -282,15 +309,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { WarehouseApi.getAllFetcher ); const warehouseOptions = isResponseSuccess(warehouses) - ? warehouses?.data.map((w) => ({ - value: w.id, - label: w.name, - area: w.area?.name, - location: - 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') - ? w.location?.name - : undefined, - })) + ? warehouses?.data.map((w) => { + const stockInfo = warehouseStockMap.get(w.id); + const stockLabel = stockInfo + ? ` (Stock: ${stockInfo.totalQty.toLocaleString('id-ID')} items, ${stockInfo.productCount} produk)` + : ' (Kosong)'; + + return { + value: w.id, + label: `${w.name}${stockLabel}`, + area: w.area?.name, + location: + 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') + ? w.location?.name + : undefined, + }; + }) : []; // Product Warehouse selection - Filter by source warehouse @@ -361,7 +395,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const handleDeliveryCostPerItemChange = useCallback( (idx: number, value: string) => { const numValue = parseFloat(value) || 0; - formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, numValue); + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + numValue + ); const delivery = formik.values.deliveries?.[idx]; if (delivery) { @@ -389,7 +426,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { ); // If delivery_cost is set, recalculate delivery_cost_per_item - if (delivery.delivery_cost && delivery.delivery_cost > 0 && productQty > 0) { + if ( + delivery.delivery_cost && + delivery.delivery_cost > 0 && + productQty > 0 + ) { const perItem = delivery.delivery_cost / productQty; if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { formik.setFieldValue( @@ -410,7 +451,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { } } }); - }, [formik.values.deliveries?.map(d => d.products.reduce((sum, p) => sum + p.product_qty, 0)).join(',')]); + }, [ + formik.values.deliveries + ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) + .join(','), + ]); useEffect(() => { if ( @@ -1147,7 +1192,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { name={`deliveries.${idx}.delivery_cost_per_item`} value={delivery.delivery_cost_per_item || ''} onChange={(e) => - handleDeliveryCostPerItemChange(idx, e.target.value) + handleDeliveryCostPerItemChange( + idx, + e.target.value + ) } onBlur={formik.handleBlur} {...isRepeaterInputError( From 881e2bfc4ad37af1055f66b5d6c3f812d313ece7 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 18 Oct 2025 09:04:39 +0700 Subject: [PATCH 069/212] feat(FE-114,136): enhance product label display in RecordingForm with warehouse and stock information --- src/components/pages/flock/recording/form/RecordingForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index e2bd0d94..65c8073b 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -125,7 +125,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const pakanOptions = isResponseSuccess(pakanProducts) ? pakanProducts?.data.map((product) => ({ value: product.id, - label: product.product.name, + label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, })) : []; @@ -148,7 +148,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const ovkOptions = isResponseSuccess(ovkProducts) ? ovkProducts?.data.map((product) => ({ value: product.id, - label: product.product.name, + label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, })) : []; From a573551110ca3a69c0d9dfbaf17513a2f3c2e8ca Mon Sep 17 00:00:00 2001 From: randy-ar Date: Sat, 18 Oct 2025 10:46:47 +0700 Subject: [PATCH 070/212] feat(FE-85-87-88): slicing ui and integrate api for search and edit --- .../project-flock/detail/edit/page.tsx | 46 +++++ src/components/input/SelectInput.tsx | 27 +-- .../form/InventoryAdjustmentForm.tsx | 2 +- .../customer/form/CustomerForm.tsx | 2 +- .../master-data/flock/form/FlockForm.tsx | 2 +- .../supplier/form/SupplierForm.tsx | 2 +- .../project-flock/ProjectFlockTable.tsx | 41 +++- .../form/ProjectFlockForm.schema.ts | 22 ++- .../project-flock/form/ProjectFlockForm.tsx | 179 ++++++++++++------ src/config/constant.ts | 4 +- src/services/api/base.ts | 36 ++++ src/services/api/inventory.ts | 2 +- src/services/api/production.ts | 2 +- src/stores/ui/ui.store.ts | 2 +- src/types/api/inventory/adjustment.d.ts | 2 +- src/types/api/master-data/flock.d.ts | 2 +- src/types/api/master-data/kandang.d.ts | 1 + src/types/api/production/project-flock.d.ts | 19 +- 18 files changed, 281 insertions(+), 112 deletions(-) diff --git a/src/app/production/project-flock/detail/edit/page.tsx b/src/app/production/project-flock/detail/edit/page.tsx index e69de29b..858d0ca8 100644 --- a/src/app/production/project-flock/detail/edit/page.tsx +++ b/src/app/production/project-flock/detail/edit/page.tsx @@ -0,0 +1,46 @@ +'use client' + + +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { ProjectFlockApi } from "@/services/api/production"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const ProjectFlockEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const projectFlockId = searchParams.get("projectFlockId"); + + const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( + projectFlockId, + (id: number) => ProjectFlockApi.getSingle(id) + ); + + if(!projectFlockId){ + router.back(); + + return ( +
    + +
    + ); + } + + if(!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))){ + router.replace("/404"); + return; + } + + return ( +
    + {isLoadingCostumer && } + {!isLoadingCostumer && isResponseSuccess(projectFlock) && ( + + )} +
    + ) +} + +export default ProjectFlockEdit; \ No newline at end of file diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 43a3f622..b35ad7dd 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - ComponentType, - ReactNode, - useEffect, - useMemo, - useState, -} from 'react'; +import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; import Select, { OptionProps, GroupBase, @@ -98,10 +92,7 @@ const SelectInput = (props: SelectInputProps) => { return { ...base, IndicatorSeparator: () => null }; }, [isAnimated]); - const internalInputChangeHandler = ( - val: string, - meta: InputActionMeta - ) => { + const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'menu-close') setInternalInputValue(''); }; @@ -113,9 +104,7 @@ const SelectInput = (props: SelectInputProps) => { const SelectComponent = createables ? CreatableSelect : Select; /** 🎯 handleChange tanpa any */ - const handleChange = ( - val: MultiValue | SingleValue - ): void => { + const handleChange = (val: MultiValue | SingleValue | null): void => { if (!val) { onChange?.(null); return; @@ -145,15 +134,15 @@ const SelectInput = (props: SelectInputProps) => { > {label} {required && ( - - * + + * )} )} > - instanceId="select" + instanceId='select' value={value ?? (isMulti ? [] : null)} onChange={handleChange} options={options} @@ -225,9 +214,9 @@ const SelectInput = (props: SelectInputProps) => { }} /> - {isError &&

    {errorMessage}

    } + {isError &&

    {errorMessage}

    } {!isError && bottomLabel && ( -

    {bottomLabel}

    +

    {bottomLabel}

    )} ); diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx index 1bb1692d..9a19ced1 100644 --- a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -13,7 +13,7 @@ import toast from 'react-hot-toast'; import { InventoryAdjustmentFormSchema, InventoryAdjustmentFormValues, -} from './InventoryAdjustmentForm.schema'; +} from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema'; import useSWR from 'swr'; import { ProductApi, diff --git a/src/components/pages/master-data/customer/form/CustomerForm.tsx b/src/components/pages/master-data/customer/form/CustomerForm.tsx index 533e0c38..ac848834 100644 --- a/src/components/pages/master-data/customer/form/CustomerForm.tsx +++ b/src/components/pages/master-data/customer/form/CustomerForm.tsx @@ -11,7 +11,7 @@ import { import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; -import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from './CustomerForm.schema'; +import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from '@/components/pages/master-data/customer/form/CustomerForm.schema'; import { useFormik } from 'formik'; import Button from '@/components/Button'; import { Icon } from '@iconify/react'; diff --git a/src/components/pages/master-data/flock/form/FlockForm.tsx b/src/components/pages/master-data/flock/form/FlockForm.tsx index 0950eef1..cc227fa6 100644 --- a/src/components/pages/master-data/flock/form/FlockForm.tsx +++ b/src/components/pages/master-data/flock/form/FlockForm.tsx @@ -5,7 +5,7 @@ import { FlockApi } from '@/services/api/master-data'; import { Flock } from '@/types/api/master-data/flock'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -import { FlockFormSchema, FlockFormValues, UpdateFlockFormSchema } from './FlockForm.schema'; +import { FlockFormSchema, FlockFormValues, UpdateFlockFormSchema } from '@/components/pages/master-data/flock/form/FlockForm.schema'; import { useFormik } from 'formik'; import Button from '@/components/Button'; import { Icon } from '@iconify/react'; diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.tsx b/src/components/pages/master-data/supplier/form/SupplierForm.tsx index 74c4da27..e400ead2 100644 --- a/src/components/pages/master-data/supplier/form/SupplierForm.tsx +++ b/src/components/pages/master-data/supplier/form/SupplierForm.tsx @@ -15,7 +15,7 @@ import { SupplierFormSchema, SupplierFormValues, UpdateSupplierFormSchema, -} from './SupplierForm.schema'; +} from '@/components/pages/master-data/supplier/form/SupplierForm.schema'; import { useFormik } from 'formik'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import { Icon } from '@iconify/react'; diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index f890873d..c8b3f89b 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -1,6 +1,7 @@ 'use client'; import Button from '@/components/Button'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -21,7 +22,7 @@ import { ColumnSort, SortingState, } from '@tanstack/react-table'; -import { useCallback, useEffect, useState } from 'react'; +import { ChangeEventHandler, useCallback, useEffect, useState } from 'react'; import toast from 'react-hot-toast'; import useSWR from 'swr'; @@ -54,6 +55,15 @@ const RowOptionsMenu = ({ Detail + +
    + +
    -
    +
    ; export const UpdateProjectFlockFormSchema = ProjectFlockFormSchema; - diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx index 5ca4c9b6..903e3105 100644 --- a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx @@ -20,17 +20,20 @@ import { ProjectFlockFormSchema, ProjectFlockFormValues, UpdateProjectFlockFormSchema, -} from './ProjectFlockForm.schema'; +} from '@/components/pages/production/project-flock/form/ProjectFlockForm.schema'; import { CreateProjectFlockPayload, + PeriodFlock, ProjectFlock, } from '@/types/api/production/project-flock'; import toast from 'react-hot-toast'; import TextInput from '@/components/input/TextInput'; -import Table from '@/components/Table'; import { Kandang } from '@/types/api/master-data/kandang'; import Collapse from '@/components/Collapse'; import { ProjectFlockApi } from '@/services/api/production'; +import { httpClient } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import axios from 'axios'; interface ProjectFlockFormProps { formType?: 'add' | 'edit' | 'detail'; @@ -58,6 +61,10 @@ const ProjectFlockForm = ({ initialValues?.kandangs ?? [] ); + const [selectedFlock, setSelectedFlock] = useState( + initialValues?.flock?.id ?? 0 + ); + // Fetch Data const flockUrl = `${FlockApi.basePath}?${new URLSearchParams({ search: '', @@ -109,6 +116,17 @@ const ProjectFlockForm = ({ KandangApi.getAllFetcher ); + const getPeriodFlocksUrl = `flocks/${selectedFlock}/periods`; + + const { data: periodFlocks, isLoading: isLoadingPeriodFlocks } = useSWR( + getPeriodFlocksUrl, + () => + ProjectFlockApi.customRequest, 'GET'>( + getPeriodFlocksUrl, + { method: 'GET' } + ) + ); + // Map Data to Options const optionsArea = isResponseSuccess(areas) ? areas?.data.map((area) => ({ @@ -147,24 +165,26 @@ const ProjectFlockForm = ({ useEffect(() => { if (isResponseSuccess(kandang)) { - if (kandang.data.length > 0 && selectedLocation != '') { + if (selectedLocation) { setOptionsKandang(kandang.data); setOpenSelectKandangs(true); } else { setOptionsKandang([]); setOpenSelectKandangs(false); + formik.setFieldValue('kandang_ids', []); } } }, [kandang]); // Options Handler const areaChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('area_id', true); formik.setFieldValue('area_id', (val as OptionType)?.value); - formik.setFieldValue('area', val); + formik.setFieldTouched('area_id', true); + setSelectedArea((val as OptionType)?.value as string); + setSelectedLocation(''); const disabled = (val as OptionType)?.value == null; setDisabledLocation(disabled); @@ -185,11 +205,11 @@ const ProjectFlockForm = ({ inputName: string ) => { formik.setFieldValue(inputName, val); - formik.setFieldValue( `${inputName}_id`, val ? (val as OptionType)?.value : 0 ); + formik.setFieldTouched(`${inputName}_id`, true); }; @@ -212,7 +232,9 @@ const ProjectFlockForm = ({ if (checked) { formik.setFieldValue( 'kandang_ids', - optionsKandang.map((kandang) => kandang.id) + optionsKandang + .filter((kandang) => kandang.status === 'NON_ACTIVE') + .map((kandang) => kandang.id) ); } else { formik.setFieldValue('kandang_ids', []); @@ -231,7 +253,24 @@ const ProjectFlockForm = ({ } if (isResponseError(createProjectFlockRes)) { setProjectFlockFormErrorMessage(createProjectFlockRes?.message as string); - // toast.ersror(createProjectFlockRes?.message as string); + toast.error(createProjectFlockRes?.message as string); + } + }; + const updateProjectFlockHandler = async ( + payload: CreateProjectFlockPayload + ) => { + const updateProjectFlockRes = await ProjectFlockApi.update( + initialValues?.id as number, + payload + ); + + if (isResponseSuccess(updateProjectFlockRes)) { + toast.success(updateProjectFlockRes?.message as string); + router.push('/production/project-flock'); + } + if (isResponseError(updateProjectFlockRes)) { + setProjectFlockFormErrorMessage(updateProjectFlockRes?.message as string); + toast.error(updateProjectFlockRes?.message as string); } }; @@ -269,12 +308,12 @@ const ProjectFlockForm = ({ label: initialValues.location.name, } : null, - flock_id: initialValues?.flock_id ?? 0, - area_id: 0, - product_category_id: 0, - fcr_id: 0, - location_id: 0, - period: initialValues?.period ?? 0, + flock_id: initialValues?.flock?.id ?? 0, + area_id: initialValues?.area?.id ?? 0, + product_category_id: initialValues?.product_category?.id ?? 0, + fcr_id: initialValues?.fcr?.id ?? 0, + location_id: initialValues?.location?.id ?? 0, + period: initialValues?.period ?? '', kandang_ids: [], }; }, [initialValues]); @@ -282,6 +321,7 @@ const ProjectFlockForm = ({ // Formik const formik = useFormik({ initialValues: formikInitialValues, + enableReinitialize: true, validationSchema: formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, validateOnBlur: true, @@ -290,7 +330,6 @@ const ProjectFlockForm = ({ onSubmit: async (values) => { setProjectFlockFormErrorMessage(''); const payload: CreateProjectFlockPayload = { - name: values.name as string, flock_id: values.flock_id as number, area_id: values.area_id as number, product_category_id: values.product_category_id as number, @@ -304,7 +343,8 @@ const ProjectFlockForm = ({ case 'add': await createProjectFlockHandler(payload); break; - case 'detail': + case 'edit': + await updateProjectFlockHandler(payload); break; default: break; @@ -315,16 +355,14 @@ const ProjectFlockForm = ({ const { setValues: formikSetValues } = formik; // Effect Initial useEffect(() => { - console.log('Initial Value'); - console.log(initialValues); - if(formType == 'detail'){ + if (formType == 'detail') { formik.setFieldValue('area', { value: initialValues.area.id, label: initialValues.area.name, }); formik.setFieldValue('area_id', initialValues.area_id); setSelectedArea(initialValues.area?.id); - + formik.setFieldValue('period', initialValues.period); } }, [initialValues, setSelectedArea, formType]); @@ -340,7 +378,7 @@ const ProjectFlockForm = ({ // Set lokasi otomatis berdasarkan initialValues saat formType = 'detail' useEffect(() => { - if (formType === 'detail' && initialValues?.location?.id) { + if (formType != 'add' && initialValues?.location?.id) { setSelectedLocation(initialValues.location?.id.toString()); setDisabledLocation(false); // biar dropdown lokasi aktif juga } @@ -348,19 +386,34 @@ const ProjectFlockForm = ({ // Setelah data kandang difetch, centang otomatis kandang yang ada di initialValues useEffect(() => { - if (formType === 'detail' && isResponseSuccess(kandang)) { - setOptionsKandang(kandang.data); - setOpenSelectKandangs(true); - - // Ambil ID dari initialValues.kandangs - const kandangIds = - initialValues?.kandangs?.map((k: Kandang) => k.id) ?? []; - - // Set nilai ke formik - formik.setFieldValue('kandang_ids', kandangIds); + if (formType != 'add' && isResponseSuccess(kandang)) { + if (selectedLocation) { + setOptionsKandang(kandang.data); + setOpenSelectKandangs(true); + const kandangIds = + initialValues?.kandangs?.map((k: Kandang) => k.id) ?? []; + formik.setFieldValue('kandang_ids', kandangIds); + console.log("kandangIds"); + console.log(kandangIds); + } else { + setOptionsKandang([]); + setOpenSelectKandangs(false); + formik.setFieldValue('kandang_ids', []); + initialValues.kandangs = []; + } } }, [formType, kandang, initialValues]); + useEffect(() => { + formik.validateForm(); + }, [formik.values]); + + useEffect(() => { + isResponseSuccess(periodFlocks) + ? formik.setFieldValue('period', periodFlocks.data.next_period) + : formik.setFieldValue('period', ''); + }, [periodFlocks]); + return ( <>
    @@ -408,24 +461,7 @@ const ProjectFlockForm = ({
    Informasi Umum
    -
    - {formType != 'detail' && ( -
    - -
    - )} +
    @@ -444,14 +482,16 @@ const ProjectFlockForm = ({ value={formik.values.flock as OptionType} onChange={(val) => { optionChangeHandler(val, 'flock'); + setSelectedFlock((val as OptionType)?.value as number); }} options={optionsFlock} isLoading={isLoadingFlocks} - isError={formik.touched.flock && Boolean(formik.errors.flock)} - errorMessage={formik.errors.flock as string} + isError={ + formik.touched.flock_id && Boolean(formik.errors.flock_id) + } + errorMessage={formik.errors.flock_id as string} isClearable isDisabled={formType === 'detail'} - /> @@ -476,8 +517,10 @@ const ProjectFlockForm = ({ }} options={optionsFcr} isLoading={isLoadingFcrs} - isError={formik.touched.fcr && Boolean(formik.errors.fcr)} - errorMessage={formik.errors.fcr as string} + isError={ + formik.touched.fcr_id && Boolean(formik.errors.fcr_id) + } + errorMessage={formik.errors.fcr_id as string} isClearable isDisabled={formType === 'detail'} /> @@ -497,13 +540,13 @@ const ProjectFlockForm = ({ errorMessage={formik.errors.product_category as string} isClearable isDisabled={formType === 'detail'} - />
    } - className='w-full size-full' + className='sm:w-full' titleClassName='w-full p-0!' onOpenChange={setOpenSelectKandangs} open={openSelectKandangs} @@ -548,8 +591,14 @@ const ProjectFlockForm = ({ k.status === 'NON_ACTIVE') + .every((k) => + formik.values.kandang_ids.includes(k.id) + ) && + optionsKandang.filter( + (k) => k.status === 'NON_ACTIVE' + ).length > 0 } className='checkbox' onChange={ @@ -561,6 +610,7 @@ const ProjectFlockForm = ({
    KandangStatus Penanggung Jawab
    {kandang.name}{kandang.status} {kandang.pic?.name}
    - { - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - Date: Sat, 18 Oct 2025 12:25:04 +0700 Subject: [PATCH 072/212] refactor(FE-114,136): update RecordingForm validation and input handling for feed and vaccination data --- .../recording/form/RecordingForm.schema.ts | 52 ++++----- .../flock/recording/form/RecordingForm.tsx | 102 +++++++++--------- 2 files changed, 80 insertions(+), 74 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index ed5507eb..16f7e690 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -49,20 +49,19 @@ export const RecordingFormSchema = Yup.object({ .of( Yup.object({ feed_id: Yup.string().required('Nama pakan wajib diisi!'), - feed_qty: Yup.number() - .required('Qty pakan wajib diisi!') - .min(1, 'Qty minimal 1!') - .typeError('Qty pakan wajib diisi!'), + feed_qty: Yup.mixed().notRequired(), feed_stock: Yup.number() - .required('Stock pakan wajib diisi!') - .min(1, 'Stock minimal 1!') - .typeError('Stock pakan wajib diisi!') + .required('Jumlah pakan yang digunakan wajib diisi!') + .min(1, 'Jumlah pakan minimal 1 kg!') + .typeError('Jumlah pakan yang digunakan harus berupa angka!') .test( 'is-not-exceed-qty', - 'Feed stock tidak boleh melebihi feed qty yang tersedia!', + 'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!', function (value) { const { feed_qty } = this.parent; - return value === undefined || value <= feed_qty; + if (value === undefined) return true; + if (feed_qty === undefined || feed_qty === '' || typeof feed_qty !== 'number') return true; + return value <= feed_qty; } ), }) @@ -74,13 +73,16 @@ export const RecordingFormSchema = Yup.object({ Yup.object({ chicken_weight: Yup.number() .required('Berat ayam wajib diisi!') - .min(1, 'Berat minimal 1!'), + .min(1, 'Berat ayam minimal 1 gram!') + .typeError('Berat ayam harus berupa angka!'), chicken_count: Yup.number() .required('Jumlah ayam wajib diisi!') - .min(1, 'Jumlah minimal 1!'), + .min(1, 'Jumlah ayam minimal 1 ekor!') + .typeError('Jumlah ayam harus berupa angka!'), average_chicken_weight: Yup.number() .required('Rata-rata berat ayam wajib diisi!') - .min(1, 'Rata-rata minimal 1!'), + .min(1, 'Rata-rata berat ayam minimal 1 gram!') + .typeError('Rata-rata berat ayam harus berupa angka!'), }) ) .min(1, 'Minimal harus ada 1 data bobot badan!') @@ -89,20 +91,19 @@ export const RecordingFormSchema = Yup.object({ .of( Yup.object({ vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'), - total_stock: Yup.number() - .required('Total stock wajib diisi!') - .min(1, 'Total stock minimal 1!') - .typeError('Total stock wajib diisi!'), + total_stock: Yup.mixed().notRequired(), used_stock: Yup.number() - .required('Jumlah stock wajib diisi!') - .min(1, 'Jumlah stock minimal 1!') - .typeError('Jumlah stock wajib diisi!') + .required('Jumlah vaksin yang digunakan wajib diisi!') + .min(1, 'Jumlah vaksin minimal 1!') + .typeError('Jumlah vaksin yang digunakan harus berupa angka!') .test( 'is-not-exceed-total', - 'Used stock tidak boleh melebihi total stock yang tersedia!', + 'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!', function (value) { const { total_stock } = this.parent; - return value === undefined || value <= total_stock; + if (value === undefined) return true; + if (total_stock === undefined || total_stock === '' || typeof total_stock !== 'number') return true; + return value <= total_stock; } ), }) @@ -119,8 +120,9 @@ export const RecordingFormSchema = Yup.object({ ) .required('Kondisi wajib diisi!'), count: Yup.number() - .required('Jumlah wajib diisi!') - .min(1, 'Jumlah minimal 1!'), + .required('Jumlah mortalitas wajib diisi!') + .min(1, 'Jumlah mortalitas minimal 1 ekor!') + .typeError('Jumlah mortalitas harus berupa angka!'), }) ) .min(1, 'Minimal harus ada 1 data mortalitas!') @@ -167,7 +169,7 @@ export const getRecordingFormInitialValues = ( : [ { feed_id: '', - feed_qty: 0, + feed_qty: '', feed_stock: 0, }, ], @@ -187,7 +189,7 @@ export const getRecordingFormInitialValues = ( : [ { vaccine_id: '', - total_stock: 0, + total_stock: '', used_stock: 0, }, ], diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 773baab6..95600120 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -74,7 +74,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { : '', feed_data: (values.feed_data ?? []).map((p) => ({ feed_id: p.feed_id, - feed_qty: p.feed_qty, + feed_qty: typeof p.feed_qty === 'number' ? p.feed_qty : 0, feed_stock: p.feed_stock, })), body_weight: (values.body_weight ?? []).map((b) => ({ @@ -84,7 +84,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { })), vaccination: (values.vaccination ?? []).map((v) => ({ vaccine_id: v.vaccine_id, - total_stock: v.total_stock, + total_stock: typeof v.total_stock === 'number' ? v.total_stock : 0, used_stock: v.used_stock, })), mortality: (values.mortality ?? []).map((m) => ({ @@ -132,8 +132,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Create stock mapping for pakan (Feed) const pakanStockMap = useMemo(() => { - if (!isResponseSuccess(pakanProducts)) return new Map(); - const map = new Map(); + if (!isResponseSuccess(pakanProducts)) + return new Map(); + const map = new Map(); pakanProducts.data.forEach((product) => { map.set(product.id, product.quantity); }); @@ -155,8 +156,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Create stock mapping for OVK (Vaccination) const ovkStockMap = useMemo(() => { - if (!isResponseSuccess(ovkProducts)) return new Map(); - const map = new Map(); + if (!isResponseSuccess(ovkProducts)) return new Map(); + const map = new Map(); ovkProducts.data.forEach((product) => { map.set(product.id, product.quantity); }); @@ -256,8 +257,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.feed_data || []), { feed: null, - feed_id: 0, - feed_qty: 0, + feed_id: '', + feed_qty: '', feed_stock: 0, }, ]; @@ -311,8 +312,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.vaccination || []), { vaccine: null, - vaccine_id: 0, - total_stock: 0, + vaccine_id: '', + total_stock: '', used_stock: 0, }, ]; @@ -532,10 +533,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { onChange={(val) => { const productWarehouseId = (val as OptionType)?.value ?? 0; - const stock = - pakanStockMap.get( - productWarehouseId as number - ) ?? 0; + const stock = productWarehouseId + ? (pakanStockMap.get( + productWarehouseId as number + ) ?? '') + : ''; formik.setFieldValue( `feed_data.${idx}.feed`, @@ -543,12 +545,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); formik.setFieldValue( `feed_data.${idx}.feed_id`, - productWarehouseId + productWarehouseId || '' ); formik.setFieldValue( `feed_data.${idx}.feed_qty`, stock ); + // Reset feed_stock when changing feed + formik.setFieldValue( + `feed_data.${idx}.feed_stock`, + 0 + ); }} options={pakanOptions} isLoading={false} @@ -569,21 +576,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { onChange={(val) => { const productWarehouseId = (val as OptionType)?.value ?? 0; - const stock = - ovkStockMap.get(productWarehouseId as number) ?? - 0; + const stock = productWarehouseId + ? (ovkStockMap.get( + productWarehouseId as number + ) ?? '') + : ''; formik.setFieldValue( `vaccination.${idx}.vaccine`, @@ -956,12 +962,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); formik.setFieldValue( `vaccination.${idx}.vaccine_id`, - productWarehouseId + productWarehouseId || '' ); formik.setFieldValue( `vaccination.${idx}.total_stock`, stock ); + // Reset used_stock when changing vaccine + formik.setFieldValue( + `vaccination.${idx}.used_stock`, + 0 + ); }} options={ovkOptions} isLoading={false} @@ -988,27 +999,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { /> - Date: Sat, 18 Oct 2025 12:58:18 +0700 Subject: [PATCH 073/212] feat(FE-87): slicing ui multiple approval checkbox and approval modal confirmation --- .../project-flock/ProjectFlockTable.tsx | 111 +++++++++++++++--- src/types/api/production/project-flock.d.ts | 1 + 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index c8b3f89b..8fe3ff7f 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -114,17 +114,45 @@ const ProjectFlockTable = () => { const [selectedProjectFlock, setSelectedProjectFlock] = useState(); const deleteModal = useModal(); + const confirmModal = useModal(); const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedFlocks, setSelectedFlocks] = useState([]); // Columns const projectFlocksColumns: ColumnDef[] = [ { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, + id: 'select', + header: () => { + const allSelected = + isResponseSuccess(projectFlocks) && + projectFlocks.data.length > 0 && + selectedIds.length === projectFlocks.data.length; + + return ( + handleSelectAll(e.target.checked)} + /> + ); + }, + cell: (props) => { + const id = props.row.original.id; + const isChecked = selectedIds.includes(id); + + return ( + handleSelectRow(id, e.target.checked)} + /> + ); + }, }, + { accessorKey: 'flock.name', header: 'Flock', @@ -226,11 +254,9 @@ const ProjectFlockTable = () => { toast.success('Successfully delete Project Flock!'); setIsDeleteLoading(false); }; - const searchChangeHandler: ChangeEventHandler = (e) => { updateFilter('search', e.target.value); }; - const updateSortingFilter = useCallback( ( sortName: Exclude, @@ -244,6 +270,34 @@ const ProjectFlockTable = () => { }, [updateFilter] ); + const handleSelectAll = (checked: boolean) => { + if (checked && isResponseSuccess(projectFlocks)) { + const allIds = projectFlocks.data.map((item) => item.id); + setSelectedIds(allIds); + setSelectedFlocks(projectFlocks.data); + } else { + setSelectedIds([]); + setSelectedFlocks([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (!isResponseSuccess(projectFlocks)) return; + + const targetFlock = projectFlocks.data.find((item) => item.id === id); + + if (!targetFlock) return; + + if (checked) { + setSelectedIds((prev) => [...prev, id]); + setSelectedFlocks((prev) => [...(prev || []), targetFlock]); + } else { + setSelectedIds((prev) => prev.filter((val) => val !== id)); + setSelectedFlocks((prev) => + (prev || []).filter((flock) => flock.id !== id) + ); + } + }; return ( <> @@ -259,14 +313,28 @@ const ProjectFlockTable = () => { Tambah +
    {
    0 + ? `Apakah anda yakin ingin approve Project Flock berikut? (${selectedFlocks + .map( + (flock, index) => + `${flock.flock?.name ?? '(Tanpa nama)'} - ${ + flock.area?.name ?? '-' + }` + ) + .join(', ')})` + : 'Tidak ada Project Flock yang dipilih.' + } secondaryButton={{ text: 'Tidak', }} primaryButton={{ text: 'Ya', - color: 'error', - isLoading: isDeleteLoading, - onClick: confirmationModalDeleteClickHandler, + color: 'success', + onClick: async () => { + toast.success('Project Flock berhasil di-approve!'); + confirmModal.closeModal(); + }, }} /> diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index 1fb71563..caaf1844 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -7,6 +7,7 @@ import { ProductCategory } from "@/types/api/master-data/product-category"; export type BaseProjectFlock = { name: string; + status: string; flock: Flock; flock_id: number; area: Area; From 6fe85fac137acf25d39b1d35f6f9867e2f647298 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 18 Oct 2025 13:40:08 +0700 Subject: [PATCH 074/212] feat(FE-113): add Client, Permission, Role, and RoleWithPermission types --- src/types/api/api-general.d.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/types/api/api-general.d.ts b/src/types/api/api-general.d.ts index cf3e57f7..c118b5a4 100644 --- a/src/types/api/api-general.d.ts +++ b/src/types/api/api-general.d.ts @@ -24,6 +24,36 @@ export type LogoutResponse = BaseApiResponse; export type GetMeResponse = BaseApiResponse; +export type Client = { + id: number; + name: stirng; + alias: string; + created_at: string; + updated_at: string; +}; + +export type Permission = { + id: number; + name: string; + action: string; + client: Omit; + created_at: string; + updated_at: string; +}; + +export type Role = { + id: number; + key: string; + name: string; + client: Omit; + created_at: string; + updated_at: string; +}; + +export type RoleWithPermissions = Omit & { + permissions: Omit[]; +}; + export type User = { id: number; email: string; From 2b3aa9c3eed4a11593b1d75035acc81be3faf4ee Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Sat, 18 Oct 2025 13:40:32 +0700 Subject: [PATCH 075/212] feat(FE-113): create permissionCheck helper function --- src/services/hooks/useAuth.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/services/hooks/useAuth.tsx b/src/services/hooks/useAuth.tsx index 86bf43ed..79fa8981 100644 --- a/src/services/hooks/useAuth.tsx +++ b/src/services/hooks/useAuth.tsx @@ -6,22 +6,43 @@ type AuthStore = { isLoadingUser?: boolean; setUser: (newUserData?: UserWithRoles) => void; setIsLoadingUser: (isLoading?: boolean) => void; + permissionCheck: (permissionName: string) => boolean; }; -const useAuthStore = create()((set) => ({ +const useAuthStore = create()((set, get) => ({ user: undefined, isLoadingUser: false, setUser: (newUserData) => set({ user: newUserData }), setIsLoadingUser: (isLoading) => set({ isLoadingUser: Boolean(isLoading) }), + + permissionCheck: (name) => { + const { user, isLoadingUser } = get(); + + if (!isLoadingUser && user) { + const isAllowed = user.roles.some((role) => { + const isPermissionNameAllowed = role.permissions.some( + (permission) => permission.name === name + ); + + return isPermissionNameAllowed; + }); + + return isAllowed; + } + + return false; + }, })); export const useAuth = () => { - const { user, setUser, isLoadingUser, setIsLoadingUser } = useAuthStore(); + const { user, setUser, isLoadingUser, setIsLoadingUser, permissionCheck } = + useAuthStore(); return { user, setUser, isLoadingUser, setIsLoadingUser, + permissionCheck, }; }; From 16d72ebf6fdc1ac538fefe8b39ed5a14c8ea6f18 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 09:51:32 +0700 Subject: [PATCH 076/212] feat(FE-114,136): integrate location selection and update flock handling in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 344 ++++++++++++------ src/types/api/production/project-flock.d.ts | 1 + 2 files changed, 224 insertions(+), 121 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 95600120..d29e7f72 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -23,8 +23,10 @@ import { ProjectFlockApi } from '@/services/api/production'; import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import useSWR from 'swr'; -import { KandangApi, LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; +import { ProjectFlock } from '@/types/api/production/project-flock'; +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { LocationApi } from '@/services/api/master-data'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -32,9 +34,10 @@ interface RecordingFormProps { } const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); - const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); + const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); + const [selectedProjectFlock, setSelectedProjectFlock] = + useState(null); const [selectedFeed, setSelectedFeed] = useState([]); const [selectedWeight, setSelectedWeight] = useState([]); const [selectedVaccine, setSelectedVaccine] = useState([]); @@ -104,87 +107,159 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); - // Flock selection - const projectFlocksUrl = `${ProjectFlockApi.basePath}?${new URLSearchParams({ search: flockSelectInputValue }).toString()}`; - const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR( - projectFlocksUrl, - ProjectFlockApi.getAllFetcher - ); - const flockOptions = isResponseSuccess(projectFlocks) - ? projectFlocks?.data.map((flock) => ({ - value: flock.id, - label: flock.flock.name, - })) - : []; - - // Pakan selection - const pakanUrl = `${ProductWarehouseApi.basePath}?${new URLSearchParams({ flag: 'PAKAN', search: '' }).toString()}`; - const { data: pakanProducts } = useSWR( - pakanUrl, - ProductWarehouseApi.getAllFetcher - ); - const pakanOptions = isResponseSuccess(pakanProducts) - ? pakanProducts?.data.map((product) => ({ - value: product.id, - label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, - })) - : []; - - // Create stock mapping for pakan (Feed) - const pakanStockMap = useMemo(() => { - if (!isResponseSuccess(pakanProducts)) - return new Map(); - const map = new Map(); - pakanProducts.data.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [pakanProducts]); - - // OVK selection - const ovkUrl = `${ProductWarehouseApi.basePath}?${new URLSearchParams({ flag: 'OVK', search: '' }).toString()}`; - const { data: ovkProducts } = useSWR( - ovkUrl, - ProductWarehouseApi.getAllFetcher - ); - const ovkOptions = isResponseSuccess(ovkProducts) - ? ovkProducts?.data.map((product) => ({ - value: product.id, - label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, - })) - : []; - - // Create stock mapping for OVK (Vaccination) - const ovkStockMap = useMemo(() => { - if (!isResponseSuccess(ovkProducts)) return new Map(); - const map = new Map(); - ovkProducts.data.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [ovkProducts]); - - const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, LocationApi.getAllFetcher ); const locationOptions = isResponseSuccess(locations) - ? locations?.data.map((loc) => ({ value: loc.id, label: loc.name })) + ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) : []; - const coopsUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: coopSelectInputValue ?? '' }).toString()}`; - const { data: coops, isLoading: isLoadingCoops } = useSWR( - coopsUrl, - KandangApi.getAllFetcher + const projectFlocksUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + search: flockSelectInputValue, + location_id: formik.values.location_id.toString(), + }); + return `${ProjectFlockApi.basePath}?${params.toString()}`; + }, [formik.values.location_id, flockSelectInputValue]); + + const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR( + projectFlocksUrl, + ProjectFlockApi.getAllFetcher ); + const flockOptions = isResponseSuccess(projectFlocks) + ? projectFlocks.data.map((flock) => ({ + value: flock.id, + label: flock.flock.name, + })) + : []; + + const buildWarehouseLabel = useCallback((warehouse: Warehouse) => { + const parts: string[] = [warehouse.name]; + + if ('kandang' in warehouse && warehouse.kandang) { + parts.push(warehouse.kandang.name); + } + + if ('location' in warehouse && warehouse.location) { + parts.push(warehouse.location.name); + } + + if (warehouse.area) { + parts.push(warehouse.area.name); + } + + return parts.join(' - '); + }, []); + + const pakanUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'PAKAN', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + + const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( + pakanUrl, + ProductWarehouseApi.getAllFetcher + ); + + const filteredPakanProducts = useMemo(() => { + if (!isResponseSuccess(pakanProducts) || !formik.values.location_id) + return []; + + return pakanProducts.data.filter((product) => { + const warehouse = product.warehouse; + + if ('location' in warehouse && warehouse.location) { + return warehouse.location.id === formik.values.location_id; + } + + // If warehouse only has area, include it if area matches the location's area + // Note: This might need adjustment based on your business logic + return false; + }); + }, [pakanProducts, formik.values.location_id]); + + const pakanOptions = useMemo( + () => + filteredPakanProducts.map((product) => ({ + value: product.id, + label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`, + })), + [filteredPakanProducts, buildWarehouseLabel] + ); + + const pakanStockMap = useMemo(() => { + const map = new Map(); + filteredPakanProducts.forEach((product) => { + map.set(product.id, product.quantity); + }); + return map; + }, [filteredPakanProducts]); + + const ovkUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'OVK', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + + const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( + ovkUrl, + ProductWarehouseApi.getAllFetcher + ); + + const filteredOvkProducts = useMemo(() => { + if (!isResponseSuccess(ovkProducts) || !formik.values.location_id) + return []; + + return ovkProducts.data.filter((product) => { + const warehouse = product.warehouse; + + if ('location' in warehouse && warehouse.location) { + return warehouse.location.id === formik.values.location_id; + } + + // If warehouse only has area, include it if area matches the location's area + // Note: This might need adjustment based on your business logic + return false; + }); + }, [ovkProducts, formik.values.location_id]); + + const ovkOptions = useMemo( + () => + filteredOvkProducts.map((product) => ({ + value: product.id, + label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`, + })), + [filteredOvkProducts, buildWarehouseLabel] + ); + + const ovkStockMap = useMemo(() => { + const map = new Map(); + filteredOvkProducts.forEach((product) => { + map.set(product.id, product.quantity); + }); + return map; + }, [filteredOvkProducts]); + const coopOptions = useMemo(() => { - if (!isResponseSuccess(coops) || !formik.values.location_id) return []; - return coops.data - .filter((coop) => coop.location.id === formik.values.location_id) - .map((coop) => ({ value: coop.id, label: coop.name })); - }, [coops, formik.values.location_id]); + if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; + return selectedProjectFlock.kandangs.map((kandang) => ({ + value: kandang.id, + label: kandang.name, + })); + }, [selectedProjectFlock]); const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; @@ -192,9 +267,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('location', val, false); formik.setFieldValue('location_id', locationValue || 0, false); + formik.setFieldValue('flock', null, false); + formik.setFieldValue('flock_id', 0, false); + formik.setFieldValue('coop', null, false); + formik.setFieldValue('coop_id', 0, false); + setSelectedProjectFlock(null); + setFlockSelectInputValue(''); + }; + + const flockChangeHandler = (val: OptionType | OptionType[] | null) => { + const flockValue = (val as OptionType)?.value; + + const selected = isResponseSuccess(projectFlocks) + ? projectFlocks.data.find((flock) => flock.id === flockValue) + : null; + + setSelectedProjectFlock(selected || null); + + formik.setFieldValue('flock', val, false); + formik.setFieldValue('flock_id', flockValue || 0, false); + formik.setFieldValue('coop', null, false); formik.setFieldValue('coop_id', 0, false); - setCoopSelectInputValue(''); }; const coopChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -204,6 +298,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('coop_id', coopValue || 0, false); }; + useEffect(() => { + if (initialValues?.flock && isResponseSuccess(projectFlocks)) { + const flock = projectFlocks.data.find( + (f) => f.id === initialValues.flock.id + ); + if (flock) { + setSelectedProjectFlock(flock); + } + } + }, [initialValues, projectFlocks]); + const isRepeaterInputError = ( arrayName: T, field: T extends 'feed_data' @@ -245,13 +350,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - const flockChangeHandler = (val: OptionType | OptionType[] | null) => { - const flockValue = (val as OptionType)?.value; - - formik.setFieldValue('flock', val, false); - formik.setFieldValue('flock_id', flockValue || 0, false); - }; - const addFeedData = () => { const newFeedData = [ ...(formik.values.feed_data || []), @@ -372,25 +470,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Basic Info Card */}
    -

    Flock

    +

    Recording Information

    + { readOnly={type === 'detail'} />
    +
    - - + + @@ -551,14 +655,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { `feed_data.${idx}.feed_qty`, stock ); - // Reset feed_stock when changing feed formik.setFieldValue( `feed_data.${idx}.feed_stock`, 0 ); }} options={pakanOptions} - isLoading={false} + isLoading={isLoadingPakan} isError={ isRepeaterInputError('feed_data', 'feed_id', idx) .isError @@ -968,14 +1071,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { `vaccination.${idx}.total_stock`, stock ); - // Reset used_stock when changing vaccine formik.setFieldValue( `vaccination.${idx}.used_stock`, 0 ); }} options={ovkOptions} - isLoading={false} + isLoading={isLoadingOvk} isError={ isRepeaterInputError( 'vaccination', diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index cd68df40..7a251d38 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -3,6 +3,7 @@ import { Area } from '@/types/api/master-data/area'; import { ProductCategory } from '@/types/api/master-data/product-category'; import { Fcr } from '@/types/api/master-data/fcr'; import { Kandang } from '@/types/api/master-data/kandang'; +import { Location } from '@/types/api/master-data/location'; import { BaseMetadata } from '@/types/api/api-general'; export type BaseProjectFlock = { From 376fa29f7edf4da9df926566f270f6053895f6de Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 20 Oct 2025 09:55:08 +0700 Subject: [PATCH 077/212] fix(FE-40): wrap master data detail with SuspenseHelper --- src/app/master-data/customer/detail/layout.tsx | 11 +++++++++++ src/app/master-data/supplier/detail/layout.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/app/master-data/customer/detail/layout.tsx create mode 100644 src/app/master-data/supplier/detail/layout.tsx diff --git a/src/app/master-data/customer/detail/layout.tsx b/src/app/master-data/customer/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/customer/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/master-data/supplier/detail/layout.tsx b/src/app/master-data/supplier/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/supplier/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; From 4233c19dc9294193197451575aae3ed83ba1b437 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 10:06:26 +0700 Subject: [PATCH 078/212] refactor(FE-114): rearrange code for better readability --- .../flock/recording/form/RecordingForm.tsx | 348 +++++++++--------- 1 file changed, 178 insertions(+), 170 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index d29e7f72..42edc1ca 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -1,9 +1,8 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FormikProps, useFormik } from 'formik'; +import { useFormik } from 'formik'; import { Icon } from '@iconify/react'; -import { toast } from 'react-hot-toast'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; import NumberInput from '@/components/input/NumberInput'; @@ -42,8 +41,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [selectedWeight, setSelectedWeight] = useState([]); const [selectedVaccine, setSelectedVaccine] = useState([]); const [selectedMortality, setSelectedMortality] = useState([]); - const [, setRecordingFormErrorMessage] = useState(''); + const { deleteModal, recordingFormErrorMessage, @@ -107,15 +106,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); + // Locations const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, LocationApi.getAllFetcher ); - const locationOptions = isResponseSuccess(locations) - ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) - : []; + // Project Flocks const projectFlocksUrl = useMemo(() => { if (!formik.values.location_id) return null; const params = new URLSearchParams({ @@ -130,13 +128,39 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ProjectFlockApi.getAllFetcher ); - const flockOptions = isResponseSuccess(projectFlocks) - ? projectFlocks.data.map((flock) => ({ - value: flock.id, - label: flock.flock.name, - })) - : []; + // Pakan Products + const pakanUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'PAKAN', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( + pakanUrl, + ProductWarehouseApi.getAllFetcher + ); + + // OVK Products + const ovkUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'OVK', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + + const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( + ovkUrl, + ProductWarehouseApi.getAllFetcher + ); + + // COMPUTED VALUES const buildWarehouseLabel = useCallback((warehouse: Warehouse) => { const parts: string[] = [warehouse.name]; @@ -155,20 +179,24 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return parts.join(' - '); }, []); - const pakanUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - flag: 'PAKAN', - search: '', - location_id: formik.values.location_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); + const locationOptions = isResponseSuccess(locations) + ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) + : []; - const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( - pakanUrl, - ProductWarehouseApi.getAllFetcher - ); + const flockOptions = isResponseSuccess(projectFlocks) + ? projectFlocks.data.map((flock) => ({ + value: flock.id, + label: flock.flock.name, + })) + : []; + + const coopOptions = useMemo(() => { + if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; + return selectedProjectFlock.kandangs.map((kandang) => ({ + value: kandang.id, + label: kandang.name, + })); + }, [selectedProjectFlock]); const filteredPakanProducts = useMemo(() => { if (!isResponseSuccess(pakanProducts) || !formik.values.location_id) @@ -181,8 +209,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return warehouse.location.id === formik.values.location_id; } - // If warehouse only has area, include it if area matches the location's area - // Note: This might need adjustment based on your business logic return false; }); }, [pakanProducts, formik.values.location_id]); @@ -204,21 +230,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return map; }, [filteredPakanProducts]); - const ovkUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - flag: 'OVK', - search: '', - location_id: formik.values.location_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); - - const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( - ovkUrl, - ProductWarehouseApi.getAllFetcher - ); - const filteredOvkProducts = useMemo(() => { if (!isResponseSuccess(ovkProducts) || !formik.values.location_id) return []; @@ -230,8 +241,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return warehouse.location.id === formik.values.location_id; } - // If warehouse only has area, include it if area matches the location's area - // Note: This might need adjustment based on your business logic return false; }); }, [ovkProducts, formik.values.location_id]); @@ -253,14 +262,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return map; }, [filteredOvkProducts]); - const coopOptions = useMemo(() => { - if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; - return selectedProjectFlock.kandangs.map((kandang) => ({ - value: kandang.id, - label: kandang.name, - })); - }, [selectedProjectFlock]); + // EFFECTS + useEffect(() => { + if (initialValues?.flock && isResponseSuccess(projectFlocks)) { + const flock = projectFlocks.data.find( + (f) => f.id === initialValues.flock.id + ); + if (flock) { + setSelectedProjectFlock(flock); + } + } + }, [initialValues, projectFlocks]); + // EVENT HANDLERS - Select Inputs const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; @@ -298,17 +312,120 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('coop_id', coopValue || 0, false); }; - useEffect(() => { - if (initialValues?.flock && isResponseSuccess(projectFlocks)) { - const flock = projectFlocks.data.find( - (f) => f.id === initialValues.flock.id - ); - if (flock) { - setSelectedProjectFlock(flock); - } - } - }, [initialValues, projectFlocks]); + // EVENT HANDLERS - Feed Data + const addFeedData = () => { + const newFeedData = [ + ...(formik.values.feed_data || []), + { + feed: null, + feed_id: '', + feed_qty: '', + feed_stock: 0, + }, + ]; + formik.setFieldValue('feed_data', newFeedData); + }; + const removeFeedData = (idx: number) => { + const updatedFeedData = formik.values.feed_data?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('feed_data', updatedFeedData); + }; + + const removeSelectedFeedData = () => { + const updatedFeedData = formik.values.feed_data?.filter( + (_, idx) => !selectedFeed.includes(idx) + ); + formik.setFieldValue('feed_data', updatedFeedData); + setSelectedFeed([]); + }; + + // EVENT HANDLERS - Body Weight + const addBodyWeight = () => { + const newBodyWeight = [ + ...(formik.values.body_weight || []), + { + chicken_weight: 0, + chicken_count: 0, + average_chicken_weight: 0, + }, + ]; + formik.setFieldValue('body_weight', newBodyWeight); + }; + + const removeBodyWeight = (idx: number) => { + const updatedBodyWeight = formik.values.body_weight?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('body_weight', updatedBodyWeight); + }; + + const removeSelectedBodyWeight = () => { + const updatedBodyWeight = formik.values.body_weight?.filter( + (_, idx) => !selectedWeight.includes(idx) + ); + formik.setFieldValue('body_weight', updatedBodyWeight); + setSelectedWeight([]); + }; + + // EVENT HANDLERS - Vaccination + const addVaccination = () => { + const newVaccination = [ + ...(formik.values.vaccination || []), + { + vaccine: null, + vaccine_id: '', + total_stock: '', + used_stock: 0, + }, + ]; + formik.setFieldValue('vaccination', newVaccination); + }; + + const removeVaccination = (idx: number) => { + const updatedVaccination = formik.values.vaccination?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('vaccination', updatedVaccination); + }; + + const removeSelectedVaccination = () => { + const updatedVaccination = formik.values.vaccination?.filter( + (_, idx) => !selectedVaccine.includes(idx) + ); + formik.setFieldValue('vaccination', updatedVaccination); + setSelectedVaccine([]); + }; + + // EVENT HANDLERS - Mortality + const addMortality = () => { + const newMortality = [ + ...(formik.values.mortality || []), + { + condition: RECORDING_FLAG_OPTIONS[0].value, + count: 0, + }, + ]; + formik.setFieldValue('mortality', newMortality); + }; + + const removeMortality = (idx: number) => { + const updatedMortality = formik.values.mortality?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('mortality', updatedMortality); + }; + + const removeSelectedMortality = () => { + const updatedMortality = formik.values.mortality?.filter( + (_, idx) => !selectedMortality.includes(idx) + ); + formik.setFieldValue('mortality', updatedMortality); + setSelectedMortality([]); + }; + + // HELPER FUNCTIONS const isRepeaterInputError = ( arrayName: T, field: T extends 'feed_data' @@ -350,115 +467,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - const addFeedData = () => { - const newFeedData = [ - ...(formik.values.feed_data || []), - { - feed: null, - feed_id: '', - feed_qty: '', - feed_stock: 0, - }, - ]; - formik.setFieldValue('feed_data', newFeedData); - }; - - const removeFeedData = (idx: number) => { - const updatedFeedData = formik.values.feed_data?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('feed_data', updatedFeedData); - }; - - const removeSelectedFeedData = () => { - const updatedFeedData = formik.values.feed_data?.filter( - (_, idx) => !selectedFeed.includes(idx) - ); - formik.setFieldValue('feed_data', updatedFeedData); - setSelectedFeed([]); - }; - - const addBodyWeight = () => { - const newBodyWeight = [ - ...(formik.values.body_weight || []), - { - chicken_weight: 0, - chicken_count: 0, - average_chicken_weight: 0, - }, - ]; - formik.setFieldValue('body_weight', newBodyWeight); - }; - - const removeBodyWeight = (idx: number) => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('body_weight', updatedBodyWeight); - }; - - const removeSelectedBodyWeight = () => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, idx) => !selectedWeight.includes(idx) - ); - formik.setFieldValue('body_weight', updatedBodyWeight); - setSelectedWeight([]); - }; - - const addVaccination = () => { - const newVaccination = [ - ...(formik.values.vaccination || []), - { - vaccine: null, - vaccine_id: '', - total_stock: '', - used_stock: 0, - }, - ]; - formik.setFieldValue('vaccination', newVaccination); - }; - - const removeVaccination = (idx: number) => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('vaccination', updatedVaccination); - }; - - const removeSelectedVaccination = () => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, idx) => !selectedVaccine.includes(idx) - ); - formik.setFieldValue('vaccination', updatedVaccination); - setSelectedVaccine([]); - }; - - const addMortality = () => { - const newMortality = [ - ...(formik.values.mortality || []), - { - condition: RECORDING_FLAG_OPTIONS[0].value, - count: 0, - }, - ]; - formik.setFieldValue('mortality', newMortality); - }; - - const removeMortality = (idx: number) => { - const updatedMortality = formik.values.mortality?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('mortality', updatedMortality); - }; - - const removeSelectedMortality = () => { - const updatedMortality = formik.values.mortality?.filter( - (_, idx) => !selectedMortality.includes(idx) - ); - formik.setFieldValue('mortality', updatedMortality); - setSelectedMortality([]); - }; - return ( <>
    From eb02a8b6f72121f45d1f8cc9893d2994fe3c3acf Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 10:09:58 +0700 Subject: [PATCH 079/212] refactor(storyless): update border class for consistent styling --- src/components/Collapse.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index cb05d5b0..8506f65c 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -68,7 +68,7 @@ export const Collapse = ({ 'collapse', variant === 'arrow' && 'collapse-arrow', variant === 'plus' && 'collapse-plus', - bordered && 'border base-content/20 border-opacity-20 rounded-box', + bordered && 'border base-content/20 border-opacity-20 rounded', disabled && 'opacity-60 pointer-events-none', !open && 'w-fit', className From 406cfad31a49af235c3f0631d7e2de5eccdd57bf Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 20 Oct 2025 10:14:04 +0700 Subject: [PATCH 080/212] chore(FE-140): adjust border radius --- src/components/Button.tsx | 2 +- src/components/input/SelectInput.tsx | 39 +++++++---------- src/components/input/TextArea.tsx | 62 +++++++++++++--------------- src/components/input/TextInput.tsx | 2 +- 4 files changed, 45 insertions(+), 60 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c67a29c2..5da6e5ad 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -43,7 +43,7 @@ const Button = ({ 'btn-warning': color === 'warning', 'btn-error': color === 'error', }, - 'h-fit justify-center items-center gap-2 rounded-xl p-2 text-base transition-all' + 'h-fit justify-center items-center gap-2 rounded p-2 text-base transition-all' ); return ( diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 43a3f622..f6cef527 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - ComponentType, - ReactNode, - useEffect, - useMemo, - useState, -} from 'react'; +import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; import Select, { OptionProps, GroupBase, @@ -98,10 +92,7 @@ const SelectInput = (props: SelectInputProps) => { return { ...base, IndicatorSeparator: () => null }; }, [isAnimated]); - const internalInputChangeHandler = ( - val: string, - meta: InputActionMeta - ) => { + const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'menu-close') setInternalInputValue(''); }; @@ -113,9 +104,7 @@ const SelectInput = (props: SelectInputProps) => { const SelectComponent = createables ? CreatableSelect : Select; /** 🎯 handleChange tanpa any */ - const handleChange = ( - val: MultiValue | SingleValue - ): void => { + const handleChange = (val: MultiValue | SingleValue): void => { if (!val) { onChange?.(null); return; @@ -145,15 +134,15 @@ const SelectInput = (props: SelectInputProps) => { > {label} {required && ( - - * + + * )} )} > - instanceId="select" + instanceId='select' value={value ?? (isMulti ? [] : null)} onChange={handleChange} options={options} @@ -171,7 +160,7 @@ const SelectInput = (props: SelectInputProps) => { classNames={{ control: ({ isFocused, isDisabled }) => cn( - 'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!', + 'w-full min-h-12! rounded! border bg-base-100 transition-shadow cursor-pointer!', { 'border-red-500! ring-2 ring-red-200': isError, 'border-indigo-500 ring-2 ring-indigo-200': isFocused, @@ -187,24 +176,24 @@ const SelectInput = (props: SelectInputProps) => { input: () => cn('text-gray-900'), indicatorsContainer: () => cn('flex items-center gap-1 pr-2'), dropdownIndicator: ({ isFocused }) => - cn('p-1 rounded-md hover:bg-gray-100', { + cn('p-1 rounded hover:bg-gray-100', { 'text-gray-900': isFocused, 'text-gray-500': !isFocused, 'text-error!': isError, }), menu: () => - cn('border border-gray-200 rounded-lg bg-white shadow-lg!'), + cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'), menuList: () => cn('p-2! max-h-60 overflow-auto'), option: ({ isFocused, isSelected }) => - cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', { - 'bg-indigo-600 text-white': isFocused, + cn('mt-1 px-3 py-2 rounded cursor-pointer!', { + 'bg-indigo-600 text-base-100': isFocused, 'bg-blue-500!': isSelected, 'text-gray-700': !isFocused && !isSelected, }), multiValue: ({ getValue, index }) => { const selectedValues = getValue() as T[]; return cn( - 'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!', + 'bg-indigo-50 rounded py-0.5 pl-2 pr-1 flex items-center gap-1!', selectedValues[index]?.className ); }, @@ -225,9 +214,9 @@ const SelectInput = (props: SelectInputProps) => { }} /> - {isError &&

    {errorMessage}

    } + {isError &&

    {errorMessage}

    } {!isError && bottomLabel && ( -

    {bottomLabel}

    +

    {bottomLabel}

    )}
    ); diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx index e9517277..fbb1637a 100644 --- a/src/components/input/TextArea.tsx +++ b/src/components/input/TextArea.tsx @@ -1,10 +1,6 @@ 'use client'; -import { - ChangeEventHandler, - FocusEventHandler, - ReactNode, -} from 'react'; +import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; import { cn } from '@/lib/helper'; @@ -52,7 +48,7 @@ const TextArea = ({ onBlur, readOnly = false, isLoading = false, - rows = 3 + rows = 3, }: TextAreaProps) => { return (
    )} - {startAdornment && startAdornment} + {startAdornment && startAdornment} -