diff --git a/package-lock.json b/package-lock.json index d73a1b22..f960d1c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "clsx": "^2.1.1", "formik": "^2.4.6", "moment": "^2.30.1", - "next": "15.5.7", + "next": "^15.5.7", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", @@ -36,9 +36,9 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "daisyui": "^5.1.12", + "daisyui": "^5.5.8", "eslint": "^9", - "eslint-config-next": "15.5.3", + "eslint-config-next": "^15.5.7", "husky": "^9.1.7", "prettier": "^3.6.2", "tailwindcss": "^4", @@ -1088,9 +1088,9 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", - "integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.7.tgz", + "integrity": "sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3067,9 +3067,9 @@ "peer": true }, "node_modules/daisyui": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.10.tgz", - "integrity": "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==", + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.8.tgz", + "integrity": "sha512-6psL9jIEOFOw68V10j/BKCWcRgx8dh81mmNxShr+g7HDM6UHNoPharlp9zq/PQkHNuGU1ZQsajR3HgpvavbRKQ==", "dev": true, "license": "MIT", "funding": { @@ -3576,13 +3576,13 @@ } }, "node_modules/eslint-config-next": { - "version": "15.5.3", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", - "integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.7.tgz", + "integrity": "sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.5.3", + "@next/eslint-plugin-next": "15.5.7", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/package.json b/package.json index 4b9fdac7..e1f92aaf 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "clsx": "^2.1.1", "formik": "^2.4.6", "moment": "^2.30.1", - "next": "15.5.7", + "next": "^15.5.7", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", @@ -39,9 +39,9 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "daisyui": "^5.1.12", + "daisyui": "^5.5.8", "eslint": "^9", - "eslint-config-next": "15.5.3", + "eslint-config-next": "^15.5.7", "husky": "^9.1.7", "prettier": "^3.6.2", "tailwindcss": "^4", diff --git a/src/app/closing/detail/layout.tsx b/src/app/closing/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/closing/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/closing/detail/page.tsx b/src/app/closing/detail/page.tsx new file mode 100644 index 00000000..487533be --- /dev/null +++ b/src/app/closing/detail/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ClosingDetail from '@/components/pages/closing/ClosingDetail'; +import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; + +import { ClosingApi } from '@/services/api/closing'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ClosingDetailPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const closingId = searchParams.get('closingId'); + + const { data: closing, isLoading: isLoadingClosing } = useSWR( + closingId, + (id: number) => ClosingApi.getGeneralInfo(id) + ); + + const { data: salesReport, isLoading: isLoadingSalesReport } = useSWR( + closingId, + (id: number) => ClosingApi.getPenjualan(id) + ); + + if (!closingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingClosing && (!closing || isResponseError(closing))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingClosing && ( + + )} + + {!isLoadingClosing && isResponseSuccess(closing) && ( + + )} + {!isLoadingSalesReport && isResponseSuccess(salesReport) && ( + + )} +
+ ); +}; + +export default ClosingDetailPage; diff --git a/src/app/closing/page.tsx b/src/app/closing/page.tsx new file mode 100644 index 00000000..acaa3ee8 --- /dev/null +++ b/src/app/closing/page.tsx @@ -0,0 +1,11 @@ +import ClosingsTable from '@/components/pages/closing/ClosingsTable'; + +const Closing = () => { + return ( +
+ +
+ ); +}; + +export default Closing; diff --git a/src/app/globals.css b/src/app/globals.css index e50e020d..10b48ad5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -7,26 +7,39 @@ 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); + + /* Primary Colors */ + --color-primary: oklch(39.4% 0.177 301.9); + --color-primary-content: oklch(87.5% 0.038 274.5); + + /* Secondary Colors */ + --color-secondary: oklch(60.1% 0.258 335.7); + --color-secondary-content: oklch(99.4% 0.007 337.8); + + /* Accent Colors */ + --color-accent: oklch(76.2% 0.155 170.8); + --color-accent-content: oklch(7.2% 0.007 167.6); + + /* Neutral Colors */ + --color-neutral: oklch(22.4% 0.032 258.8); + --color-neutral-content: oklch(87.7% 0.016 257); + + /* Base Colors */ + --color-base-100: oklch(100% 0 0); /* #ffffff */ + --color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */ + --color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */ + --color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */ + + /* Status/Utility Colors */ + --color-info: oklch(67.4% 0.176 238.9); + --color-info-content: oklch(0% 0 0); /* #000000 */ + --color-success: oklch(62.3% 0.147 149); + --color-success-content: oklch(100% 0 0); /* #ffffff */ + --color-warning: oklch(82.2% 0.165 91.9); + --color-warning-content: oklch(0% 0 0); /* #000000 */ + --color-error: oklch(61.8% 0.203 27.8); + --color-error-content: oklch(100% 0 0); /* #fffffff */ + --radius-selector: 0rem; --radius-field: 0.25rem; --radius-box: 0.25rem; @@ -43,6 +56,12 @@ @theme { --font-inter: var(--font-inter); + + --container-sm: 40rem; + --container-md: 48rem; + --container-lg: 64rem; + --container-xl: 80rem; + --container-2xl: 96rem; } html { diff --git a/src/app/inventory/adjustment/detail/page.tsx b/src/app/inventory/adjustment/detail/page.tsx index acb9f8db..eb13647d 100644 --- a/src/app/inventory/adjustment/detail/page.tsx +++ b/src/app/inventory/adjustment/detail/page.tsx @@ -12,8 +12,6 @@ const DetailInventoryAdjustment = () => { // Ambil data dari router state useEffect(() => { - console.log('Router State'); - console.log(window.history.state); const state = window.history.state?.usr as | { inventoryAdjustment?: InventoryAdjustment } | undefined; @@ -26,9 +24,6 @@ const DetailInventoryAdjustment = () => { const finalData = inventoryAdjustment; - console.log('Final Data'); - console.log(finalData); - if (!finalData) { return (
diff --git a/src/app/inventory/product/detail/layout.tsx b/src/app/inventory/product/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/inventory/product/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/product/detail/page.tsx b/src/app/inventory/product/detail/page.tsx new file mode 100644 index 00000000..6daa7a86 --- /dev/null +++ b/src/app/inventory/product/detail/page.tsx @@ -0,0 +1,50 @@ +'use client'; + +import InventoryProductDetail from '@/components/pages/inventory/product/detail/InventoryProductDetail'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { InventoryProductApi } from '@/services/api/inventory'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const InventoryProductDetailPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const inventoryProductId = searchParams.get('inventoryProductId'); + + const { data: inventoryProduct, isLoading: isLoadingInventoryProduct } = + useSWR(inventoryProductId, (id: number) => + InventoryProductApi.getSingle(id) + ); + + if (!inventoryProductId) { + router.back(); + + return ( +
+ +
+ ); + } + + if ( + !isLoadingInventoryProduct && + (!inventoryProduct || isResponseError(inventoryProduct)) + ) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingInventoryProduct && ( + + )} + {!isLoadingInventoryProduct && isResponseSuccess(inventoryProduct) && ( + + )} +
+ ); +}; + +export default InventoryProductDetailPage; diff --git a/src/app/inventory/product/page.tsx b/src/app/inventory/product/page.tsx new file mode 100644 index 00000000..4815b8a1 --- /dev/null +++ b/src/app/inventory/product/page.tsx @@ -0,0 +1,11 @@ +import InventoryProductTable from '@/components/pages/inventory/product/InventoryProductTable'; + +const InventoryProductPage = () => { + return ( +
+ +
+ ); +}; + +export default InventoryProductPage; diff --git a/src/app/production/project-flock/add/page.tsx b/src/app/production/project-flock/add/page.tsx index b323b5f3..2eb2c090 100644 --- a/src/app/production/project-flock/add/page.tsx +++ b/src/app/production/project-flock/add/page.tsx @@ -1,10 +1,18 @@ 'use client'; import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; +import React, { useImperativeHandle } from 'react'; +import toast from 'react-hot-toast'; const AddProjectFlock = () => { + // useImperativeHandle(ref, () => ({ + // validate() { + // toast.success('Validating'); + // return false; + // }, + // })); return ( -
+
); diff --git a/src/app/production/project-flock/chickin/add/kandang/page.tsx b/src/app/production/project-flock/chickin/add/kandang/page.tsx index a22039d1..c3a93a80 100644 --- a/src/app/production/project-flock/chickin/add/kandang/page.tsx +++ b/src/app/production/project-flock/chickin/add/kandang/page.tsx @@ -44,7 +44,7 @@ export default function AddChickinKandang() { return ( <> -
+
{isLoading && } {!isLoading && isResponseSuccess(projectFlockKandang) && diff --git a/src/app/production/project-flock/chickin/add/page.tsx b/src/app/production/project-flock/chickin/add/page.tsx index bcb4d612..831979cb 100644 --- a/src/app/production/project-flock/chickin/add/page.tsx +++ b/src/app/production/project-flock/chickin/add/page.tsx @@ -10,7 +10,7 @@ const AddChickin = () => { return ( <> -
+
diff --git a/src/app/production/project-flock/chickin/page.tsx b/src/app/production/project-flock/chickin/page.tsx index 5d105aab..d40c39a3 100644 --- a/src/app/production/project-flock/chickin/page.tsx +++ b/src/app/production/project-flock/chickin/page.tsx @@ -2,7 +2,7 @@ import ChickinTable from '@/components/pages/production/chickin/ChickinTable'; const Chickin = () => { return ( -
+
); diff --git a/src/app/production/project-flock/closing/layout.tsx b/src/app/production/project-flock/closing/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/project-flock/closing/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/production/project-flock/closing/page.tsx b/src/app/production/project-flock/closing/page.tsx new file mode 100644 index 00000000..d734f669 --- /dev/null +++ b/src/app/production/project-flock/closing/page.tsx @@ -0,0 +1,63 @@ +'use client'; +import ProjectFlockClosingForm from '@/components/pages/production/project-flock/closing/ProjectFlockClosingForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { ProjectFlockKandangApi } from '@/services/api/production'; +import { ProjectFlockApi } from '@/services/api/production/project-flock'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const ProjectFlockClosingPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const projectFlockId = searchParams.get('projectFlockId'); + const projectFlockKandangId = searchParams.get('projectFlockKandangId'); + + const { data: projectFlockKandang, isLoading: isLoadingProjectFlockKandang } = + useSWR(projectFlockKandangId, (id: number) => + ProjectFlockKandangApi.getSingle(id) + ); + + const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR( + projectFlockId, + (id: number) => ProjectFlockApi.getSingle(id) + ); + + if (!projectFlockId || !projectFlockKandangId) { + router.back(); + + return ( +
+ +
+ ); + } + + if ( + !isLoadingProjectFlock && + (!projectFlock || isResponseError(projectFlock)) && + !isLoadingProjectFlockKandang && + (!projectFlockKandang || isResponseError(projectFlockKandang)) + ) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingProjectFlock || + (isLoadingProjectFlockKandang && ( + + ))} + {isResponseSuccess(projectFlock) && + isResponseSuccess(projectFlockKandang) && ( + + )} +
+ ); +}; + +export default ProjectFlockClosingPage; diff --git a/src/app/production/project-flock/detail/edit/page.tsx b/src/app/production/project-flock/detail/edit/page.tsx index f55ce601..e5f88f19 100644 --- a/src/app/production/project-flock/detail/edit/page.tsx +++ b/src/app/production/project-flock/detail/edit/page.tsx @@ -37,7 +37,7 @@ const ProjectFlockEdit = () => { } return ( -
+
{isLoadingProjectFlock && ( )} diff --git a/src/app/production/project-flock/detail/page.tsx b/src/app/production/project-flock/detail/page.tsx index 91d4dfd5..29a078dd 100644 --- a/src/app/production/project-flock/detail/page.tsx +++ b/src/app/production/project-flock/detail/page.tsx @@ -1,12 +1,13 @@ 'use client'; +import ProjectFlockDetail from '@/components/pages/production/project-flock/detail/ProjectFlockDetail'; import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; -const ProjectFlockDetail = () => { +const ProjectFlockDetailPage = () => { const router = useRouter(); const searchParams = useSearchParams(); @@ -37,19 +38,17 @@ const ProjectFlockDetail = () => { } return ( -
+
{isLoadingProjectFlock && ( )} {isResponseSuccess(projectFlock) && ( - + )}
); }; -export default ProjectFlockDetail; +export default ProjectFlockDetailPage; +ProjectFlockDetail; +ProjectFlockDetail; diff --git a/src/app/production/project-flock/layout.tsx b/src/app/production/project-flock/layout.tsx new file mode 100644 index 00000000..b74ef612 --- /dev/null +++ b/src/app/production/project-flock/layout.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { usePathname, useRouter } from 'next/navigation'; +import Drawer from '@/components/Drawer'; +import React, { ReactNode } from 'react'; +import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable'; +import { useUiStore } from '@/stores/ui/ui.store'; + +export default function ProjectFlockLayout({ + children, +}: { + children: ReactNode; +}) { + const pathname = usePathname(); + const router = useRouter(); + const toggleValidate = useUiStore((s) => s.toggleValidate); + + const isAdd = pathname.endsWith('/add'); + const isEdit = pathname.includes('/detail/edit'); + const isDetail = pathname.includes('/detail'); + const isChickin = pathname.includes('/chickin/add/kandang'); + const isClosing = pathname.includes('/closing'); + + const isOpen = isAdd || isEdit || isDetail || isChickin || isClosing; + + const handleBackdropClick = () => { + const unsub = useUiStore.getState().subscribeIsValid((isValid) => { + if (isValid) { + unsub(); // berhenti listen + router.push('/production/project-flock'); + } + }); + + toggleValidate(); + }; + + return ( + <> + {/* List page always rendered */} +
+ !isOpen && router.push('/production/project-flock')} + /> +
+ + {/* Render Drawer only on /add */} + { + if (!v) router.push('/production/project-flock'); + }} + closeOnBackdropClick={isDetail ? true : false} + onBackdropClick={handleBackdropClick} + variant='right' + zIndex='99999' + sidebarContent={isOpen &&
{children}
} + /> + + ); +} diff --git a/src/app/production/project-flock/page.tsx b/src/app/production/project-flock/page.tsx index 79feb41f..e93c6bc4 100644 --- a/src/app/production/project-flock/page.tsx +++ b/src/app/production/project-flock/page.tsx @@ -2,7 +2,7 @@ import ProjectFlockTable from '@/components/pages/production/project-flock/Proje const ProjectFlock = () => { return ( -
+
); diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index f0efb417..17b8a56f 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -10,28 +10,102 @@ interface DrawerProps { open: boolean; setOpen: (newOpenState: boolean) => void; openOnLarge?: boolean; + variant?: 'sidebar' | 'left' | 'right'; + zIndex?: string; + className?: DrawerClassName; + onBackdropClick?: () => void; + closeOnBackdropClick?: boolean; } +type DrawerClassName = { + drawer?: string; + drawerContent?: string; + drawerSide?: string; + drawerOverlay?: string; + drawerSidebarContent?: string; +}; + const Drawer = ({ children, sidebarContent, open, setOpen, openOnLarge, + variant = 'sidebar', + zIndex = '20', + className, + onBackdropClick, + closeOnBackdropClick = true, }: DrawerProps) => { + const getDrawerClassNames = (): DrawerClassName => { + const baseClassNames = { + drawer: 'drawer', + drawerContent: 'drawer-content', + drawerSide: 'drawer-side', + drawerOverlay: 'drawer-overlay', + drawerSidebarContent: 'min-h-full bg-base-100', + }; + + if (variant === 'sidebar') { + return { + ...baseClassNames, + drawerSidebarContent: cn( + baseClassNames.drawerSidebarContent, + 'w-full max-w-[300px] lg:w-[300px]' + ), + }; + } else if (variant === 'right') { + return { + ...baseClassNames, + drawer: cn(baseClassNames.drawer, 'drawer-end'), + drawerSide: cn( + baseClassNames.drawerSide, + 'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21' + ), + drawerSidebarContent: cn( + baseClassNames.drawerSidebarContent, + 'w-full min-w-120 sm:w-fit' + ), + }; + } else if (variant === 'left') { + return { + ...baseClassNames, + drawerSide: cn( + baseClassNames.drawerSide, + 'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21' + ), + drawerSidebarContent: cn( + baseClassNames.drawerSidebarContent, + 'w-full min-w-120 sm:w-fit' + ), + }; + } + return baseClassNames; // Fallback for default or unknown variant + }; + + const varianClassName = getDrawerClassNames(); + const toggleDrawer = () => { setOpen(!open); }; const closeDrawer = () => { - setOpen(false); + if (closeOnBackdropClick) { + setOpen(false); + } + onBackdropClick && onBackdropClick(); }; return (
-
{children}
+ {/* Drawer Content */} +
+ {children} +
-
+ {/* Drawer Side */} +
- +
); }; @@ -216,9 +76,9 @@ const MainDrawer = ({ const hasSubmenu = menu?.submenu && menu?.submenu.length > 0; if (!title) { - title += menu?.title; + title += menu?.text; } else { - title += ' - ' + menu?.title; + title += ' - ' + menu?.text; } if (!hasSubmenu || !menu.submenu) return; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 973bf031..bee92a57 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -7,6 +7,7 @@ import { Icon } from '@iconify/react'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import Button from '@/components/Button'; +import Dropdown from '@/components/dropdown/Dropdown'; import { useAuth } from '@/services/hooks/useAuth'; import { AuthApi } from '@/services/api/auth'; @@ -52,21 +53,21 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
-
-
-
- + +
+ +
-
- - + } + contentClassName='w-52 mt-3' + > + -
+
); diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index e47e480d..43b26d90 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -1,7 +1,9 @@ 'use client'; -import { ReactNode } from 'react'; +import { ChangeEventHandler, ReactNode } from 'react'; + import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; import { cn } from '@/lib/helper'; @@ -17,16 +19,18 @@ const PaginationButton = ({ disabled?: boolean; onClick?: () => void; }) => ( - + ); const EtcPaginationButton = ({ @@ -48,7 +52,7 @@ const EtcPaginationButton = ({ tabIndex={0} role='button' className={cn( - 'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square' + 'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square' )} > ... @@ -57,7 +61,7 @@ const EtcPaginationButton = ({
    {pages.map((pageNumber) => (
  • @@ -76,7 +80,7 @@ const EtcPaginationButton = ({ + ); + + const PrevPageButton = () => ( + + ); + + const GoToLastPageButton = () => ( + + ); + + const NextPageButton = () => ( + + ); + + const PageInfo = () => ( + + Page {currentPage} of {totalPages} + + ); return ( -
    -
    - +
    +
    +
    + +
    - {totalPages <= 7 && ( -
    - {range(1, totalPages).map((pageNumber) => ( +
    +
    + +
    + +
    + +
    + + {totalPages <= 7 && + range(1, totalPages).map((pageNumber) => ( pageChangeHandler(pageNumber)} /> ))} -
    - )} - {totalPages > 7 && ( -
    - pageChangeHandler(1)} - /> - - {totalPages >= 2 && - (currentPage <= 3 || currentPage >= totalPages - 2) && ( - pageChangeHandler(2)} - /> - )} - - {totalPages >= 2 && - currentPage > 3 && - currentPage < totalPages - 2 && ( - - )} - - {totalPages >= 3 && - (currentPage <= 4 || currentPage >= totalPages - 2) && - currentPage !== totalPages - 2 && ( - pageChangeHandler(3)} - /> - )} - - {totalPages >= 7 && - (currentPage <= 2 || currentPage >= totalPages - 2) && ( - = totalPages - 1 - ? 4 - : 1 - } - endPage={ - currentPage <= 2 || currentPage >= totalPages - 1 - ? totalPages - 3 - : currentPage === totalPages - 2 - ? totalPages - 4 - : 2 - } - onPageItemClick={pageChangeHandler} - /> - )} - - {totalPages >= 3 && - currentPage > 4 && - currentPage < totalPages - 1 && ( - pageChangeHandler(currentPage - 1)} - /> - )} - - {totalPages >= 7 && - currentPage > 3 && - currentPage < totalPages - 2 && ( - - )} - - {totalPages >= 5 && - currentPage > 2 && - currentPage < totalPages - 2 && ( - pageChangeHandler(currentPage + 1)} - /> - )} - - {totalPages >= 5 && - (currentPage <= 2 || currentPage >= totalPages - 2) && ( - pageChangeHandler(totalPages - 2)} - /> - )} - - {totalPages >= 6 && - currentPage > 2 && - currentPage < totalPages - 3 && ( - = 4 - ? currentPage + 2 - : 1 - } - endPage={ - currentPage <= 3 - ? totalPages - 2 - : currentPage >= 4 - ? totalPages - 1 - : 0 - } - onPageItemClick={pageChangeHandler} - /> - )} - - {totalPages >= 6 && - (currentPage <= 3 || currentPage >= totalPages - 3) && ( - pageChangeHandler(totalPages - 1)} - /> - )} - - {totalPages >= 7 && ( + {totalPages > 7 && ( + <> pageChangeHandler(totalPages)} + content={1} + disabled={currentPage === 1} + onClick={() => pageChangeHandler(1)} /> - )} -
    - )} - + +
    + +
    + +
    + +
    +
    + +
    + +
    -
    - +
    +
    + + + + +
    - +
    + + + +
    ); diff --git a/src/components/Table.tsx b/src/components/Table.tsx index b02dd3b5..9feb33e2 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -14,6 +14,7 @@ import { SortingState, OnChangeFn, Row, + HeaderContext, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -31,6 +32,9 @@ interface TableClassNames { tableBodyClassName?: string; bodyRowClassName?: string; bodyColumnClassName?: string; + tableFooterClassName?: string; + footerRowClassName?: string; + footerColumnClassName?: string; paginationClassName?: string; } @@ -38,6 +42,7 @@ export interface TableProps { data: TData[]; columns: ColumnDef[]; pageSize?: number; + onPageSizeChange?: (pageSize: number) => void; totalItems?: number; page?: number; onPageChange?: (page: number) => void; @@ -52,6 +57,9 @@ export interface TableProps { rowSelection?: Record; setRowSelection?: OnChangeFn>; enableRowSelection?: boolean | ((row: Row) => boolean); + renderFooter?: boolean; + withCheckbox?: boolean; + rowOptions?: number[]; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -64,28 +72,36 @@ const emptyContentDefaultValue = (
    ); +export const TABLE_DEFAULT_STYLING = { + containerClassName: 'w-full mb-20', + tableWrapperClassName: + 'overflow-x-auto border border-solid border-base-content/10 rounded-lg', + tableClassName: 'font-inter w-full table-auto text-sm font-medium', + tableHeaderClassName: '', + headerRowClassName: '', + headerColumnClassName: + 'px-4 py-3 border-base-content/10 text-base-content/50', + tableBodyClassName: '', + bodyRowClassName: 'border-t border-base-content/10', + bodyColumnClassName: 'px-4 py-3 text-base-content', + paginationClassName: '', + tableFooterClassName: 'font-semibold border-base-content/10', + footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10', + footerColumnClassName: 'p-4 text-base-content whitespace-nowrap', +}; + const Table = ({ data = [], columns = [], pageSize = 10, + onPageSizeChange, totalItems, page, onPageChange, isLoading = false, fuzzySearchValue, onFuzzySearchValueChange, - className = { - containerClassName: '', - tableWrapperClassName: '', - tableClassName: '', - tableHeaderClassName: '', - headerRowClassName: '', - headerColumnClassName: '', - tableBodyClassName: '', - bodyRowClassName: '', - bodyColumnClassName: '', - paginationClassName: '', - }, + className = TABLE_DEFAULT_STYLING, emptyContent = emptyContentDefaultValue, sorting, setSorting, @@ -93,12 +109,20 @@ const Table = ({ rowSelection, setRowSelection, enableRowSelection, + renderFooter = false, + withCheckbox = false, + rowOptions = [10, 20, 50, 100], }: TableProps) => { const isServerSideTable = totalItems !== undefined && page !== undefined && onPageChange !== undefined; + const tableClassNames = { + ...TABLE_DEFAULT_STYLING, + ...className, + }; + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize, @@ -191,68 +215,106 @@ const Table = ({ }, [pageSize, setPageSize]); return ( -
    -
    - - +
    +
    +
    + {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - + {headerGroup.headers.map((header) => { + const columnRelativeDepth = + header.depth - header.column.depth; + if ( + !header.isPlaceholder && + columnRelativeDepth > 1 && + header.id === header.column.id + ) { + return null; + } + let rowSpan = 1; + if (header.isPlaceholder) { + const leafs = header.getLeafHeaders(); + rowSpan = leafs[leafs.length - 1].depth - header.depth; + } + return ( + - ))} + {header.column.getCanSort() && ( +
    + + +
    + )} + + + ); + })} ))} - + {table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( - ))} + + {renderFooter && ( + + {table.getAllLeafColumns().map((column) => ( + + ))} + + )} +
    -
    - {flexRender( - header.column.columnDef.header, - header.getContext() +
    1, + }, + tableClassNames.headerColumnClassName )} + > +
    1, + })} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} - {header.column.getCanSort() && ( -
    - - -
    - )} -
    -
    + {!isLoading && flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -262,6 +324,28 @@ const Table = ({
    + {column.columnDef.footer && + flexRender(column.columnDef.footer, { + column, + header: column.columnDef, + table, + } as HeaderContext)} +
    @@ -270,7 +354,7 @@ const Table = ({ emptyContent} {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && ( -
    +
    ({ onPrevPage={prevPageClickHandler} onNextPage={nextPageClickHandler} onPageChange={pageChangeHandler} + rowOptions={rowOptions} + onRowChange={onPageSizeChange} />
    )} diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx new file mode 100644 index 00000000..4489231d --- /dev/null +++ b/src/components/dropdown/Dropdown.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { ReactNode, useRef, useEffect, useState } from 'react'; +import { cn } from '@/lib/helper'; + +interface DropdownProps { + trigger: ReactNode; + children: ReactNode; + position?: + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end' + | 'right-start' + | 'right-end'; + align?: 'start' | 'center' | 'end'; + hover?: boolean; + className?: string; + contentClassName?: string; +} + +const Dropdown = ({ + trigger, + children, + position = 'bottom', + align = 'start', + hover = false, + className, + contentClassName, +}: DropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Build position classes + const getPositionClasses = () => { + const classes: string[] = []; + + // Handle combined positions like 'top-start' + if (position.includes('-')) { + const [pos, al] = position.split('-'); + classes.push(`dropdown-${pos}`); + classes.push(`dropdown-${al}`); + } else { + classes.push(`dropdown-${position}`); + if (align !== 'start') { + classes.push(`dropdown-${align}`); + } + } + + return classes.join(' '); + }; + + const handleToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // alert('clicked'); + setIsOpen(!isOpen); + }; + + return ( +
    + {/* Trigger Button */} +
    + {trigger} +
    + + {/* Dropdown Content - Only render when open */} + {isOpen && ( +
    setIsOpen(false)} // Close on item click + > + {children} +
    + )} +
    + ); +}; + +export default Dropdown; diff --git a/src/components/helper/drawer/DrawerHeader.tsx b/src/components/helper/drawer/DrawerHeader.tsx new file mode 100644 index 00000000..f9d70a04 --- /dev/null +++ b/src/components/helper/drawer/DrawerHeader.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { Icon } from '@iconify/react'; +import Link from 'next/link'; +import { ReactNode } from 'react'; +import { cn } from '@/lib/helper'; + +export interface DrawerHeaderProps { + // Left side props + leftIcon?: string; + leftIconSize?: number; + leftIconHref?: string; + leftIconOnClick?: () => void; + leftIconClassName?: string; + + // Subtitle/label props + subtitle?: string | ReactNode; + subtitleClassName?: string; + + // Right side actions (children) + children?: ReactNode; + + // Container props + className?: string; + showDivider?: boolean; +} + +const DrawerHeader = ({ + leftIcon = 'mdi:close', + leftIconSize = 24, + leftIconHref, + leftIconOnClick, + leftIconClassName, + subtitle, + subtitleClassName, + children, + className, + showDivider = true, +}: DrawerHeaderProps) => { + const renderLeftIcon = () => { + const iconElement = ( + + ); + + if (leftIconHref) { + return ( + + {iconElement} + + ); + } + + if (leftIconOnClick) { + return ( + + ); + } + + return iconElement; + }; + + return ( +
    + {/* Left Side */} +
    + {renderLeftIcon()} + + {showDivider && subtitle && ( +
    + )} + + {subtitle && ( +
    + {subtitle} +
    + )} +
    + + {/* Right Side Actions */} + {children && ( +
    + {children} +
    + )} +
    + ); +}; + +export default DrawerHeader; diff --git a/src/components/input/CheckboxInput.tsx b/src/components/input/CheckboxInput.tsx index fb0c95c7..32f14f94 100644 --- a/src/components/input/CheckboxInput.tsx +++ b/src/components/input/CheckboxInput.tsx @@ -2,8 +2,9 @@ import { HTMLProps, useEffect, useRef } from 'react'; import { cn } from '@/lib/helper'; +import { Size } from '@/types/theme'; -interface CheckboxInputProps extends HTMLProps { +interface CheckboxInputProps extends Omit, 'size'> { name: string; label?: string; indeterminate?: boolean; @@ -16,6 +17,7 @@ interface CheckboxInputProps extends HTMLProps { isError?: boolean; isValid?: boolean; errorMessage?: string; + size?: Size; } const CheckboxInput = ({ @@ -27,10 +29,19 @@ const CheckboxInput = ({ isValid, isError, errorMessage, + size = 'sm', ...rest }: CheckboxInputProps) => { const ref = useRef(null!); + const checkboxBaseClassName = cn('checkbox cursor-pointer rounded-md', { + 'checkbox-xs': size === 'xs', + 'checkbox-sm': size === 'sm', + 'checkbox-md': size === 'md', + 'checkbox-lg': size === 'lg', + 'checkbox-xl': size === 'xl', + }); + useEffect(() => { if (typeof indeterminate === 'boolean') { ref.current.indeterminate = !rest.checked && indeterminate; @@ -53,7 +64,7 @@ const CheckboxInput = ({ id={name} name={name} className={cn( - 'checkbox cursor-pointer', + checkboxBaseClassName, { 'border-error': isError, 'border-success': isValid, diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 77267090..2d55fe6d 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -7,11 +7,11 @@ import { useState, } from 'react'; import { cn, formatDate } from '@/lib/helper'; -import Modal, { useModal } from '../Modal'; import { DateRange, DayPicker, Matcher } from 'react-day-picker'; import 'react-day-picker/dist/style.css'; -import Button from '../Button'; import { Icon } from '@iconify/react'; +import Modal, { useModal } from '@/components/Modal'; +import Button from '@/components/Button'; export interface DateInputProps { label?: string; diff --git a/src/components/input/RadioInput.tsx b/src/components/input/RadioInput.tsx index 71a731aa..e508e7ba 100644 --- a/src/components/input/RadioInput.tsx +++ b/src/components/input/RadioInput.tsx @@ -1,6 +1,11 @@ 'use client'; -import { ChangeEventHandler, ReactNode } from 'react'; +import { + ChangeEventHandler, + ReactNode, + createContext, + useContext, +} from 'react'; import { cn } from '@/lib/helper'; export interface RadioOption { @@ -8,37 +13,74 @@ export interface RadioOption { value: string; } -export interface RadioInputProps { - label?: string; - bottomLabel?: string; +// DaisyUI Radio Colors +export type RadioColor = + | 'neutral' + | 'primary' + | 'secondary' + | 'accent' + | 'success' + | 'warning' + | 'info' + | 'error'; + +// DaisyUI Radio Sizes +export type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +// Context untuk RadioGroup +interface RadioGroupContextValue { name: string; value?: string; - options: RadioOption[]; - variant?: string; - className?: { - wrapper?: string; - label?: string; - radioWrapper?: string; - radio?: string; - }; - isError?: boolean; - isValid?: boolean; - errorMessage?: string; - required?: boolean; + color?: RadioColor; + size?: RadioSize; disabled?: boolean; - startAdornment?: ReactNode; - endAdornment?: ReactNode; onChange?: ChangeEventHandler; onBlur?: (e: React.FocusEvent) => void; } -const RadioInput = ({ +const RadioGroupContext = createContext( + undefined +); + +const useRadioGroup = () => { + const context = useContext(RadioGroupContext); + if (!context) { + throw new Error('RadioGroupItem must be used within RadioGroup'); + } + return context; +}; + +// RadioGroup Component +export interface RadioGroupProps { + label?: string; + bottomLabel?: string; + name: string; + value?: string; + options?: RadioOption[]; + color?: RadioColor; + size?: RadioSize; + className?: { + wrapper?: string; + label?: string; + radioWrapper?: string; + }; + isError?: boolean; + errorMessage?: string; + required?: boolean; + disabled?: boolean; + onChange?: ChangeEventHandler; + onBlur?: (e: React.FocusEvent) => void; + children?: ReactNode; +} + +export const RadioGroup = ({ label, bottomLabel, name, value, options, - variant = 'radio-primary', + color = 'primary', + size = 'md', className, isError, errorMessage, @@ -46,68 +88,125 @@ const RadioInput = ({ disabled = false, onChange, onBlur, -}: RadioInputProps) => { - return ( -
    - {/* Label atas */} - {label && ( - - )} + children, +}: RadioGroupProps) => { + const contextValue: RadioGroupContextValue = { + name, + value, + color, + size, + disabled, + onChange, + onBlur, + }; - {/* Daftar opsi radio */} -
    - {options.map((option) => ( + return ( + +
    + {/* Label atas */} + {label && ( - ))} + )} + + {/* Daftar opsi radio */} +
    + {/* Jika options diberikan, render otomatis */} + {options && + options.map((option) => ( + + ))} + + {/* Atau gunakan children untuk custom rendering */} + {children} +
    + + {/* Label bawah */} + {!isError && bottomLabel && ( +

    {bottomLabel}

    + )} + + {/* Pesan error */} + {isError && errorMessage && ( +

    {errorMessage}

    + )}
    - - {/* Label bawah */} - {!isError && bottomLabel && ( -

    {bottomLabel}

    - )} - - {/* Pesan error */} - {isError && errorMessage && ( -

    {errorMessage}

    - )} -
    + ); }; -export default RadioInput; +// RadioGroupItem Component +export interface RadioGroupItemProps { + value: string; + label?: string; + className?: string; + disabled?: boolean; + color?: RadioColor; + size?: RadioSize; +} + +export const RadioGroupItem = ({ + value, + label, + className, + disabled: itemDisabled, + color: itemColor, + size: itemSize, +}: RadioGroupItemProps) => { + const { + name, + value: groupValue, + color: groupColor, + size: groupSize, + disabled: groupDisabled, + onChange, + onBlur, + } = useRadioGroup(); + + const isDisabled = itemDisabled ?? groupDisabled; + const radioColor = itemColor ?? groupColor; + const radioSize = itemSize ?? groupSize; + + return ( + + ); +}; diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index b3981065..ae74717d 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -1,16 +1,32 @@ import { ReactNode } from 'react'; import { cn } from '@/lib/helper'; +import { Size } from '@/types/theme'; interface MenuProps { children?: ReactNode; + size?: Size; + direction?: 'vertical' | 'horizontal'; className?: string; } -const Menu = ({ children, className }: MenuProps) => { - return ( -
      {children}
    - ); +const Menu = ({ + children, + size = 'md', + direction = 'vertical', + className, +}: MenuProps) => { + const menuBaseClassName = cn('menu w-full', { + 'menu-xs': size === 'xs', + 'menu-sm': size === 'sm', + 'menu-md': size === 'md', + 'menu-lg': size === 'lg', + 'menu-xl': size === 'xl', + 'menu-vertical': direction === 'vertical', + 'menu-horizontal': direction === 'horizontal', + }); + + return
      {children}
    ; }; export default Menu; diff --git a/src/components/molecules/SidebarMenu.tsx b/src/components/molecules/SidebarMenu.tsx new file mode 100644 index 00000000..6a217dcc --- /dev/null +++ b/src/components/molecules/SidebarMenu.tsx @@ -0,0 +1,92 @@ +import Link from 'next/link'; +import Menu from '@/components/menu/Menu'; +import { Icon } from '@iconify/react'; +import { cn, isPathActive } from '@/lib/helper'; + +export interface SidebarMenuItem { + type?: 'item' | 'title'; + text: string; + link: string; + icon?: string; + submenu?: SidebarMenuItem[]; +} + +interface SidebarMenuItemProps { + item: SidebarMenuItem; + activeLink: string; +} + +interface SidebarMenuProps { + menu: SidebarMenuItem[]; + activeLink: string; +} + +const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => { + const isItemActive = isPathActive(activeLink, item.link); + + const menuItemWithoutSubmenu = ( +
  • + + {item.icon && } + + {item.text} + +
  • + ); + + if (!item.submenu || item.submenu.length === 0) { + return menuItemWithoutSubmenu; + } + + const menuItemWithSubmenu = ( +
  • +
    + + {item.icon && } + + {item.text} + + +
      + {item.submenu.map((submenuItem, submenuIdx) => ( + + ))} +
    +
    +
  • + ); + + return menuItemWithSubmenu; +}; + +const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => { + return ( + + {menu.map((menuItem, menuIdx) => ( + + ))} + + ); +}; + +export default SidebarMenu; diff --git a/src/components/pages/closing/ClosingDetail.tsx b/src/components/pages/closing/ClosingDetail.tsx new file mode 100644 index 00000000..147b3fbd --- /dev/null +++ b/src/components/pages/closing/ClosingDetail.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import Tabs from '@/components/Tabs'; +import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable'; + +import { ClosingGeneralInformation } from '@/types/api/closing'; +import ClosingSapronakTabContent from './ClosingSapronakTabContent'; + +interface ClosingDetailProps { + id: number; + initialValue?: ClosingGeneralInformation; +} + +const ClosingDetail: React.FC = ({ id, initialValue }) => { + const [activeTab, setActiveTab] = useState('sapronak'); + + const closingDetailTabs = useMemo(() => { + const validTabs = [ + { + id: 'sapronak', + label: 'Sapronak', + content: , + }, + { + id: 'perhitunganSapronak', + label: 'Perhitungan Sapronak', + content: 'Perhitungan Sapronak', + }, + { + id: 'penjualan', + label: 'Penjualan', + content: 'Penjualan', + }, + { + id: 'overhead', + label: 'Overhead', + content: 'Overhead', + }, + { + id: 'hppEkspedisi', + label: 'HPP Ekspedisi', + content: 'HPP Ekspedisi', + }, + { + id: 'dataProduksi', + label: 'Data Produksi', + content: 'Data Produksi', + }, + { + id: 'keuangan', + label: 'Keuangan', + content: 'Keuangan', + }, + ]; + + return validTabs; + }, [initialValue]); + + return ( + <> +
    +
    + + +

    Detail Closing

    +
    + + + + +
    + + ); +}; + +export default ClosingDetail; diff --git a/src/components/pages/closing/ClosingGeneralInformationTable.tsx b/src/components/pages/closing/ClosingGeneralInformationTable.tsx new file mode 100644 index 00000000..af21497a --- /dev/null +++ b/src/components/pages/closing/ClosingGeneralInformationTable.tsx @@ -0,0 +1,100 @@ +import { ClosingGeneralInformation } from '@/types/api/closing'; + +interface ClosingGeneralInformationProps { + initialValue?: ClosingGeneralInformation; +} + +const ClosingGeneralInformationTable = ({ + initialValue, +}: ClosingGeneralInformationProps) => { + return ( +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Lokasi:{initialValue?.location_name}
    Periode:{initialValue?.period}
    Kategori:{initialValue?.project_category}
    Populasi:{initialValue?.population} Ekor
    Jenis Project:{initialValue?.project_type}
    Kandang Aktif:{initialValue?.active_house_count} Kandang
    Status Pembayaran Penjualan:{initialValue?.sales_payment_status}
    Status Project:{initialValue?.project_status}
    Status Closing:{initialValue?.closing_status}
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    Kandang Aktif:{initialValue?.active_house_count} Kandang
    Status Pembayaran Penjualan:{initialValue?.sales_payment_status}
    Status Project:{initialValue?.project_status}
    Status Closing:{initialValue?.closing_status}
    +
    +
    +
    +
    + ); +}; + +export default ClosingGeneralInformationTable; diff --git a/src/components/pages/closing/ClosingIncomingSapronaksTable.tsx b/src/components/pages/closing/ClosingIncomingSapronaksTable.tsx new file mode 100644 index 00000000..53e45710 --- /dev/null +++ b/src/components/pages/closing/ClosingIncomingSapronaksTable.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { ColumnDef, SortingState } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Card from '@/components/Card'; +import Collapse from '@/components/Collapse'; + +import { cn, formatDate, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ClosingApi } from '@/services/api/closing'; +import { ClosingIncomingSapronak } from '@/types/api/closing'; + +interface ClosingIncomingSapronaksTableProps { + projectFlockId: number; +} + +const ClosingIncomingSapronaksTable = ({ + projectFlockId, +}: ClosingIncomingSapronaksTableProps) => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + }, + }); + + const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } = + useSWR( + `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`, + ClosingApi.getAllIncomingSapronakFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + + const incomingSapronaksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'date', + header: 'Tanggal', + cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'), + }, + { + accessorKey: 'reference_number', + header: 'No. Referensi', + }, + { + accessorKey: 'transaction_type', + header: 'Jenis Transaksi', + }, + { + accessorKey: 'product_name', + header: 'Produk', + }, + { + accessorKey: 'product_category', + header: 'Kategori Produk', + }, + { + accessorKey: 'source_warehouse', + header: 'Gudang Asal', + }, + { + accessorKey: 'destination_warehouse', + header: 'Gudang Tujuan', + }, + { + accessorKey: 'quantity', + header: 'Kuantitas', + cell: (props) => + `${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`, + }, + { + accessorKey: 'notes', + header: 'Keterangan', + }, + ]; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(incomingSapronaks) + ? incomingSapronaks.data.length > 0 + : false + ); + } + }, [incomingSapronaks, isResponseSuccess]); + + return ( + + +
    Sapronak Masuk
    + + +
+ } + className='w-full!' + titleClassName='w-full p-0!' + > +
+
+
+ +
+
+ + + data={ + isResponseSuccess(incomingSapronaks) + ? incomingSapronaks?.data + : [] + } + columns={incomingSapronaksColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(incomingSapronaks) + ? incomingSapronaks?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(incomingSapronaks) + ? incomingSapronaks?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoadingIncomingSapronaks} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(incomingSapronaks) && + incomingSapronaks?.data?.length === 0, + }), + }} + /> +
+ + + ); +}; + +export default ClosingIncomingSapronaksTable; diff --git a/src/components/pages/closing/ClosingOutgoingSapronaksTable.tsx b/src/components/pages/closing/ClosingOutgoingSapronaksTable.tsx new file mode 100644 index 00000000..5662cff1 --- /dev/null +++ b/src/components/pages/closing/ClosingOutgoingSapronaksTable.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { ColumnDef, SortingState } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Card from '@/components/Card'; +import Collapse from '@/components/Collapse'; + +import { cn, formatDate, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ClosingApi } from '@/services/api/closing'; +import { ClosingOutgoingSapronak } from '@/types/api/closing'; + +interface ClosingOutgoingSapronaksTableProps { + projectFlockId: number; +} + +const ClosingOutgoingSapronaksTable = ({ + projectFlockId, +}: ClosingOutgoingSapronaksTableProps) => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + }, + }); + + const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } = + useSWR( + `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`, + ClosingApi.getAllOutgoingSapronakFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + + const outgoingSapronaksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'date', + header: 'Tanggal', + cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'), + }, + { + accessorKey: 'reference_number', + header: 'No. Referensi', + }, + { + accessorKey: 'transaction_type', + header: 'Jenis Transaksi', + }, + { + accessorKey: 'product_name', + header: 'Produk', + }, + { + accessorKey: 'product_category', + header: 'Kategori Produk', + }, + { + accessorKey: 'source_warehouse', + header: 'Gudang Asal', + }, + { + accessorKey: 'destination_warehouse', + header: 'Gudang Tujuan', + }, + { + accessorKey: 'quantity', + header: 'Kuantitas', + cell: (props) => + `${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`, + }, + { + accessorKey: 'notes', + header: 'Keterangan', + }, + ]; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(outgoingSapronaks) + ? outgoingSapronaks.data.length > 0 + : false + ); + } + }, [outgoingSapronaks, isResponseSuccess]); + + return ( + + +
Sapronak Keluar
+ + +
+ } + className='w-full!' + titleClassName='w-full p-0!' + > +
+
+
+ +
+
+ + + data={ + isResponseSuccess(outgoingSapronaks) + ? outgoingSapronaks?.data + : [] + } + columns={outgoingSapronaksColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(outgoingSapronaks) + ? outgoingSapronaks?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(outgoingSapronaks) + ? outgoingSapronaks?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoadingOutgoingSapronaks} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(outgoingSapronaks) && + outgoingSapronaks?.data?.length === 0, + }), + }} + /> +
+ + + ); +}; + +export default ClosingOutgoingSapronaksTable; diff --git a/src/components/pages/closing/ClosingSapronakTabContent.tsx b/src/components/pages/closing/ClosingSapronakTabContent.tsx new file mode 100644 index 00000000..41c7aa05 --- /dev/null +++ b/src/components/pages/closing/ClosingSapronakTabContent.tsx @@ -0,0 +1,26 @@ +'use client'; + +import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; +import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; + +interface ClosingSapronakTableProps { + projectFlockId?: number; +} + +const ClosingSapronakTabContent = ({ + projectFlockId, +}: ClosingSapronakTableProps) => { + return ( +
+ {projectFlockId && ( + <> + + + + + )} +
+ ); +}; + +export default ClosingSapronakTabContent; diff --git a/src/components/pages/closing/ClosingsTable.tsx b/src/components/pages/closing/ClosingsTable.tsx new file mode 100644 index 00000000..91e78c8c --- /dev/null +++ b/src/components/pages/closing/ClosingsTable.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +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 SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; +import { cn, formatCurrency, formatDate } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { LocationApi } from '@/services/api/master-data'; +import { Location } from '@/types/api/master-data/location'; +import { ClosingApi } from '@/services/api/closing'; +import { Closing } from '@/types/api/closing'; + +const PROJECT_STATUS_OPTIONS = [ + { + value: 1, + label: 'Pengajuan', + }, + { + value: 2, + label: 'Aktif', + }, +]; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; +}) => { + return ( + + {/* TODO: apply RBAC */} +
+ +
+
+ ); +}; + +const ClosingsTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + transactionDate: '', + realizationDate: '', + locationId: '', + projectStatus: '', + userId: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + transactionDate: 'transaction_date', + realizationDate: 'realization_date', + locationId: 'location_id', + projectStatus: 'project_status', + userId: 'user_id', + }, + }); + + const { data: closings, isLoading: isLoadingClosings } = useSWR( + `${ClosingApi.basePath}${getTableFilterQueryString()}`, + ClosingApi.getAllFetcher + ); + + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + + const closingsColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'location_name', + header: 'Lokasi', + }, + { + accessorKey: 'project_category', + header: 'Kategori', + }, + { + accessorKey: 'period', + header: 'Periode', + }, + { + accessorKey: 'closing_date', + header: 'Periode', + cell: (props) => + formatDate(props.row.original.closing_date, 'DD MMM YYYY'), + }, + { + accessorKey: 'shed_label', + header: 'Jumlah Kandang', + }, + { + accessorKey: 'sales_paid_amount', + header: 'Jumlah Sudah Bayar', + cell: (props) => ( + + {formatCurrency(props.row.original.sales_paid_amount)} + + ), + }, + { + accessorKey: 'sales_remaining_amount', + header: 'Jumlah Sisa Bayar', + cell: (props) => ( + + {formatCurrency(props.row.original.sales_remaining_amount)} + + ), + }, + { + accessorKey: 'sales_payment_status', + header: 'Status Pembayaran', + }, + { + accessorKey: 'project_status', + header: 'Status', + }, + { + 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 - 3; + + return ( + <> + {currentPageSize > 3 && ( + + + + )} + + {currentPageSize <= 3 && ( + + + + )} + + ); + }, + }, + ]; + + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + } = useSelect(LocationApi.basePath, 'id', 'name'); + + const [selectedLocation, setSelectedLocation] = useState( + null + ); + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + updateFilter( + 'locationId', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedProjectStatus, setSelectedProjectStatus] = + useState(null); + + const projectStatusChangeHandler = ( + val: OptionType | OptionType[] | null + ) => { + setSelectedProjectStatus(val as OptionType); + updateFilter( + 'projectStatus', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + return ( + <> +
+
+
+
+ +
+ +
+ + + +
+
+
+ + + data={isResponseSuccess(closings) ? closings?.data : []} + columns={closingsColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={isResponseSuccess(closings) ? closings?.meta?.page : 0} + totalItems={ + isResponseSuccess(closings) ? closings?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoadingClosings} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(closings) && closings?.data?.length === 0, + }), + }} + /> +
+ + ); +}; + +export default ClosingsTable; diff --git a/src/components/pages/closing/sale/SalesReportTable.tsx b/src/components/pages/closing/sale/SalesReportTable.tsx new file mode 100644 index 00000000..e509eb7d --- /dev/null +++ b/src/components/pages/closing/sale/SalesReportTable.tsx @@ -0,0 +1,285 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import Table from '@/components/Table'; +import Card from '@/components/Card'; +import Badge from '@/components/Badge'; +import { formatCurrency, formatNumber, formatDate } from '@/lib/helper'; +import { BaseClosingSales, BaseSales } from '@/types/api/closing'; +import { Product } from '@/types/api/master-data/product'; +import { Customer } from '@/types/api/master-data/customer'; +import { Kandang } from '@/types/api/master-data/kandang'; + +interface SalesReportTableProps { + type?: 'detail'; + initialValues?: BaseClosingSales; +} + +const SalesReportTable = ({ + type = 'detail', + initialValues, +}: SalesReportTableProps) => { + const salesData: BaseSales[] = useMemo(() => { + return initialValues?.sales || []; + }, [initialValues]); + + const totals = useMemo(() => { + if (salesData.length === 0) { + return { + totalQuantity: 0, + totalWeight: 0, + avgWeight: 0, + avgPricePartner: 0, + totalPartner: 0, + }; + } + + const totalQuantity = salesData.reduce( + (sum, item) => sum + (item.qty || 0), + 0 + ); + const totalWeight = salesData.reduce( + (sum, item) => sum + (item.weight || 0), + 0 + ); + const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0; + + const validPriceItems = salesData.filter( + (item) => item.price != null && item.price > 0 + ); + const avgPricePartner = + validPriceItems.length > 0 + ? validPriceItems.reduce((sum, item) => sum + item.price, 0) / + validPriceItems.length + : 0; + + const totalPartner = salesData.reduce( + (sum, item) => sum + (item.total_price || 0), + 0 + ); + + return { + totalQuantity, + totalWeight, + avgWeight, + avgPricePartner, + totalPartner, + }; + }, [salesData]); + + const salesColumns: ColumnDef[] = useMemo( + () => [ + { + id: 'realization_date', + accessorKey: 'realization_date', + header: 'Tanggal Realisasi', + cell: (props) => { + const date = props.row.original.realization_date; + return date ? formatDate(date, 'DD MMM YYYY') : '-'; + }, + footer: () => ( +
Total Penjualan
+ ), + }, + { + id: 'age', + accessorKey: 'age', + header: 'Umur', + cell: (props) => props.getValue() || '-', + }, + { + id: 'do_number', + accessorKey: 'do_number', + header: 'No. DO', + cell: (props) => props.getValue() || '-', + }, + { + id: 'product', + accessorKey: 'product', + header: 'Produk', + cell: (props) => { + const product = props.getValue() as Product; + return product?.name || '-'; + }, + }, + { + id: 'customer', + accessorKey: 'customer', + header: 'Customer', + cell: (props) => { + const customer = props.getValue() as Customer; + return customer?.name || '-'; + }, + }, + { + id: 'jumlah', + header: 'Jumlah', + columns: [ + { + id: 'qty', + accessorKey: 'qty', + header: 'Kuantitas', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.totalQuantity)} +
+ ), + }, + { + id: 'weight', + accessorKey: 'weight', + header: 'Kg', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.totalWeight)} +
+ ), + }, + ], + }, + { + id: 'avg_weight', + accessorKey: 'avg_weight', + header: 'AVG (Kg)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.avgWeight)} +
+ ), + }, + { + id: 'price_partner', + accessorKey: 'price', + header: 'Harga Mitra (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.avgPricePartner)} +
+ ), + }, + { + id: 'total_mitra', + accessorKey: 'total_price', + header: 'Total Mitra (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.totalPartner)} +
+ ), + }, + { + id: 'price_act', + accessorKey: 'price', + header: 'Harga Act (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + }, + { + id: 'total_act', + accessorKey: 'total_price', + header: 'Total Act (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + }, + { + id: 'kandang', + accessorKey: 'kandang', + header: 'Kandang', + cell: (props) => { + const kandang = props.getValue() as Kandang; + return kandang?.name || '-'; + }, + }, + { + id: 'payment_status', + accessorKey: 'payment_status', + header: 'Status Pembayaran', + cell: (props) => { + const status = props.getValue() as string; + const getStatusColor = (status: string) => { + if (!status) return 'neutral'; + switch (status.toLowerCase()) { + case 'paid': + return 'success'; + case 'tempo': + return 'warning'; + default: + return 'neutral'; + } + }; + + return ( + + {status || '-'} + + ); + }, + }, + ], + [] + ); + + return ( + <> +
+
+

Penjualan

+ + 0} + className={{ + tableWrapperClassName: 'overflow-x-auto', + tableClassName: 'w-full table-auto text-sm', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0', + bodyRowClassName: + 'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', + tableFooterClassName: + 'bg-gray-100 font-semibold border border-gray-200', + footerRowClassName: 'border-t-2 border-gray-300', + footerColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', + }} + /> + + + + + ); +}; + +export default SalesReportTable; diff --git a/src/components/pages/expense/ExpenseRealizationContent.tsx b/src/components/pages/expense/ExpenseRealizationContent.tsx index 478cdadf..2b5b0a0a 100644 --- a/src/components/pages/expense/ExpenseRealizationContent.tsx +++ b/src/components/pages/expense/ExpenseRealizationContent.tsx @@ -207,7 +207,7 @@ const ExpenseRealizationContent = ({ let expenseGrandTotal = 0; kandangExpense.pengajuans?.forEach( - (item) => (expenseGrandTotal += item.total_price) + (item) => (expenseGrandTotal += item.price) ); return ( @@ -238,7 +238,7 @@ const ExpenseRealizationContent = ({ - + ) @@ -269,7 +269,7 @@ const ExpenseRealizationContent = ({ let expenseGrandTotal = 0; kandangExpense.realisasi?.forEach( - (item) => (expenseGrandTotal += item.total_price) + (item) => (expenseGrandTotal += item.price) ); return ( @@ -300,7 +300,7 @@ const ExpenseRealizationContent = ({ - + ) diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx index af8ceddc..0d7d959d 100644 --- a/src/components/pages/expense/ExpenseRequestContent.tsx +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -402,7 +402,10 @@ const ExpenseRequestContent = ({ @@ -529,7 +532,7 @@ const ExpenseRequestContent = ({ let expenseGrandTotal = 0; kandangExpense.pengajuans?.forEach( - (item) => (expenseGrandTotal += item.total_price) + (item) => (expenseGrandTotal += item.price) ); return ( @@ -550,7 +553,7 @@ const ExpenseRequestContent = ({ - + @@ -560,9 +563,7 @@ const ExpenseRequestContent = ({ - + diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index 3a50f233..bbcb6c4e 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -263,11 +263,11 @@ const ExpensesTable = () => { }, }, { - accessorKey: 'expense_date', + accessorKey: 'transaction_date', header: 'Tanggal Pengajuan', cell: (props) => - props.row.original.expense_date - ? formatDate(props.row.original.expense_date, 'DD MMM YYYY') + props.row.original.transaction_date + ? formatDate(props.row.original.transaction_date, 'DD MMM YYYY') : '-', }, { diff --git a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts index 863238b9..77db761c 100644 --- a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts @@ -27,7 +27,7 @@ type ExpenseRealizationFormSchemaType = { label: string; }; quantity?: number; - total_cost?: number; + price?: number; notes?: string; }[]; }[]; @@ -82,7 +82,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema { realization.cost_items.forEach((costItem) => { - const unitPrice = - parseFloat(String(costItem.total_cost)) / - parseFloat(String(costItem.quantity)); - const realizationItem = { expense_nonstock_id: costItem.nonstock?.value as number, qty: parseFloat(String(costItem.quantity)) as number, - unit_price: unitPrice, - total_price: parseFloat(String(costItem.total_cost)) as number, + price: parseFloat(String(costItem.price)) as number, notes: costItem.notes ?? '', }; @@ -177,7 +172,7 @@ const ExpenseRealizationForm = ({ { nonstock: undefined, quantity: undefined, - total_cost: undefined, + price: undefined, notes: '', }, ], diff --git a/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx index 8b889c5b..017a733e 100644 --- a/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx +++ b/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx @@ -48,7 +48,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC< }; const isExpenseRepeaterInputError = ( - column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', + column: 'nonstock' | 'quantity' | 'price' | 'notes', kandangExpenseIdx: number, expenseIdx: number ) => { @@ -112,7 +112,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC< - + @@ -163,17 +163,17 @@ const ExpenseRealizationKandangDetailExpense: React.FC< - + {type !== 'detail' && } @@ -178,10 +178,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
{pengajuanItem.nonstock.name} {pengajuanItem.qty}{formatCurrency(pengajuanItem.total_price)}{formatCurrency(pengajuanItem.price)} {pengajuanItem.note ?? '-'}
{realisasiItem.nonstock.name} {realisasiItem.qty}{formatCurrency(realisasiItem.total_price)}{formatCurrency(realisasiItem.price)} {realisasiItem.note ?? '-'}
Tanggal Transaksi : - {formatDate(initialValues?.expense_date, 'DD MMMM YYYY')} + {formatDate( + initialValues?.transaction_date, + 'DD MMMM YYYY' + )}
Nonstock Total KuantitasTotal BiayaHarga Satuan Catatan
{pengajuanItem.nonstock.name} {pengajuanItem.qty} - {formatCurrency(pengajuanItem.total_price)} - {formatCurrency(pengajuanItem.price)} {pengajuanItem.note ?? '-'}
Nonstock Total KuantitasTotal BiayaHarga Satuan Catatan
= documents: Yup.array().of(Yup.mixed().required()).optional(), - cost_per_kandangs: Yup.array() + expense_nonstocks: Yup.array() .of( Yup.object({ kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), @@ -86,7 +86,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema = label: Yup.string().required(), }).required('Nonstock wajib diisi!'), quantity: Yup.number().required('Total kuantitas wajib diisi!'), - total_cost: Yup.number().required('Total biaya wajib diisi!'), + price: Yup.number().required('Harga satuan wajib diisi!'), notes: Yup.string(), }) ) @@ -128,8 +128,8 @@ export const getExpenseFormInitialValues = ( label: initialValues.location.name, } : undefined, - transaction_date: initialValues?.expense_date - ? formatDate(initialValues.expense_date, 'YYYY-MM-DD') + transaction_date: initialValues?.transaction_date + ? formatDate(initialValues.transaction_date, 'YYYY-MM-DD') : undefined, kandangs: initialValues?.kandangs.map((kandang) => ({ id: kandang.kandang_id, @@ -148,7 +148,7 @@ export const getExpenseFormInitialValues = ( })), deleted_documents: [], documents: [], - cost_per_kandangs: initialValues?.kandangs + expense_nonstocks: initialValues?.kandangs ? initialValues.kandangs.map((kandangExpense) => ({ kandang_id: kandangExpense.kandang_id, cost_items: kandangExpense.pengajuans @@ -158,7 +158,7 @@ export const getExpenseFormInitialValues = ( label: expenseItem.nonstock.name, }, quantity: expenseItem.qty, - total_cost: expenseItem.total_price, + price: expenseItem.price, notes: expenseItem.note, })) : [], diff --git a/src/components/pages/expense/form/ExpenseRequestForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx index e47f2f76..d52bde0d 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.tsx +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -110,12 +110,12 @@ const ExpenseRequestForm = ({ transaction_date: values?.transaction_date as string, supplier_id: values.supplier?.value as number, documents: values.documents as File[], - cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({ - kandang_id: costPerKandang.kandang_id, - cost_items: costPerKandang.cost_items.map((costItem) => ({ + expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({ + kandang_id: expenseNonstock.kandang_id, + cost_items: expenseNonstock.cost_items.map((costItem) => ({ nonstock_id: costItem.nonstock?.value as number, quantity: parseFloat(String(costItem.quantity)) as number, - total_cost: parseFloat(String(costItem.total_cost)) as number, + price: parseFloat(String(costItem.price)) as number, notes: costItem.notes ?? '', })), })), @@ -132,13 +132,13 @@ const ExpenseRequestForm = ({ transaction_date: values?.transaction_date as string, supplier_id: values.supplier?.value as number, documents: values.documents as File[], - cost_per_kandang: values.cost_per_kandangs.map( - (costPerKandang) => ({ - kandang_id: costPerKandang.kandang_id, - cost_items: costPerKandang.cost_items.map((costItem) => ({ + expense_nonstocks: values.expense_nonstocks.map( + (expenseNonstock) => ({ + kandang_id: expenseNonstock.kandang_id, + cost_items: expenseNonstock.cost_items.map((costItem) => ({ nonstock_id: costItem.nonstock?.value as number, quantity: parseFloat(String(costItem.quantity)) as number, - total_cost: parseFloat(String(costItem.total_cost)) as number, + price: parseFloat(String(costItem.price)) as number, notes: costItem.notes ?? '', })), }) @@ -179,53 +179,54 @@ const ExpenseRequestForm = ({ formik.setFieldValue('location', val); formik.setFieldValue('kandangs', []); - formik.setFieldValue('cost_per_kandangs', []); + formik.setFieldValue('expense_nonstocks', []); }; const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { formik.setFieldTouched('kandangs', true); formik.setFieldValue('kandangs', kandangs); - const newCostPerKandangs = [...(formik.values.cost_per_kandangs ?? [])]; + const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])]; - // add new cost_per_kandangs + // add new expense_nonstocks kandangs.forEach((kandangItem) => { - const isKandangExistInCostPerKandangs = newCostPerKandangs.find( - (costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id + const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find( + (expenseNonstockItem) => + expenseNonstockItem.kandang_id === kandangItem.id ); - if (isKandangExistInCostPerKandangs) return; + if (isKandangExistInExpenseNonstocks) return; - newCostPerKandangs.push({ + newExpenseNonstocks.push({ kandang_id: kandangItem.id, cost_items: [ { nonstock: undefined, quantity: undefined, - total_cost: undefined, + price: undefined, notes: '', }, ], }); }); - // prune cost_per_kandangs + // prune expense_nonstocks const kandangIds = new Set(kandangs.map((kandang) => kandang.id)); - const deletedCostPerKandangsIdx: number[] = []; + const deletedExpenseNonstocksIdx: number[] = []; - newCostPerKandangs.forEach((costPerKandang, idx) => { - const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id); + newExpenseNonstocks.forEach((expenseNonstock, idx) => { + const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id); - if (!isCostPerKandangValid) { - deletedCostPerKandangsIdx.push(idx); + if (!isExpenseNonstockValid) { + deletedExpenseNonstocksIdx.push(idx); } }); - deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => { - newCostPerKandangs.splice(deletedCostPerKandangIdx, 1); + deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => { + newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1); }); - formik.setFieldValue('cost_per_kandangs', newCostPerKandangs); + formik.setFieldValue('expense_nonstocks', newExpenseNonstocks); }; const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { diff --git a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx index 73e6c9b7..11f54585 100644 --- a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx +++ b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx @@ -41,28 +41,28 @@ const ExpenseRequestKandangDetailExpense: React.FC< val: OptionType | OptionType[] | null ) => { formik.setFieldTouched( - `cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, + `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, true ); formik.setFieldValue( - `cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, + `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, val ); }; const addExpenseItemHandler = (kandangExpenseIdx: number) => { const newExpensesValue = [ - ...formik.values.cost_per_kandangs[kandangExpenseIdx].cost_items, + ...formik.values.expense_nonstocks[kandangExpenseIdx].cost_items, { nonstock: undefined, - total_cost: undefined, + price: undefined, quantity: undefined, notes: '', }, ]; formik.setFieldValue( - `cost_per_kandangs[${kandangExpenseIdx}].cost_items`, + `expense_nonstocks[${kandangExpenseIdx}].cost_items`, newExpensesValue ); }; @@ -71,28 +71,28 @@ const ExpenseRequestKandangDetailExpense: React.FC< kandangExpenseIdx: number, expenseIdx: number ) => { - const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`; + const path = `expense_nonstocks[${kandangExpenseIdx}].cost_items`; // trims values, errors, and touched at expenseIdx removeArrayItemAndSync(formik, path, expenseIdx); }; const isExpenseRepeaterInputError = ( - column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', + column: 'nonstock' | 'quantity' | 'price' | 'notes', kandangExpenseIdx: number, expenseIdx: number ) => { return ( - formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[ + formik.touched.expense_nonstocks?.[kandangExpenseIdx]?.cost_items?.[ expenseIdx ]?.[column] && Boolean( - formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof + formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof Object && - formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ + formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ expenseIdx ] instanceof Object && - formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ + formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ expenseIdx ]?.[column] ) @@ -113,7 +113,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
- {(formik.values.cost_per_kandangs.length === 0 || + {(formik.values.expense_nonstocks.length === 0 || !formik.values.supplier?.value) && (

@@ -122,9 +122,9 @@ const ExpenseRequestKandangDetailExpense: React.FC<

)} - {formik.values.cost_per_kandangs.length > 0 && + {formik.values.expense_nonstocks.length > 0 && formik.values.supplier?.value && - formik.values.cost_per_kandangs.map( + formik.values.expense_nonstocks.map( (kandangExpense, kandangExpenseIdx) => { const kandangName = formik.values.kandangs?.find( (kandang) => kandang.id === kandangExpense.kandang_id @@ -147,7 +147,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
Nonstock Total KuantitasTotal BiayaHarga Satuan CatatanAksi
{ { label: 'Vendor', value: expense?.supplier.name }, { label: 'Tanggal Transaksi', - value: formatDate(expense?.expense_date, 'DD MMMM YYYY'), + value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'), }, { label: 'Tanggal Realisasi', @@ -326,7 +326,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => { let expenseRequestTotal = 0; kandangExpense.pengajuans?.forEach( - (item) => (expenseRequestTotal += item.total_price) + (item) => (expenseRequestTotal += item.price) ); return ( @@ -374,7 +374,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => { - Total Biaya + Harga Satuan { ]} > - {formatCurrency(pengajuan.total_price)} + {formatCurrency(pengajuan.price)} { let expenseRealizationTotal = 0; kandangExpense.realisasi?.forEach( - (item) => (expenseRealizationTotal += item.total_price) + (item) => (expenseRealizationTotal += item.price) ); return ( @@ -532,7 +532,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => { - Total Biaya + Harga Satuan { ]} > - {formatCurrency(realisasi.total_price)} + {formatCurrency(realisasi.price)} { // Fetch Data const { data: inventoryAdjustments, isLoading } = useSWR( - `${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, - inventoryAdjustmentApi.getAllFetcher + `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, + InventoryAdjustmentApi.getAllFetcher ); // State diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx index bbfb3154..2c6c463c 100644 --- a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -1,7 +1,7 @@ 'use client'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { inventoryAdjustmentApi } from '@/services/api/inventory'; +import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { CreateInventoryAdjustmentPayload, InventoryAdjustment, @@ -24,7 +24,7 @@ import Button from '@/components/Button'; import { Icon } from '@iconify/react'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import TextInput from '@/components/input/TextInput'; -import RadioInput from '@/components/input/RadioInput'; +import { RadioGroup } from '@/components/input/RadioInput'; import TextArea from '@/components/input/TextArea'; interface InventoryAdjustmentFormProps { @@ -52,7 +52,7 @@ const InventoryAdjustmentForm = ({ const createInventoryAdjustmentHandler = useCallback( async (payload: CreateInventoryAdjustmentPayload) => { const createInventoryAdjustmentRes = - await inventoryAdjustmentApi.create(payload); + await InventoryAdjustmentApi.create(payload); if (isResponseError(createInventoryAdjustmentRes)) { setInventoryAdjustmentFormErrorMessage( @@ -347,7 +347,7 @@ const InventoryAdjustmentForm = ({ /> {/* Radio Button Flag Stock */} - ; +}) => ( + + + +); + +const InventoryProductTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + + const [sorting, setSorting] = useState([]); + + const { data: inventoryProducts, isLoading } = useSWR( + `${InventoryProductApi.basePath}${getTableFilterQueryString()}`, + InventoryProductApi.getAllFetcher + ); + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }; + + const columns: ColumnDef[] = useMemo( + () => [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'product_price', + header: 'Harga Beli', + cell: (props) => { + return props.row.original.product_price + ? formatCurrency(props.row.original.product_price) + : '-'; + }, + }, + { + accessorKey: 'selling_price', + header: 'Harga Jual', + cell: (props) => { + return props.row.original.selling_price + ? formatCurrency(props.row.original.selling_price) + : '-'; + }, + }, + { + accessorFn: (row) => row.product_category.name, + header: 'Kategori', + }, + { + accessorFn: (row) => row.total_stock, + header: 'Stok', + cell: (props) => { + return props.row.original.total_stock + ? formatNumber(props.row.original.total_stock) + : '0'; + }, + }, + { + accessorFn: (row) => row.uom.name, + header: 'Satuan', + }, + { + 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; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ], + [] + ); + + return ( + <> +
+
+
+
+
+ +
+ + +
+
+ + + data={ + isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : [] + } + columns={columns} + pageSize={tableFilterState.pageSize} + page={ + isResponseSuccess(inventoryProducts) + ? inventoryProducts?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(inventoryProducts) + ? inventoryProducts?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(inventoryProducts) && + inventoryProducts?.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 InventoryProductTable; diff --git a/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx b/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx new file mode 100644 index 00000000..ad523929 --- /dev/null +++ b/src/components/pages/inventory/product/detail/InventoryProductDetail.tsx @@ -0,0 +1,118 @@ +import Card from '@/components/Card'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable'; +import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable'; +import { formatCurrency, formatNumber } from '@/lib/helper'; +import { InventoryProduct } from '@/types/api/inventory/product'; +import { useMemo } from 'react'; + +const InventoryProductDetail = ({ + inventoryProduct, +}: { + inventoryProduct?: InventoryProduct; +}) => { + const stockLogs = useMemo(() => { + return ( + inventoryProduct?.product_warehouses?.flatMap( + (warehouse) => warehouse.stock_logs || [] + ) || [] + ); + }, [inventoryProduct]); + + return ( +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
SKU:{inventoryProduct?.sku}
Nama Produk:{inventoryProduct?.name}
Kategory:{inventoryProduct?.product_category.name}
Satuan:{inventoryProduct?.uom.name}
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Harga Jual: + {inventoryProduct?.selling_price + ? formatCurrency(inventoryProduct.selling_price) + : '-'} +
Harga Beli: + {inventoryProduct?.product_price + ? formatCurrency(inventoryProduct?.product_price) + : '-'} +
Pajak: + {inventoryProduct?.tax + ? formatCurrency(inventoryProduct?.tax) + : '-'} +
Total Stok: + {inventoryProduct?.total_stock + ? formatNumber(inventoryProduct?.total_stock) + : '0'} +
+
+
+
+ + + + +
+ ); +}; + +export default InventoryProductDetail; diff --git a/src/components/pages/inventory/product/detail/StockLogTable.tsx b/src/components/pages/inventory/product/detail/StockLogTable.tsx new file mode 100644 index 00000000..42f7bc29 --- /dev/null +++ b/src/components/pages/inventory/product/detail/StockLogTable.tsx @@ -0,0 +1,81 @@ +import Card from '@/components/Card'; +import Table from '@/components/Table'; +import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; +import { StockLog } from '@/types/api/inventory/product'; + +const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => { + return ( + + + data={stockLogs} + columns={[ + { + header: 'ID', + accessorKey: 'id', + }, + { + header: 'Tanggal', + accessorKey: 'created_at', + cell: (props) => { + return formatDate(props.row.original.created_at, 'DD-MMM-yyyy'); + }, + }, + { + header: 'Peningkatan', + accessorKey: 'increase', + cell: (props) => { + return formatNumber(props.row.original.increase); + }, + }, + { + header: 'Penurunan', + accessorKey: 'decrease', + cell: (props) => { + return formatNumber(props.row.original.decrease); + }, + }, + { + header: 'Jenis Transaksi', + accessorKey: 'loggable_type', + cell: (props) => { + return props.row.original.loggable_type + ? formatTitleCase(props.row.original.loggable_type) + : '-'; + }, + }, + { + header: 'Catatan', + accessorKey: 'notes', + cell: (props) => { + return props.row.original.notes ? props.row.original.notes : '-'; + }, + }, + { + header: 'Oleh', + accessorKey: 'created_user.name', + }, + ]} + className={{ + containerClassName: 'mt-6', + 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 StockLogTable; diff --git a/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx b/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx new file mode 100644 index 00000000..6f48f7cd --- /dev/null +++ b/src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx @@ -0,0 +1,65 @@ +import Card from '@/components/Card'; +import Table from '@/components/Table'; +import { formatNumber } from '@/lib/helper'; +import { + InventoryProduct, + ProductWarehouseStock, +} from '@/types/api/inventory/product'; + +const StockProductWarehouseTable = ({ + productWarehouseStock, +}: { + productWarehouseStock?: ProductWarehouseStock[]; +}) => { + return ( + + + data={productWarehouseStock ?? []} + columns={[ + { + header: 'Nama Gudang', + accessorKey: 'warehouse_name', + }, + { + header: 'Lokasi', + accessorKey: 'location', + cell: (props) => { + return props.row.original.location != null + ? props.row.original.location.name + : '-'; + }, + }, + { + header: 'Stok', + accessorFn(row) { + return row.current_stock; + }, + cell: (props) => { + return formatNumber(props.row.original.current_stock); + }, + }, + ]} + className={{ + containerClassName: 'mt-6', + 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 StockProductWarehouseTable; diff --git a/src/components/pages/marketing/form/MarketingForm.schema.ts b/src/components/pages/marketing/form/MarketingForm.schema.ts index 0c427a9a..d81cdb9c 100644 --- a/src/components/pages/marketing/form/MarketingForm.schema.ts +++ b/src/components/pages/marketing/form/MarketingForm.schema.ts @@ -6,7 +6,7 @@ import { import { DeliveryOrderProductFormValues, DeliveryOrderProductSchema, -} from './repeater/delivery-order/DeliverOrderProduct.schema'; +} from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema'; type MarketingSchemaType = { customer_id: number | undefined; diff --git a/src/components/pages/marketing/form/MarketingForm.tsx b/src/components/pages/marketing/form/MarketingForm.tsx index b90febfe..326eac72 100644 --- a/src/components/pages/marketing/form/MarketingForm.tsx +++ b/src/components/pages/marketing/form/MarketingForm.tsx @@ -8,7 +8,6 @@ import SelectInput, { OptionType, useSelect, } from '@/components/input/SelectInput'; -import TextArea from '@/components/input/TextArea'; import Modal, { useModal } from '@/components/Modal'; import { formatCurrency, formatDate } from '@/lib/helper'; import { @@ -31,23 +30,23 @@ import { DeliveryOrderSchema, SalesOrderFormValues, SalesOrderSchema, -} from './MarketingForm.schema'; +} from '@/components/pages/marketing/form/MarketingForm.schema'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { DeliveryOrderApi, MarketingApi, SalesOrderApi, } from '@/services/api/marketing/marketing'; -import { SalesOrderProductFormValues } from './repeater/sales-order/SalesOrderProduct.schema'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import toast from 'react-hot-toast'; import { useRouter } from 'next/navigation'; -import SalesOrderProductTable from './table-view/SalesOrderProductTable'; -import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm'; -import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable'; -import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct'; -import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema'; import DebouncedTextArea from '@/components/input/DebouncedTextArea'; +import SalesOrderProductTable from '@/components/pages/marketing/form/table-view/SalesOrderProductTable'; +import SalesOrderProductForm from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProductForm'; +import DeliveryOrderProductTable from '@/components/pages/marketing/form/table-view/DeliveryOrderProductTable'; +import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct'; +import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema'; +import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema'; const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); @@ -156,8 +155,6 @@ export const recalculate = ( field: string, values: ProductCalculationFields ) => { - console.log('Values'); - console.log(values); const { qty, unit_price, total_price, avg_weight, total_weight } = values; const result: Partial = {}; if (field == 'unit_price' || field == 'total_price' || field == 'qty') { @@ -174,8 +171,6 @@ export const recalculate = ( result.avg_weight = Number(total_weight) / Number(qty); } } - console.log('Result'); - console.log(result); return result; }; export const getSubmitField = (values: ProductCalculationFields) => { @@ -327,8 +322,6 @@ const MarketingForm = ({ }) .filter((item) => Boolean(item)), } as UpdateDeliveryOrderPayload); - console.log('PAYLOAD'); - console.log(payload); switch (formType) { case 'add': await createMarketingHandler(payload as CreateSalesOrderPayload); @@ -352,7 +345,6 @@ const MarketingForm = ({ // ================== FORM REPEATER HANDLER ================== const createMarketingHandler = async (values: CreateSalesOrderPayload) => { setIsLoading(true); - console.log(values); const createMarketingRes = await SalesOrderApi.create(values); if (isResponseSuccess(createMarketingRes)) { toast.success(createMarketingRes?.message as string); @@ -365,7 +357,6 @@ const MarketingForm = ({ }; const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => { setIsLoading(true); - console.log(values); const updateMarketingRes = await SalesOrderApi.update( initialValues?.id as number, values @@ -381,10 +372,8 @@ const MarketingForm = ({ }; const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => { setIsLoading(true); - console.log(initialValues?.id); const createDeliveryRes = await DeliveryOrderApi.create(values); if (isResponseSuccess(createDeliveryRes)) { - console.log(createDeliveryRes); toast.success(createDeliveryRes?.message as string); setDeliveryOrderValues( createDeliveryRes.data?.delivery_order?.flatMap((delivery) => @@ -397,20 +386,17 @@ const MarketingForm = ({ router.push(`/marketing/detail?marketingId=${initialValues?.id}`); } if (isResponseError(createDeliveryRes)) { - console.log(createDeliveryRes); toast.error(createDeliveryRes?.message as string); } setIsLoading(false); }; const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => { setIsLoading(true); - console.log(initialValues?.id); const updateDeliveryRes = await DeliveryOrderApi.update( initialValues?.id as number, values ); if (isResponseSuccess(updateDeliveryRes)) { - console.log(updateDeliveryRes); toast.success(updateDeliveryRes?.message as string); setDeliveryOrderValues( mergeSOwithDO( @@ -426,7 +412,6 @@ const MarketingForm = ({ router.push(`/marketing/detail?marketingId=${initialValues?.id}`); } if (isResponseError(updateDeliveryRes)) { - console.log(updateDeliveryRes); toast.error(updateDeliveryRes?.message as string); } setIsLoading(false); @@ -435,16 +420,13 @@ const MarketingForm = ({ // ================== MARKETING HANDLER ================== const deleteMarketingHandler = async () => { setIsLoading(true); - console.log(initialValues?.id); const deleteMarketingRes = await MarketingApi.delete( initialValues?.id as number ); if (isResponseSuccess(deleteMarketingRes)) { - console.log(deleteMarketingRes); toast.success(deleteMarketingRes?.message as string); } if (isResponseError(deleteMarketingRes)) { - console.log(deleteMarketingRes); toast.error(deleteMarketingRes?.message as string); } setIsLoading(false); diff --git a/src/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.tsx b/src/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.tsx index 4fe4179f..2dae2da5 100644 --- a/src/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.tsx +++ b/src/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { DeliveryOrderProductFormValues, DeliveryOrderProductSchema, -} from './DeliverOrderProduct.schema'; +} from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema'; import { useFormik } from 'formik'; import Alert from '@/components/Alert'; import Button from '@/components/Button'; diff --git a/src/components/pages/marketing/pdf/DeliveryOrderExport.tsx b/src/components/pages/marketing/pdf/DeliveryOrderExport.tsx index c2b19660..46e85a23 100644 --- a/src/components/pages/marketing/pdf/DeliveryOrderExport.tsx +++ b/src/components/pages/marketing/pdf/DeliveryOrderExport.tsx @@ -3,10 +3,10 @@ import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing'; import { Icon } from '@iconify/react'; import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; import { useMemo, useState } from 'react'; -import pdfStyles from './styles/MarketingPDFStyles'; import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper'; import { format } from 'path'; import { date } from 'yup'; +import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; interface DeliveryOrderExportProps { data?: Marketing; diff --git a/src/components/pages/marketing/pdf/SalesOrderExport.tsx b/src/components/pages/marketing/pdf/SalesOrderExport.tsx index e7fa9a71..f9f0a6c5 100644 --- a/src/components/pages/marketing/pdf/SalesOrderExport.tsx +++ b/src/components/pages/marketing/pdf/SalesOrderExport.tsx @@ -3,8 +3,8 @@ import { Marketing } from '@/types/api/marketing/marketing'; import { Icon } from '@iconify/react'; import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; import { useMemo, useState } from 'react'; -import pdfStyles from './styles/MarketingPDFStyles'; import { formatDate, formatNumber } from '@/lib/helper'; +import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; interface SalesOrderExportProps { data?: Marketing; diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.tsx b/src/components/pages/master-data/supplier/form/SupplierForm.tsx index da69a52e..429c3bb6 100644 --- a/src/components/pages/master-data/supplier/form/SupplierForm.tsx +++ b/src/components/pages/master-data/supplier/form/SupplierForm.tsx @@ -306,7 +306,6 @@ const SupplierForm = ({ label='Hatchery' value={hatcheryOptionsValues} onChange={(val) => { - console.log(val); // pastikan val = array of { value, label } setHatcheryOptionValues(val as OptionType[]); }} isError={ diff --git a/src/components/pages/production/chickin/form/ChickinForm.tsx b/src/components/pages/production/chickin/form/ChickinForm.tsx index 1f56459f..84c5b5a5 100644 --- a/src/components/pages/production/chickin/form/ChickinForm.tsx +++ b/src/components/pages/production/chickin/form/ChickinForm.tsx @@ -7,13 +7,16 @@ import { formatNumber } from '@/lib/helper'; import { Kandang } from '@/types/api/master-data/kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import Tabs from '@/components/Tabs'; -import ChickinFormView from './tabs/ChickinFormView'; -import ChickinLogsView from './tabs/ChickLogsView'; import { useState } from 'react'; import ApprovalSteps, { useApprovalSteps, } from '@/components/pages/ApprovalSteps'; import { PROJECT_FLOCK_KANDANG_APPROVAL_LINE } from '@/config/approval-line'; +import ChickinFormView from '@/components/pages/production/chickin/form/tabs/ChickinFormView'; +import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/ChickLogsView'; +import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; +import { Icon } from '@iconify/react'; +import Badge from '@/components/Badge'; const ChickinFormKandang = ({ formType = 'add', initialValues, @@ -23,7 +26,7 @@ const ChickinFormKandang = ({ initialValues: ProjectFlockKandang; afterSubmit?: () => void; }) => { - const [activeTabId, setActiveTabId] = useState('formChickIn'); + const [openChickin, setOpenChickin] = useState(false); const { approvals, @@ -37,108 +40,148 @@ const ChickinFormKandang = ({ }); const afterSubmitFormChickin = () => { - setActiveTabId('logsChickIn'); + setOpenChickin(true); afterSubmit && afterSubmit(); refreshApprovals(); }; return ( -
- + - {approvals && !approvalsLoading && ( - - )} + {/* Informasi Kandang */} +
+
+

Informasi Kandang

- - - emptyContent={ -
- - Informasi Kandang belum tersedia... - -
- } - data={[initialValues?.kandang]} - columns={[ - { - header: 'Area', - accessorFn: () => initialValues?.project_flock?.area.name || '-', - }, - { - header: 'Lokasi', - accessorFn: () => - initialValues?.project_flock?.location.name || '-', - }, - { - header: 'Flock', - accessorFn: () => initialValues?.project_flock?.flock_name || '-', - }, - { - header: 'Kandang', - accessorFn: (row) => row?.name || '-', - }, - { - header: 'Kapasitas', - accessorFn: (row) => - (row?.capacity && formatNumber(row?.capacity)) || '-', - }, - { - header: 'Penanggung Jawab', - accessorFn: (row) => row?.pic?.name || '-', - }, - ]} - className={{ - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', - }} + {approvals && !approvalsLoading && ( +
+ +
+ )} + + {/* Badge Row */} +
+ + {' '} + Aktif + +
+ + + {` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`} + +
+ + {/* Information Grid */} +
+ {/* Area */} +
+ Area +
+
+ {initialValues.project_flock.area.name} +
+ + {/* Lokasi */} +
+ Lokasi +
+
+ {initialValues.project_flock?.location.name} +
+ + {/* Kandang */} +
+ Kandang +
+
{initialValues.kandang.name}
+ + {/* Jumlah DOC */} +
+ Jumlah DOC +
+
+ {formatNumber( + initialValues.chickins?.reduce( + (total, chickin) => total + chickin.usage_qty, + 0 + ) ?? 0 + )}{' '} + Ekor +
+
+
+ +
+
+

Informasi Chick In

+ {/* Badge Row */} +
+ + {' '} + Perlu Chick In ({initialValues.available_qtys?.length ?? 0}) + +
+ setOpenChickin(!openChickin)} + > + {`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`} + + +
+
+ {openChickin && ( + - - - ), - }, - { - content: ( - - ), - id: 'logsChickIn', - label: 'Riwayat Chick In', - }, - ]} - variant='lifted' + )} + -
+ ); }; diff --git a/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx b/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx index 8accf9ae..865091d7 100644 --- a/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx +++ b/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx @@ -2,17 +2,12 @@ import Alert from '@/components/Alert'; import Button from '@/components/Button'; import Card from '@/components/Card'; import { useModal } from '@/components/Modal'; -import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import PillBadge from '@/components/PillBadge'; -import Table from '@/components/Table'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { cn, formatDate, formatNumber } from '@/lib/helper'; +import { formatDate, formatNumber } from '@/lib/helper'; import { ChickinApi } from '@/services/api/production/chickin'; -import { - Chickin, - ProjectFlockKandang, -} from '@/types/api/production/project-flock-kandang'; +import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { Icon } from '@iconify/react'; import { useState } from 'react'; import toast from 'react-hot-toast'; @@ -54,105 +49,120 @@ const ChickinLogsView = ({ return ( <> - -
- {initialValues?.approval?.step_number == 1 && ( - - )} -
- - data={initialValues?.chickins || []} - columns={[ - { - header: '#', - cell: (props) => props.row.index + 1, - }, - { - accessorFn: (row) => row.chick_in_date, - header: 'Tanggal Chick In', - cell: (props) => { - return formatDate(props.getValue() as string, 'DD MMM YYYY'); - }, - }, - { - accessorFn: (row) => row.product_warehouse?.warehouse?.name, - header: 'Kandang', - }, - { - accessorFn: (row) => row.product_warehouse?.product?.name, - header: 'Produk', - }, - { - accessorFn: (row) => row.usage_qty ?? row.pending_usage_qty, - header: 'Jumlah Chick In', - cell: (props) => { - if (props.row.original.usage_qty != 0) { - return formatNumber(props.row.original.usage_qty); - } else if (props.row.original.pending_usage_qty != 0) { - return formatNumber(props.row.original.pending_usage_qty); - } else { - return '-'; - } - }, - }, - { - accessorFn: (row) => row.pending_usage_qty, - header: 'Status', - cell: (props) => { - return ( - - ); - }, - }, - ]} - className={{ - containerClassName: cn({ - 'mb-20': initialValues?.chickins?.length === 0, - }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', - }} - /> +
+ {/* Card List Chickin Logs */} + {(initialValues?.chickins || []).length === 0 ? ( +
+ + Belum ada riwayat Chick In... + +
+ ) : ( + (initialValues?.chickins || []).map((chickin, index) => { + const isApproved = chickin.usage_qty !== 0; + const isPending = chickin.pending_usage_qty !== 0; + const quantity = isApproved + ? chickin.usage_qty + : isPending + ? chickin.pending_usage_qty + : 0; + + return ( + +
+ {/* Header with Status Badge */} +
+
+ Chick In #{index + 1} +
+ +
+ + {/* Tanggal Chick In */} +
+
+ {' '} + Tanggal Chick In +
+
+ {formatDate(chickin.chick_in_date, 'DD MMM YYYY')} +
+
+ + {/* Kandang */} +
+
+ {' '} + Kandang +
+
+ {chickin.product_warehouse?.warehouse?.name || '-'} +
+
+ + {/* Produk */} +
+
+ {' '} + Produk +
+
+ {chickin.product_warehouse?.product?.name || '-'} +
+
+ + {/* Jumlah Chick In */} +
+
+ {' '} + Jumlah Chick In +
+
+ {quantity > 0 ? `${formatNumber(quantity)} Ekor` : '-'} +
+
+
+
+ ); + }) + )} + + {initialValues?.approval?.step_number == 1 && ( + + )} + {chickinErrorMessage && (
setChickinErrorMessage('')}> {chickinErrorMessage}
)} - +
+ { handleReset(); }} onSubmit={formik.handleSubmit} > - - - data={formik.values.chickin_requests || []} - columns={[ - { - accessorFn: (row) => row.chick_in_date, - header: 'Tanggal Chick In', - cell(props) { - return ( - - ); - }, - }, - { - accessorFn: (row) => row.product_warehouse_id, - header: 'Produk', - cell(props) { - const availableQty = initialValues?.available_qtys?.find( - (availableQty) => - availableQty.product_warehouse.id === - props.row.original.product_warehouse_id - ); - return ( -
{availableQty?.product_warehouse?.product?.name}
- ); - }, - }, - { - accessorFn: (row) => row.product_warehouse_id, - header: 'Jumlah (ekor)', - cell(props) { - const availableQty = initialValues?.available_qtys?.find( - (availableQty) => - availableQty.product_warehouse.id === - props.row.original.product_warehouse_id - ); - return ( -
- {availableQty?.available_qty - ? formatNumber(availableQty?.available_qty) - : '-'} -
- ); - }, - }, - ]} - className={{ - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-2 py-2 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', - }} - emptyContent={ -
- - Isi persediaan DOC untuk kandang belum tersedia... - + {(formik.values.chickin_requests || []).map((chickinRequest, index) => { + const availableQty = initialValues?.available_qtys?.find( + (availableQty) => + availableQty.product_warehouse.id === + chickinRequest.product_warehouse_id + ); + return ( + +
+
+ {formatNumber(availableQty?.available_qty ?? 0)} Ekor -{' '} + {availableQty?.product_warehouse?.product?.name} +
+ {chickinRequest.chick_in_date && ( + + )}
- } - /> -
-
- + + + ); + })} + {/* + data={formik.values.chickin_requests || []} + columns={[ + { + accessorFn: (row) => row.chick_in_date, + header: 'Tanggal Chick In', + cell(props) { + return ( + + ); + }, + }, + { + accessorFn: (row) => row.product_warehouse_id, + header: 'Produk', + cell(props) { + const availableQty = initialValues?.available_qtys?.find( + (availableQty) => + availableQty.product_warehouse.id === + props.row.original.product_warehouse_id + ); + return ( +
{availableQty?.product_warehouse?.product?.name}
+ ); + }, + }, + { + accessorFn: (row) => row.product_warehouse_id, + header: 'Jumlah (ekor)', + cell(props) { + const availableQty = initialValues?.available_qtys?.find( + (availableQty) => + availableQty.product_warehouse.id === + props.row.original.product_warehouse_id + ); + return ( +
+ {availableQty?.available_qty + ? formatNumber(availableQty?.available_qty) + : '-'} +
+ ); + }, + }, + ]} + className={{ + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-2 py-2 last:flex last:flex-row last:justify-end', + paginationClassName: 'hidden', + }} + emptyContent={ +
+ + Isi persediaan DOC untuk kandang belum tersedia... + +
+ } + /> */} + {formik.values.chickin_requests?.length > 0 && ( -
+ )} {chickinErrorMessage && (
setChickinErrorMessage('')}> {chickinErrorMessage} diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index a315b332..4be30f7a 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -1,6 +1,8 @@ 'use client'; +import Badge from '@/components/Badge'; import Button from '@/components/Button'; +import FloatingActionsButton from '@/components/FloatingActionsButton'; import CheckboxInput from '@/components/input/CheckboxInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; @@ -8,23 +10,18 @@ import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import Table from '@/components/Table'; -import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import { ROWS_OPTIONS } from '@/config/constant'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { cn } from '@/lib/helper'; +import { cn, formatDate } from '@/lib/helper'; import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import { BaseApiResponse } from '@/types/api/api-general'; import { Kandang } from '@/types/api/master-data/kandang'; -import { - ProjectFlockApprovalPayload, - ProjectFlock, -} from '@/types/api/production/project-flock'; +import { ProjectFlock } from '@/types/api/production/project-flock'; import { Icon } from '@iconify/react'; import { CellContext, SortingState } from '@tanstack/react-table'; -import { ChangeEventHandler, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import useSWR from 'swr'; @@ -98,7 +95,7 @@ const RowOptionsMenu = ({ ); }; -const ProjectFlockTable = () => { +const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { const { state: tableFilterState, updateFilter, @@ -123,8 +120,9 @@ const ProjectFlockTable = () => { periodFilter: 'period', }, }); + const router = useRouter(); - // State + // ===== State ===== const [rowSelection, setRowSelection] = useState>({}); const selectedRowIds = Object.keys(rowSelection) .filter((id) => rowSelection[id]) @@ -151,14 +149,15 @@ const ProjectFlockTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false); - // Fetch Data + // ===== Fetch Data ===== const { data: projectFlocks, isLoading, mutate: refreshProjectFlocks, } = useSWR( `${ProjectFlockApi.basePath}${getTableFilterQueryString()}`, - ProjectFlockApi.getAllFetcher + ProjectFlockApi.getAllFetcher, + { revalidateOnMount: true } ); const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ @@ -191,7 +190,7 @@ const ProjectFlockTable = () => { KandangApi.getAllFetcher ); - // Data to Options Mapping + // ===== Data to Options Mapping ====== const optionsArea = isResponseSuccess(areas) ? areas?.data.map((area) => ({ value: area.id, @@ -211,7 +210,7 @@ const ProjectFlockTable = () => { })) : []; - // Handler + // ====== HANDLER ====== const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const newVal = val as OptionType; setPageSize(newVal.value as number); @@ -219,17 +218,17 @@ const ProjectFlockTable = () => { const confirmationModalDeleteClickHandler = async () => { setIsDeleteLoading(true); - await ProjectFlockApi.delete(selectedProjectFlock?.id as number); + await ProjectFlockApi.delete(selectedSingleRow?.id as number); refreshProjectFlocks(); deleteModal.closeModal(); toast.success('Successfully delete Project Flock!'); setIsDeleteLoading(false); + setRowSelection({}); }; const searchChangeHandler: ChangeEventHandler = (e) => { updateFilter('search', e.target.value); }; - const confirmApprovalHandler = async ( notes: string, approvalAction: 'APPROVED' | 'REJECTED' @@ -259,22 +258,44 @@ const ProjectFlockTable = () => { setIsApproveLoading(false); }; + // ====== EFFECT ====== + useEffect(() => { + refreshProjectFlocks(); + }, [refresh]); + + // ====== MEMO ====== + const selectedSingleRow: ProjectFlock | null | undefined = useMemo(() => { + return selectedRowIds.length === 1 + ? isResponseSuccess(projectFlocks) + ? projectFlocks?.data.find((row) => row.id === selectedRowIds[0]) + : null + : null; + }, [rowSelection]); + + const canApprove = useMemo(() => { + if (!selectedSingleRow || isApproveLoading) return false; + + const isPengajuan = selectedSingleRow.approval.step_number == 1; + const isNotRejected = selectedSingleRow.approval.action != 'REJECTED'; + + return isPengajuan && isNotRejected; + }, [selectedSingleRow, isApproveLoading]); + return ( <> -
+
- + */}
{ id: 'select', header: ({ table }) => { const allRows = table.getRowModel().rows; - const selectableRows = allRows.filter( - (row) => row.original?.approval?.step_number == 1 - ); + const selectableRows = allRows; const allSelected = selectableRows.every((row) => row.getIsSelected()) && @@ -417,12 +436,6 @@ const ProjectFlockTable = () => { checked={allSelected} indeterminate={someSelected} onChange={toggleSelectableRows} - disabled={ - isResponseSuccess(projectFlocks) && - projectFlocks?.data?.filter( - (flock) => flock.approval.step_number == 1 - ).length == 0 - } />
); @@ -431,14 +444,8 @@ const ProjectFlockTable = () => { return ( @@ -469,6 +476,40 @@ const ProjectFlockTable = () => { { accessorKey: 'approval.step_name', header: 'Status', + cell: (props) => { + const approval = props.row.original.approval; + + return ( + + + {approval.step_name} + + ); + }, }, { header: 'Kandang', @@ -496,51 +537,51 @@ const ProjectFlockTable = () => { accessorKey: 'created_at', header: 'Dibuat pada', cell: (props) => - new Date(props.row.original.created_at).toLocaleDateString(), + formatDate(props.row.original.created_at, 'MMM DD, YYYY'), }, - { - 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; + // { + // 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 isLast2Rows = + // currentRowRelativeIndex > currentPageSize - 2; - const deleteClickHandler = () => { - setSelectedProjectFlock(props.row.original); - deleteModal.openModal(); - }; + // const deleteClickHandler = () => { + // setSelectedProjectFlock(props.row.original); + // deleteModal.openModal(); + // }; - return ( - <> - {currentPageSize > 2 && ( - - - - )} + // return ( + // <> + // {currentPageSize > 2 && ( + // + // + // + // )} - {currentPageSize <= 2 && ( - - - - )} - - ); - }, - }, + // {currentPageSize <= 2 && ( + // + // + // + // )} + // + // ); + // }, + // }, ]} pageSize={tableFilterState.pageSize} page={ @@ -576,6 +617,57 @@ const ProjectFlockTable = () => {
+ { + deleteModal.openModal(); + }, + }, + ]} + approvals={[ + { + icon: 'material-symbols:check', + label: 'Approve', + action: 'APPROVED', + onClick: () => { + setApprovalAction('APPROVED'); + confirmModal.openModal(); + }, + disabled: !canApprove, + }, + { + icon: 'mdi:times', + label: 'Reject', + action: 'REJECTED', + onClick: () => { + setApprovalAction('REJECTED'); + confirmModal.openModal(); + }, + }, + ]} + selectedRowIds={selectedRowIds} + onClose={() => { + setRowSelection({}); + }} + /> + - +
+ + + +
+
+ Chick In {projectFlock?.flock_name} +
+
+
+ {/* -
+ backUrl={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`} + /> */} + {/*
-
- */} + {/* Informasi Umum */} + {projectFlock && ( +
+
+

Informasi Umum

+ {/* Badge Row */} +
+ = 3 + ? 'error' + : undefined + } + className={{ + badge: 'rounded-lg px-2', + }} + > + = 3 + ? 'error' + : undefined + } + />{' '} + {projectFlock.approval.step_name} + +
+ + + {` ${formatTitleCase(projectFlock.category)}`} + +
+ {/* Information Grid */} +
+
+ Submitted +
+
+ + {' '} + {projectFlock.created_user.name} + +
+ +
+ History +
+
+ +
+ + {/* BARIS 1 */} +
+ Area +
+
{projectFlock.area.name}
+ + {/* BARIS 2 */} +
+ Lokasi +
+
{projectFlock.location.name}
+ +
+ FCR +
+
{projectFlock.fcr.name}
+ + {/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */} +
+ {' '} + Kategori +
+
+ {formatTitleCase(projectFlock.category)} +
+
+
+
+ )} + {/* - - */} + {/* Card Kandangs */} +
+
+

Daftar Kandang

+ {isResponseSuccess(listProjectFlock) ? ( + <> + {/* Badge Row */} +
+ + {' '} + Disetujui ( + {isResponseSuccess(listProjectFlockKandang) && + listProjectFlockKandang.data.filter( + (k) => k.approval?.step_number == 1 + ).length} + ) + +
+ + {' '} + Pengajuan ( + {isResponseSuccess(listProjectFlockKandang) && + listProjectFlockKandang.data.filter( + (k) => k.approval?.step_number == 2 + ).length} + ) + +
+ + + Belum Chickin ( + {isResponseSuccess(listProjectFlockKandang) && + listProjectFlockKandang.data.filter( + (k) => k.approval == null + ).length} + ) + +
+ {/* Card Kandang */} + +
+ {isResponseSuccess(listProjectFlockKandang) && + listProjectFlockKandang.data.map((kandang) => ( +
+
+ + + + + {kandang.kandang.name} + +
+ +
+ ))} +
+
+ + ) : ( +
+ + Pilih project flock terlebih dahulu... + +
+ )} +
+
+ {/* - +
*/} ); }; diff --git a/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx new file mode 100644 index 00000000..bcfb7795 --- /dev/null +++ b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx @@ -0,0 +1,304 @@ +'use client'; + +import Button from '@/components/Button'; +import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; +import Table from '@/components/Table'; +import Badge from '@/components/Badge'; +import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; +import { ProjectFlock } from '@/types/api/production/project-flock'; +import { + ClosingExpense, + ProjectFlockKandang, +} from '@/types/api/production/project-flock-kandang'; +import { Icon } from '@iconify/react'; +import useSWR from 'swr'; +import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useRouter } from 'next/navigation'; +import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; + +const ProjectFlockClosingForm = ({ + projectFlock, + projectFlockKandang, +}: { + projectFlock: ProjectFlock; + projectFlockKandang: ProjectFlockKandang; +}) => { + const router = useRouter(); + const closeModal = useModal(); + const isCanClose = projectFlock.approval?.step_number <= 2; + const [isClosingLoading, setIsClosingLoading] = useState(false); + + const { data: closingData, isLoading } = useSWR( + `${ProjectFlockKandangApi.basePath}/${projectFlockKandang.id}/closing`, + () => ProjectFlockKandangApi.checkClosing(projectFlockKandang.id) + ); + + const confirmationModalCloseClickHandler = async () => { + setIsClosingLoading(true); + const deleteProjectFlockRes = await ProjectFlockKandangApi.closing( + projectFlock?.id as number, + { + closed_date: formatDate(new Date(), 'yyyy-MM-dd'), + action: isCanClose ? 'close' : 'unclose', + } + ); + + if (isResponseSuccess(deleteProjectFlockRes)) { + toast.success(deleteProjectFlockRes?.message as string); + router.push(`/production/project-flock`); + } + if (isResponseError(deleteProjectFlockRes)) { + toast.error(deleteProjectFlockRes?.message as string); + } + setIsClosingLoading(false); + closeModal.closeModal(); + }; + + const errorStock = useMemo(() => { + return isResponseSuccess(closingData) + ? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0) + : false; + }, [closingData]); + + const errorExpense = useMemo(() => { + return isResponseSuccess(closingData) + ? closingData?.data?.expenses.every((expense) => expense.step < 5) + : false; + }, [closingData]); + + const isCanCloseValid = !errorStock && !errorExpense; + + return ( + <> + + + {/* Informasi Kandang */} +
+
+

Informasi Kandang

+ + {/* Badge Row */} +
+ + {' '} + Aktif + +
+ + + {` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`} + +
+ + {/* Information Grid */} +
+ {/* Area */} +
+ Area +
+
{projectFlock.area?.name}
+ + {/* Lokasi */} +
+ Lokasi +
+
{projectFlock.location?.name}
+ + {/* Kandang */} +
+ Kandang +
+
{projectFlockKandang.kandang?.name}
+ + {/* Jumlah DOC */} +
+ Jumlah DOC +
+
+ {formatNumber( + projectFlockKandang.chickins?.reduce( + (total, chickin) => total + chickin.usage_qty, + 0 + ) ?? 0 + )}{' '} + Ekor +
+
+
+ + {/* Table Biaya */} +
+
+

Biaya

+ + data={ + isResponseSuccess(closingData) ? closingData.data?.expenses : [] + } + columns={[ + { + header: 'PO Number', + accessorKey: 'po_number', + }, + { + header: 'Total', + accessorKey: 'total', + }, + { + header: 'Status', + accessorKey: 'status', + cell(props) { + return ( + + {formatTitleCase(props.row.original.status)} + + ); + }, + }, + ]} + className={{ + containerClassName: cn('my-4'), + tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120', + tableClassName: 'font-inter w-full table-sm min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-3 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-3 py-3 last:flex last:flex-row last:justify-end', + paginationClassName: 'hidden', + }} + /> + {errorExpense && ( +
+ *Pastikan semua biaya sudah selesai sebelum melakukan closing. +
+ )} +
+ + {/* Table Persediaan Gudang */} +
+
+

Persediaan Gudang

+ + data={ + isResponseSuccess(closingData) + ? closingData.data?.stock_remaining + : [] + } + columns={[ + { + header: 'Product', + accessorKey: 'product.name', + }, + { + header: 'Kategori', + accessorKey: 'product.product_category.name', + }, + { + header: 'Quantity', + accessorKey: 'quantity', + }, + { + header: 'UOM', + accessorKey: 'product.uom.name', + }, + ]} + className={{ + containerClassName: cn('my-4'), + tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120', + tableClassName: 'font-inter w-full table-sm min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-3 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-3 py-3 last:flex last:flex-row last:justify-end', + paginationClassName: 'hidden', + }} + /> + {errorStock && ( +
+ *Masih ada sisa stock yang belum dihabiskan. +
+ )} +
+ +
+ +
+ + + + ); +}; + +export default ProjectFlockClosingForm; diff --git a/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx new file mode 100644 index 00000000..5b54b10e --- /dev/null +++ b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx @@ -0,0 +1,439 @@ +import Badge from '@/components/Badge'; +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; +import Tooltip from '@/components/Tooltip'; +import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; +import { + formatCurrency, + formatDate, + formatNumber, + formatTitleCase, +} from '@/lib/helper'; +import { ProjectFlock } from '@/types/api/production/project-flock'; +import { Icon } from '@iconify/react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { ProjectFlockApi } from '@/services/api/production/project-flock'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import toast from 'react-hot-toast'; +import ApprovalSteps, { + useApprovalSteps, +} from '@/components/pages/ApprovalSteps'; +import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line'; + +const ProjectFlockDetail = ({ + projectFlock, +}: { + projectFlock: ProjectFlock; +}) => { + const router = useRouter(); + const deleteModal = useModal(); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [openBudgets, setOpenBudget] = useState(false); + const [selectedKandangId, setSelectedKamdangId] = useState( + null + ); + + const selectedKandang = projectFlock.kandangs.find( + (kandang) => kandang.id === Number(selectedKandangId) + ); + + const { + approvals, + isLoading: approvalsLoading, + refresh: refreshApprovals, + } = useApprovalSteps({ + latestApproval: projectFlock?.approval, + approvalLines: PROJECT_FLOCK_APPROVAL_LINE, + moduleName: 'PROJECT_FLOCKS', + moduleId: projectFlock?.id.toString() ?? '', + }); + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + const deleteProjectFlockRes = await ProjectFlockApi.delete( + projectFlock?.id as number + ); + + if (isResponseSuccess(deleteProjectFlockRes)) { + toast.success(deleteProjectFlockRes?.message as string); + router.push('/production/project-flock'); + } + if (isResponseError(deleteProjectFlockRes)) { + toast.error(deleteProjectFlockRes?.message as string); + } + setIsDeleteLoading(false); + }; + + return ( + <> +
+ {/* Header */} + + + + + + + + + + {/* Informasi Umum */} +
+
+

Informasi Umum

+ {/* Status Approval */} + {approvals && !approvalsLoading && ( +
+ +
+ )} + {/* Badge Row */} +
+ = 3 + ? 'error' + : undefined + } + className={{ + badge: 'rounded-lg px-2', + }} + > + = 3 + ? 'error' + : undefined + } + />{' '} + {projectFlock.approval?.step_name} + +
+ + + {` ${formatTitleCase(projectFlock.category)}`} + +
+ {/* Information Grid */} +
+
+ Submitted +
+
+ + {' '} + {projectFlock.created_user.name} + +
+ + {/*
+ History +
+
+ +
*/} + + {/* BARIS 1 */} +
+ Area +
+
{projectFlock.area.name}
+ + {/* BARIS 2 */} +
+ Lokasi +
+
{projectFlock.location.name}
+ +
+ FCR +
+
{projectFlock.fcr.name}
+ + {/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */} +
+ {' '} + Kategori +
+
+ {formatTitleCase(projectFlock.category)} +
+
+
+
+ + {/* Kandang Aktif */} +
+
+

Kandang Aktif

+ {/* Badge Row */} +
+ + {' '} + Kandang Aktif ({projectFlock.kandangs.length}) + +
+ { + setOpenBudget(!openBudgets); + }} + > + {` ${formatCurrency( + (projectFlock.project_budgets ?? []).reduce( + (acc, curr) => acc + curr.price * curr.qty, + 0 + ) + )}`} + + +
+ + {/* Card List Project Budgets */} + {openBudgets && + (projectFlock.project_budgets ?? []).map((budget) => ( + +
+
+
+ {' '} + Jenis Produk +
+
+ {budget.nonstock?.name} +
+
+
+
+ {' '} + Nama Satuan +
+
+ {budget.nonstock?.uom.name} +
+
+
+
+ {' '} + Jumlah Pembelian +
+
+ {formatNumber(budget.qty)} +
+
+
+
+ {' '} + Harga Satuan +
+
+ {formatCurrency(budget.price)} +
+
+
+
+ {' '} + Total Harga +
+
+ {formatCurrency(budget.price * budget.qty)} +
+
+
+
+ ))} + + {/* Card Kandangs */} + + setSelectedKamdangId(e.target.value)} + value={selectedKandangId?.toString()} + size='md' + color='neutral' + disabled={projectFlock.approval.step_number == 1} + > + {projectFlock.kandangs.map((kandang) => ( +
+ projectFlock.approval.step_number > 1 && + setSelectedKamdangId(kandang.id.toString()) + } + > + +
+ + Kapasitas {kandang.capacity} Ekor + +
+
+ ))} +
+
+
+ + + + + + +
+
+
+
+ + + + ); +}; + +export default ProjectFlockDetail; diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts b/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts index ca27f64b..9ac07c0f 100644 --- a/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.schema.ts @@ -1,52 +1,124 @@ import * as Yup from 'yup'; -export const ProjectFlockFormSchema = Yup.object({ - // Flock - flock: Yup.object({ - value: Yup.number().required('ID Flock wajib diisi!'), - label: Yup.string().required('Nama Flock wajib diisi!'), - }).nullable(), - flock_name: Yup.string().required('Nama Flock wajib diisi!'), +type ProjectFlockFormSchemaType = { + flock: { + value: number | string; + label: string; + } | null; + flock_name: string; + area: { + value: number | string; + label: string; + } | null; + area_id: number; + category_option: { + value: string; + label: string; + } | null; + category: string; + fcr: { + value: number | string; + label: string; + } | null; + fcr_id: number; + location: { + value: number | string; + label: string; + } | null; + location_id: number; + kandang_ids: number[]; + project_budgets: ProjectFlockBudgetsSchemaType[]; +}; - // 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() - .min(1, 'Area wajib diisi!') - .required('Area wajib diisi!'), +export type ProjectFlockBudgetsSchemaType = { + nonstock: { + value: number | string; + label: string; + } | null; + nonstock_id: number | string; + qty: number | string; + price: number | string; + total_price: number | string; +}; - // Kategori - category_option: Yup.object({ - value: Yup.string().required('Nilai Kategori wajib diisi!'), - label: Yup.string().required('Label Kategori wajib diisi!'), - }).nullable(), - category: Yup.string() - .oneOf(['GROWING', 'LAYING'], 'Kategori wajib diisi!') - .required('Kategori wajib diisi!'), +export const ProjectFlockBudgetsSchema: Yup.ObjectSchema = + Yup.object({ + nonstock: Yup.object({ + value: Yup.number().required('ID Nonstock wajib diisi!'), + label: Yup.string().required('Nama Nonstock wajib diisi!'), + }).required('Nonstock wajib diisi!'), + nonstock_id: Yup.number() + .min(1, 'Nonstock wajib diisi!') + .required('Nonstock wajib diisi!'), + qty: Yup.number() + .typeError('Jumlah harus berupa angka!') + .min(1, 'Jumlah minimal 1!') + .required('Jumlah wajib diisi!'), + price: Yup.number() + .typeError('Harga harus berupa angka!') + .min(1, 'Harga minimal 1!') + .required('Harga wajib diisi!'), + total_price: Yup.number() + .typeError('Harga harus berupa angka!') + .min(1, 'Harga minimal 1!') + .required('Harga 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().min(1, 'FCR wajib diisi!').required('FCR wajib diisi!'), +export const ProjectFlockFormSchema: Yup.ObjectSchema = + Yup.object({ + // Flock + flock: Yup.object({ + value: Yup.number().required('ID Flock wajib diisi!'), + label: Yup.string().required('Nama Flock wajib diisi!'), + }).nullable(), + flock_name: Yup.string().required('Nama Flock 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() - .min(1, 'Lokasi wajib diisi!') - .required('Lokasi 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() + .min(1, 'Area wajib diisi!') + .required('Area wajib diisi!'), - kandang_ids: Yup.array() - .of(Yup.number().typeError('Kandang tidak valid!')) - .min(1, 'Minimal harus ada 1 kandang!') - .required('Kandang wajib diisi!'), -}); + // Kategori + category_option: Yup.object({ + value: Yup.string().required('Nilai Kategori wajib diisi!'), + label: Yup.string().required('Label Kategori wajib diisi!'), + }).nullable(), + category: Yup.string() + .oneOf(['GROWING', 'LAYING'], 'Kategori wajib diisi!') + .required('Kategori 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() + .min(1, 'FCR wajib diisi!') + .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() + .min(1, 'Lokasi wajib diisi!') + .required('Lokasi wajib diisi!'), + + kandang_ids: Yup.array() + .of(Yup.number().required('Kandang tidak valid!')) + .min(1, 'Minimal harus ada 1 kandang!') + .required('Kandang wajib diisi!'), + + project_budgets: Yup.array() + .of(ProjectFlockBudgetsSchema) + .min(1, 'Minimal harus ada 1 data budget!') + .required('Data budget wajib diisi!'), + }); export type ProjectFlockFormValues = Yup.InferType< typeof ProjectFlockFormSchema diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx index ae60b020..9e5eaeef 100644 --- a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx @@ -12,37 +12,44 @@ import { FlockApi, KandangApi, LocationApi, + NonstockApi, } from '@/services/api/master-data'; import { Icon } from '@iconify/react'; -import { useFormik } from 'formik'; +import { FormikErrors, useFormik } from 'formik'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import useSWR, { KeyedMutator } from 'swr'; import { + ProjectFlockBudgetsSchemaType, ProjectFlockFormSchema, ProjectFlockFormValues, UpdateProjectFlockFormSchema, } from '@/components/pages/production/project-flock/form/ProjectFlockForm.schema'; import { - ProjectFlockApprovalPayload, CreateProjectFlockPayload, ProjectFlock, + ProjectFlockBudget, } from '@/types/api/production/project-flock'; import toast from 'react-hot-toast'; import { Kandang } from '@/types/api/master-data/kandang'; -import Collapse from '@/components/Collapse'; import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { BaseApiResponse } from '@/types/api/api-general'; import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import ProjectFlockKandangTable from './ProjectFlockKandangTable'; import ApprovalSteps, { useApprovalSteps, } from '@/components/pages/ApprovalSteps'; import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import NumberInput from '@/components/input/NumberInput'; +import Card from '@/components/Card'; +import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { useUiStore } from '@/stores/ui/ui.store'; +import Link from 'next/link'; +import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; +import { formatDate } from '@/lib/helper'; interface ProjectFlockFormProps { formType?: 'add' | 'edit' | 'detail'; @@ -79,6 +86,8 @@ const ProjectFlockForm = ({ initialValues?.flock_name?.lastIndexOf(' ') ) ?? '' ); + const subscribeValidate = useUiStore((s) => s.subscribeValidate); + const setIsValid = useUiStore((s) => s.setIsValid); const deleteModal = useModal(); const confirmModal = useModal(); @@ -104,19 +113,6 @@ const ProjectFlockForm = ({ ) ); - useEffect(() => { - if (initialValues?.approval?.step_name) { - const pengajuanRejected = - initialValues.approval.step_number == 1 && - initialValues.approval.action == 'REJECTED'; - const approvedDisabled = - initialValues.approval.step_number !== 1 || pengajuanRejected; - setIsApprovedDisabled(approvedDisabled); - setIsRejectedDisabled(!approvedDisabled || pengajuanRejected); - setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED'); - } - }, [initialValues]); - // Fetch Data const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } = useSelect(FlockApi.basePath, 'id', 'name'); @@ -156,6 +152,12 @@ const ProjectFlockForm = ({ () => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string)) ); + const { + options: optionsNonstock, + rawData: nonstocks, + isLoadingOptions: isLoadingNonstocks, + } = useSelect(NonstockApi.basePath, 'id', 'name'); + const { approvals, isLoading: approvalsLoading, @@ -209,7 +211,12 @@ const ProjectFlockForm = ({ formik.setFieldValue('area_id', (val as OptionType)?.value); formik.setFieldValue('area', val); - formik.setFieldTouched('area_id', true); + if (Boolean(val)) { + formik.setFieldTouched('area_id', false); + formik.setFieldError('area_id', ''); + } else { + formik.setFieldTouched('area_id', true); + } setSelectedArea((val as OptionType)?.value as string); setSelectedLocation(''); @@ -242,7 +249,12 @@ const ProjectFlockForm = ({ val ? (val as OptionType)?.value : 0 ); - formik.setFieldTouched(`${inputName}_id`, true); + if (Boolean(val)) { + formik.setFieldTouched(`${inputName}_id`, false); + formik.setFieldError(`${inputName}_id`, ''); + } else { + formik.setFieldTouched(`${inputName}_id`, true); + } }; const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -259,6 +271,7 @@ const ProjectFlockForm = ({ if (isResponseSuccess(createProjectFlockRes)) { toast.success(createProjectFlockRes?.message as string); + handleReset(); router.push('/production/project-flock'); } if (isResponseError(createProjectFlockRes)) { @@ -269,13 +282,14 @@ const ProjectFlockForm = ({ const updateProjectFlockHandler = async ( payload: CreateProjectFlockPayload ) => { - const updateProjectFlockRes = await ProjectFlockApi.update( + const updateProjectFlockRes = await ProjectFlockApi.resubmit( initialValues?.id as number, payload ); if (isResponseSuccess(updateProjectFlockRes)) { toast.success(updateProjectFlockRes?.message as string); + handleReset(); router.push('/production/project-flock'); } if (isResponseError(updateProjectFlockRes)) { @@ -283,6 +297,15 @@ const ProjectFlockForm = ({ toast.error(updateProjectFlockRes?.message as string); } }; + const handleReset = () => { + formik.resetForm(); + setSelectedArea(''); + setSelectedLocation(''); + setDisabledLocation(true); + setOpenSelectKandangs(false); + setOptionsKandang([]); + formikSetValues(formikInitialValues); + }; // Formik InitialValue const formikInitialValues = useMemo(() => { @@ -291,21 +314,14 @@ const ProjectFlockForm = ({ 0, initialValues?.flock_name?.lastIndexOf(' ') ) ?? ''; + const optionFind = optionsFlock.find((flock) => { + return flock.label == trimFlock; + }) as OptionType; return { - flock: initialValues?.flock_name - ? { - value: - optionsFlock.find((flock) => { - return flock.label == trimFlock; - })?.value ?? 0, - label: - formType != 'detail' - ? (optionsFlock.find((flock) => { - return flock.label == trimFlock; - })?.label ?? '') - : initialValues?.flock_name, - } - : null, + flock: + optionsFlock.find((flock) => { + return flock.label == trimFlock; + }) ?? null, area: initialValues?.area ? { value: initialValues.area?.id, @@ -332,109 +348,50 @@ const ProjectFlockForm = ({ : null, flock_name: optionsFlock.find((flock) => { - return ( - flock.label == - initialValues?.flock_name?.slice( - 0, - initialValues?.flock_name?.lastIndexOf(' ') - ) - ); - })?.label ?? '', + return flock.label == trimFlock; + })?.label ?? trimFlock, area_id: initialValues?.area?.id ?? 0, category: initialValues?.category as NonNullable< 'GROWING' | 'LAYING' | undefined >, fcr_id: initialValues?.fcr?.id ?? 0, location_id: initialValues?.location?.id ?? 0, - kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as ( - | number - | undefined - )[], + kandang_ids: initialValues?.kandangs?.map( + (k: Kandang) => k.id + ) as number[], + project_budgets: initialValues?.project_budgets?.map((budget) => { + return { + nonstock: { + value: budget.nonstock?.id ?? '', + label: budget.nonstock?.name ?? '', + }, + nonstock_id: budget.nonstock?.id ?? '', + qty: budget.qty, + price: budget.price, + total_price: budget.qty * budget.price, + }; + }) ?? [ + { + nonstock: null, + nonstock_id: '', + qty: '', + price: '', + total_price: '', + }, + ], }; }, [initialValues, optionsFlock]); // Formik const formik = useFormik({ initialValues: { - flock: initialValues?.flock_name - ? { - value: - optionsFlock.find((flock) => { - return ( - flock.label == - initialValues?.flock_name?.slice( - 0, - initialValues?.flock_name?.lastIndexOf(' ') - ) - ); - })?.value ?? 0, - label: - formType != 'detail' - ? (optionsFlock.find((flock) => { - return ( - flock.label == - initialValues?.flock_name?.slice( - 0, - initialValues?.flock_name?.lastIndexOf(' ') - ) - ); - })?.label ?? '') - : initialValues?.flock_name, - } - : null, - area: initialValues?.area - ? { - value: initialValues.area?.id, - label: initialValues.area.name, - } - : null, - category_option: initialValues?.category - ? { - value: initialValues.category, - label: initialValues.category, - } - : 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_name: - formType != 'detail' - ? optionsFlock.find((flock) => { - return ( - flock.label == - initialValues?.flock_name?.slice( - 0, - initialValues?.flock_name?.lastIndexOf(' ') - ) - ); - })?.label - : (initialValues?.flock_name ?? ''), - area_id: initialValues?.area?.id ?? 0, - category: initialValues?.category as NonNullable< - 'GROWING' | 'LAYING' | undefined - >, - fcr_id: initialValues?.fcr?.id ?? 0, - location_id: initialValues?.location?.id ?? 0, - kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as ( - | number - | undefined - )[], + ...formikInitialValues, } as ProjectFlockFormValues, - enableReinitialize: true, validationSchema: formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, validateOnBlur: true, - validateOnChange: true, - validateOnMount: true, + // validateOnChange: true, + // validateOnMount: true, onSubmit: async (values) => { setProjectFlockFormErrorMessage(''); const payload: CreateProjectFlockPayload = { @@ -444,6 +401,13 @@ const ProjectFlockForm = ({ fcr_id: values.fcr_id as number, location_id: values.location_id as number, kandang_ids: values.kandang_ids as number[], + project_budgets: values.project_budgets.flatMap((budget) => { + return { + nonstock_id: budget.nonstock_id, + qty: budget.qty, + price: budget.price, + } as ProjectFlockBudget; + }), }; switch (formType) { @@ -458,8 +422,8 @@ const ProjectFlockForm = ({ } }, }); - const { setValues: formikSetValues } = formik; + // Effect Initial useEffect(() => { if (formType == 'detail') { @@ -475,7 +439,18 @@ const ProjectFlockForm = ({ }, [initialValues, setSelectedArea, formType]); useEffect(() => { - formikSetValues(formikInitialValues); + const trimFlock = + initialValues?.flock_name?.slice( + 0, + initialValues?.flock_name?.lastIndexOf(' ') + ) ?? ''; + formikSetValues({ + ...formikInitialValues, + flock: optionsFlock.find((flock) => { + return flock.label == trimFlock; + }) as OptionType, + flock_name: trimFlock ?? '', + }); }, [formikSetValues]); // Aktifkan lokasi jika formType = 'detail' @@ -495,10 +470,6 @@ const ProjectFlockForm = ({ } }, [formType, initialValues]); - useEffect(() => { - formik.validateForm(); - }, [formik.values]); - useEffect(() => { const selectedRowIds = Object.keys(rowSelection) .filter((id) => rowSelection[id]) @@ -509,6 +480,46 @@ const ProjectFlockForm = ({ }); }, [rowSelection, formikSetValues]); + useEffect(() => { + const unsub = subscribeValidate(() => { + formik.validateForm().then((errors) => { + if (Object.keys(errors).length > 0) { + // Membentuk touched object yang strongly-typed + const touched: Record[]> = + {}; + Object.keys(formik.values).forEach((key) => { + if ( + key === 'project_budgets' && + Array.isArray(formik.values.project_budgets) + ) { + touched[key] = formik.values.project_budgets.map(() => ({})); // Mark each item as touched if it's an array + } else { + touched[key] = true; + } + }); + + formik.setTouched(touched, true); + } + setIsValid(Object.keys(errors).length === 0); + }); + }); + + return unsub; + }, []); + + useEffect(() => { + if (initialValues?.approval?.step_name) { + const pengajuanRejected = + initialValues.approval.step_number == 1 && + initialValues.approval.action == 'REJECTED'; + const approvedDisabled = + initialValues.approval.step_number !== 1 || pengajuanRejected; + setIsApprovedDisabled(approvedDisabled); + setIsRejectedDisabled(!approvedDisabled || pengajuanRejected); + setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED'); + } + }, [initialValues]); + // Actions handler const confirmationModalDeleteClickHandler = async () => { setIsDeleteLoading(true); @@ -526,6 +537,42 @@ const ProjectFlockForm = ({ setIsDeleteLoading(false); }; + const onAddBudgetRowHandler = () => { + const newProjectBudgets = [ + ...(formik.values.project_budgets ?? []), + { + nonstock: null, + nonstock_id: '', + qty: '', + price: '', + }, + ]; + formik.setFieldValue('project_budgets', newProjectBudgets); + }; + + const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => { + console.log(`nonstock_id: ${nonstock_id}, index: ${index}`); + if (!nonstock_id) { + const updatedBudgets = formik.values.project_budgets + .map((budget, i) => { + if (i == index) { + console.log(`buget: ${null}, index: ${index}, i: ${i}`); + return null; + } else { + console.log(`buget: ${budget}, index: ${index}, i: ${i}`); + return budget; + } + }) + .filter((budget) => budget != null); + formik.setFieldValue('project_budgets', updatedBudgets); + } else { + const updatedBudgets = (formik.values.project_budgets ?? []).filter( + (budget) => budget.nonstock_id !== nonstock_id + ); + formik.setFieldValue('project_budgets', updatedBudgets); + } + }; + const confirmApprovalHandler = async ( notes: string, approvalAction: 'REJECTED' | 'APPROVED' @@ -549,6 +596,67 @@ const ProjectFlockForm = ({ setIsApproveLoading(false); }; + const handleBudgetChange = ( + index: number, + fieldName: 'qty' | 'price' | 'total_price', + value: string + ) => { + const updatedBudgets = [...formik.values.project_budgets]; + const currentBudget = updatedBudgets[index]; + + const isNewValueEmpty = value === ''; + + let numericValue: number; + + if (isNewValueEmpty) { + (currentBudget[fieldName] as string) = ''; + numericValue = 0; + + formik.setFieldValue('project_budgets', updatedBudgets); + return; + } else { + numericValue = Math.max(0, parseFloat(value) || 0); + + (currentBudget[fieldName] as number) = numericValue; + } + + const getSafeNumber = (val: string | number) => + Math.max(0, parseFloat(String(val)) || 0); + + const currentQty = getSafeNumber(currentBudget.qty); + const currentPrice = getSafeNumber(currentBudget.price); + const currentTotal = getSafeNumber(currentBudget.total_price); + + let newQty = currentQty; + let newPrice = currentPrice; + let newTotal = currentTotal; + + if (fieldName === 'price') { + // Jika Harga Satuan diubah, hitung Total Harga + newTotal = newQty * numericValue; + newPrice = numericValue; + } else if (fieldName === 'qty') { + // Jika Kuantitas diubah, hitung Total Harga + newTotal = numericValue * newPrice; + newQty = numericValue; + } else if (fieldName === 'total_price') { + // Jika Total Harga diubah, hitung Harga Satuan + newTotal = numericValue; + if (newQty > 0) { + newPrice = newTotal / newQty; + } else { + // Jika Qty 0, Harga Satuan tetap 0 + newPrice = 0; + } + } + + currentBudget.qty = newQty; + currentBudget.price = newPrice; + currentBudget.total_price = newTotal; + + formik.setFieldValue('project_budgets', updatedBudgets); + }; + const selectedPeriod = isResponseSuccess(periodFlocks) ? periodFlocks.data.find((kandang) => formik.values.kandang_ids?.includes(kandang.id) @@ -557,25 +665,50 @@ const ProjectFlockForm = ({ const inputPeriod = (initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod; + const filteredNonStockOptions = optionsNonstock.filter((nonstock) => { + const isNonstockAlreadyInBudgets = ( + formik.values.project_budgets ?? [] + ).some((budget) => budget.nonstock_id === nonstock.value); + + return !isNonstockAlreadyInBudgets; + }); + return ( <>
-
- - -

- {formType === 'add' && 'Tambah Project Flock'} - {formType === 'edit' && 'Edit Project Flock'} - {formType === 'detail' && 'Detail Project Flock'} -

-
+ {/* Header */} + + {formType == 'edit' && ( + + )} + {projectFlockFormErrorMessage && (
@@ -631,21 +764,6 @@ const ProjectFlockForm = ({ Reject - {initialValues?.approval?.step_number == 2 && ( - - )}
)}
-
-
-
Informasi Umum
-
- - { - return flock.label === formik.values.flock_name; - })?.value, - } as OptionType) - : undefined - } - onChange={(val) => { - optionChangeHandler(val, 'flock'); - setSelectedFlock((val as OptionType)?.label); - formik.setFieldValue( - 'flock_name', - (val as OptionType)?.label - ); - }} - options={optionsFlock} - isLoading={isLoadingFlocks} - isError={ - formik.touched.flock_name && - Boolean(formik.errors.flock_name) - } - errorMessage={formik.errors.flock_name as string} - isClearable - isDisabled={formType === 'detail'} - /> - - { - optionChangeHandler(val, 'fcr'); - }} - options={optionsFcr} - isLoading={isLoadingFcrs} - isError={ - formik.touched.fcr_id && Boolean(formik.errors.fcr_id) - } - errorMessage={formik.errors.fcr_id as string} - isClearable - isDisabled={formType === 'detail'} - /> - - -
-
-
-
-
- -
Pilih Kandang
- -
+ {/* Form Informasi Umum */} +
+
+

Informasi Umum

+
+ -
- {isLoadingKandang && ( - - )} - -
- + errorMessage={formik.errors.area_id as string} + isClearable + isDisabled={formType === 'detail'} + /> + + { + return flock.label === formik.values.flock_name; + })?.value, + } as OptionType) + : undefined + } + onChange={(val) => { + optionChangeHandler(val, 'flock'); + setSelectedFlock((val as OptionType)?.label); + formik.setFieldValue( + 'flock_name', + (val as OptionType)?.label + ); + }} + options={optionsFlock} + isLoading={isLoadingFlocks} + isError={ + formik.touched.flock_name && Boolean(formik.errors.flock_name) + } + errorMessage={formik.errors.flock_name as string} + isClearable + isDisabled={formType === 'detail'} + /> + { + optionChangeHandler(val, 'fcr'); + }} + options={optionsFcr} + isLoading={isLoadingFcrs} + isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)} + errorMessage={formik.errors.fcr_id as string} + isClearable + isDisabled={formType === 'detail'} + /> + +
-
- {formType !== 'detail' && ( -
- - + {/* Form Pilih Kandang */} +
+
+

Pilih Kandang

+
+ {isLoadingKandang && ( + + )} + +
+
+ + {/* Card Estimasi Budget */} +
+
+

+ Estimasi Anggaran Per Flock +

+
+ {formik.values.project_budgets && + formik.values.project_budgets.length > 0 ? ( + formik.values.project_budgets.map((budget, index) => ( + +
+
+
Anggaran ke-{index + 1}
+ +
+
+ { + const updatedBudgets = [ + ...formik.values.project_budgets, + ]; + updatedBudgets[index].nonstock = val as OptionType; + updatedBudgets[index].nonstock_id = + (val as OptionType) + ? (val as OptionType).value + : 0; + formik.setFieldValue( + 'project_budgets', + updatedBudgets + ); + formik.setFieldTouched( + `project_budgets[${index}].nonstock_id`, + true + ); + }} + errorMessage={ + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.nonstock_id as string + } + isError={ + formik.touched.project_budgets?.[index] + ?.nonstock_id && + Boolean( + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.nonstock_id as string + ) + } + /> +
+
+ + handleBudgetChange(index, 'qty', e.target.value) + } + onBlur={formik.handleBlur} + allowNegative={false} + endAdornment={ +
+ {isResponseSuccess(nonstocks) + ? (nonstocks.data.find( + (ns) => ns.id === budget.nonstock_id + )?.uom?.name ?? '') + : ''} +
+ } + errorMessage={ + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.qty as string + } + isError={ + formik.touched.project_budgets?.[index]?.qty && + Boolean( + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.qty as string + ) + } + /> +
+
+ + handleBudgetChange(index, 'price', e.target.value) + } + onBlur={formik.handleBlur} + placeholder='Masukkan harga satuan' + allowNegative={false} + startAdornment='Rp' + endAdornment={ +
+ {`Per ${ + isResponseSuccess(nonstocks) + ? (nonstocks.data.find( + (ns) => ns.id === budget.nonstock_id + )?.uom?.name ?? 'Item') + : 'Item' + }`} +
+ } + errorMessage={ + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.price as string + } + isError={ + formik.touched.project_budgets?.[index]?.price && + Boolean( + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.price as string + ) + } + /> +
+
+ + handleBudgetChange( + index, + 'total_price', + e.target.value + ) + } + onBlur={formik.handleBlur} + placeholder='Masukkan harga total' + allowNegative={false} + startAdornment='Rp' + endAdornment={ +
Total
+ } + errorMessage={ + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.total_price as string + } + isError={ + formik.touched.project_budgets?.[index] + ?.total_price && + Boolean( + ( + formik.errors.project_budgets?.[ + index + ] as FormikErrors + )?.total_price as string + ) + } + /> +
+
+
+ )) + ) : ( +
+ Tidak ada data estimasi anggaran. +
+ )} + +
+
+ +
+ {/*
+
+ {JSON.stringify(formik.values)}
+
+ {JSON.stringify(formik.errors)} +
+
*/} + {formType !== 'detail' && ( + )}
- {formType != 'add' && ( -
- {formType != 'edit' && ( - - )} - -
- )}
{ - const initialKandangIdSet = useMemo(() => { - return initialValues?.kandangs.map((k) => k.id) ?? []; - }, [initialValues]); - const isRowEnabled = (row: Row) => { - const isDisabled = - !initialKandangIdSet.includes(row.original.id) && - (row.original.status == 'ACTIVE' || - row.original.status == 'PENGAJUAN' || - formType == 'detail'); - return !isDisabled; + // Fungsi untuk menangani perubahan checkbox + const handleCheckboxChange = (kandang: Kandang, isChecked: boolean) => { + // Hanya izinkan perubahan jika tidak dalam mode 'detail' + if (formType === 'detail') return; + + // Pastikan kandang.id ada dan tidak null/undefined + if (kandang.id === undefined) return; + + const kandangIdString = kandang.id.toString(); + + setRowSelection((prev) => { + const newSelection = { ...prev }; + if (isChecked) { + newSelection[kandangIdString] = true; + } else { + delete newSelection[kandangIdString]; + } + return newSelection; + }); }; return ( <> - - data={listKandang} - columns={[ - { - id: 'select', - header: ({ table }) => { - const allRows = table.getRowModel().rows; - // 1. Filter semua baris dengan logika yang sama persis seperti di cell - const selectableRows = allRows.filter(isRowEnabled); + {listKandang.length > 0 ? ( + <> + {/* ... Bagian Badge Status ... */} +
+ + + Tersedia ( + { + listKandang.filter((kandang) => kandang.status == 'NON_ACTIVE') + .length + } + ) + +
+ + + Tidak Tersedia ( + { + listKandang.filter((kandang) => kandang.status != 'NON_ACTIVE') + .length + } + ) + +
+ {/* --- */} + +
+ {listKandang.map((kandang, index) => { + const kandangIdString = + kandang.id?.toString() ?? `temp-${index}`; - // 2. Cek apakah SEMUA baris yang BISA DIPILIH sudah terpilih - const allSelected = - selectableRows.length > 0 && - selectableRows.every((row) => row.getIsSelected()); + const isSelected = + !!rowSelection[kandangIdString] || + (kandang.id !== undefined && + selectedIds.includes(kandang.id)); - // 3. Cek apakah BEBERAPA baris yang BISA DIPILIH sudah terpilih - const someSelected = - selectableRows.some((row) => row.getIsSelected()) && - !allSelected; + const isDisabled = + formType == 'detail' || kandang.status != 'NON_ACTIVE'; - // 4. Fungsi toggle HANYA akan mentoggle baris yang BISA DIPILIH - const toggleSelectableRows = () => { - const shouldSelect = !allSelected; - selectableRows.forEach((row) => - row.toggleSelected(shouldSelect) + return ( +
+ + handleCheckboxChange(kandang, e.currentTarget.checked) + } + /> + + + {kandang.status != 'NON_ACTIVE' && 'Tidak'} Tersedia + +
); - }; - - return ( -
- -
- ); - }, - cell: ({ row }) => { - return ( - - ); - }, - }, - { - accessorFn: (row) => row.name, - header: 'Kandang', - }, - { - accessorFn: (row) => row.status, - header: 'Status', - cell: (props) => { - return ( - { - switch (props.row.original.status) { - case 'ACTIVE': - return 'red'; - case 'PENGAJUAN': - return 'green'; - case 'NON_ACTIVE': - return 'blue'; - default: - return 'gray'; - } - })()} - content={props.row.original.status - .toLowerCase() - .replace(/_/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase())} - /> - ); - }, - }, - { - accessorFn: (row) => row.capacity, - header: 'Kapasitas', - }, - { - accessorFn: (row) => row.location?.name, - header: 'Periode', - cell: (props) => { - console.log('listPeriods'); - console.log(listPeriods); - const period = - listPeriods.length > 0 - ? listPeriods.find((p) => p.id == props.row.original.id) - : undefined; - const calcPeriod = period?.period == 0 ? 1 : period?.period; - const selected = props.row.getIsSelected(); - const initPeriod = initialValues?.period; - return formType == 'detail' - ? selected - ? initPeriod - : '-' - : formType == 'add' - ? (calcPeriod ?? '-') - : selected - ? (initPeriod ?? '-') - : (calcPeriod ?? '-'); - }, - }, - { - accessorFn: (row) => row.pic?.name, - header: 'Penanggung Jawab', - }, - ]} - className={{ - containerClassName: cn({ - 'mb-20': listKandang?.length === 0, - }), - tableWrapperClassName: 'overflow-x-auto min-h-full!', - tableClassName: 'font-inter w-full table-auto min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', - bodyColumnClassName: - 'px-6 py-3 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', - }} - rowSelection={rowSelection} - setRowSelection={setRowSelection} - /> + })} +
+
+ + ) : ( +
+ Pilih lokasi terlebih dahulu +
+ )} ); }; diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 6cf254e7..4a413bc4 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -370,7 +370,7 @@ const RecordingTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false); - const [approvalNotes, setApprovalNotes] = useState(''); + const [, setApprovalNotes] = useState(''); const singleDeleteModal = useModal(); const approveModal = useModal(); diff --git a/src/config/constant.ts b/src/config/constant.ts index dc36025b..8f4a9b46 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -1,155 +1,125 @@ -type MAIN_DRAWER_MENU = { - title: string; - link: string; - icon: string; - submenu?: MAIN_DRAWER_MENU[]; -}; +import { SidebarMenuItem } from '@/components/molecules/SidebarMenu'; -export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ +export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ { - title: 'Dashboard', + text: 'Dashboard', link: '/dashboard', - icon: 'gg:chart', + icon: 'heroicons-outline:chart-bar-square', }, - { - title: 'Produksi', + text: 'Produksi', link: '/production', - icon: 'material-symbols:conveyor-belt-outline-rounded', + icon: 'heroicons-outline:wrench-screwdriver', submenu: [ { - title: 'List Flock', + text: 'Daftar Flock', link: '/production/project-flock', - icon: 'material-symbols:list-alt-add-outline-rounded', }, - // { // DI HILANGKAN PADA VERSI REFACTORING - // title: 'Chick In', - // link: '/production/chickin', - // icon: 'mdi:home-import-outline', - // }, { - title: 'Recording', + text: 'Recording', link: '/production/recording', - icon: 'mdi:clipboard-text', }, { - title: 'Transfer ke Laying', + text: 'Transfer to Laying', link: '/production/transfer-to-laying', - icon: 'streamline:transfer-van', }, ], }, - { - title: 'Pembelian', + text: 'Pembelian', link: '/purchase', - icon: 'gg:shopping-cart', + icon: 'heroicons-outline:shopping-cart', }, - { - title: 'Penjualan', + text: 'Penjualan', link: '/marketing', - icon: 'mdi:attach-money', + icon: 'heroicons-outline:currency-dollar', }, - { - title: 'Biaya Operasional', + text: 'Biaya Operasional', link: '/expense', - icon: 'uil:wallet', + icon: 'heroicons:wallet', }, - { - title: 'Persediaan', + text: 'Closing', + link: '/closing', + icon: 'heroicons-outline:presentation-chart-bar', + }, + { + text: 'Persediaan', link: '/inventory', - icon: 'mdi:warehouse', + icon: 'heroicons-outline:folder', submenu: [ - // { - // title: 'Product', - // link: '/inventory/product', - // icon: 'mdi:package-variant-closed', - // }, { - title: 'Penyesuaian Stok', - link: '/inventory/adjustment', - icon: 'mdi:database-edit', + text: 'Produk', + link: '/inventory/product', }, { - title: 'Transfer Stok', + text: 'Penyesuaian Stok', + link: '/inventory/adjustment', + }, + { + text: 'Transfer Stok', link: '/inventory/movement', - icon: 'mdi:swap-horizontal', }, ], }, - { - title: 'Master Data', + text: 'Master Data', link: '/master-data', - icon: 'majesticons:data-line', + icon: 'heroicons-outline:circle-stack', submenu: [ { - title: 'Product', + text: 'Produk', link: '/master-data/product', - icon: 'fluent-mdl2:product-variant', }, { - title: 'Product Category', + text: 'Kategori Produk', link: '/master-data/product-category', - icon: 'carbon:categories', }, { - title: 'Bank', + text: 'Bank', link: '/master-data/bank', - icon: 'mdi:bank-outline', }, { - title: 'Area', + text: 'Area', link: '/master-data/area', - icon: 'majesticons:map-marker-area-line', }, { - title: 'Location', + text: 'Lokasi', link: '/master-data/location', - icon: 'mingcute:location-line', }, { - title: 'Kandang', + text: 'Kandang', link: '/master-data/kandang', - icon: 'mdi:farm-home-outline', }, { - title: 'Warehouse', + text: 'Warehouse', link: '/master-data/warehouse', - icon: 'hugeicons:warehouse', }, { - title: 'Customer', + text: 'Customer', link: '/master-data/customer', - icon: 'ix:customer', }, { - title: 'UOM', + text: 'UOM', link: '/master-data/uom', - icon: 'lsicon:measure-outline', }, { - title: 'Non-Stock', + text: 'Non-Stock', link: '/master-data/nonstock', - icon: 'fluent:box-32-regular', }, { - title: 'FCR', + text: 'FCR', link: '/master-data/fcr', - icon: 'fluent:food-chicken-leg-16-regular', }, { - title: 'Supplier', + text: 'Supplier', link: '/master-data/supplier', - icon: 'material-symbols:add-business-outline-rounded', }, { - title: 'Flock', + text: 'Flock', link: '/master-data/flock', - icon: 'material-symbols:raven-outline-rounded', }, ], }, diff --git a/src/lib/helper.ts b/src/lib/helper.ts index 2c66e1cf..c69f610f 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -10,6 +10,8 @@ export const sleep = (ms: number = 1000) => new Promise((resolve) => setTimeout(resolve, ms)); export const formatDate = (date: moment.MomentInput, format?: string) => { + if (!date) return '-'; + return moment(date).format(format); }; @@ -29,6 +31,14 @@ export const formatNumber = ( }).format(value); }; +export const formatTitleCase = (value: string) => { + return value + .toLowerCase() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + export function formatVechicleNumber(value: string): string { let result = ''; for (let i = 0; i < (value?.length ?? 0); i++) { @@ -119,3 +129,16 @@ export const convertRowSelectionObjToArr = ( return result; }; + +export const isPathActive = (pathname: string, link?: string) => { + if (!link) return false; + + const splittedPathname = pathname.split('/'); + const splittedLink = link.split('/'); + + const isActiveLinkValid = splittedLink.every((linkChunk, idx) => { + return linkChunk === splittedPathname[idx]; + }); + + return pathname.startsWith(link) && isActiveLinkValid; +}; diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts new file mode 100644 index 00000000..fe2c2d50 --- /dev/null +++ b/src/services/api/closing.ts @@ -0,0 +1,71 @@ +import axios from 'axios'; + +import { BaseApiService } from '@/services/api/base'; +import { + Closing, + ClosingGeneralInformation, + ClosingIncomingSapronak, + ClosingOutgoingSapronak, +} from '@/types/api/closing'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { ClosingSales } from '@/types/api/closing'; + +export class ClosingApiService extends BaseApiService { + constructor(basePath: string) { + super(basePath); + } + + async getPenjualan( + id: number + ): Promise | undefined> { + try { + const getPenjualanPath = `${id}/penjualan`; + return await this.customRequest>( + getPenjualanPath + ); + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + + async getAllIncomingSapronakFetcher( + endpoint: string + ): Promise> { + return await httpClientFetcher>( + endpoint + ); + } + + async getAllOutgoingSapronakFetcher( + endpoint: string + ): Promise> { + return await httpClientFetcher>( + endpoint + ); + } + + async getGeneralInfo(id: number) { + try { + const getGeneralInfoPath = `${this.basePath}/${id}`; + const getGeneralInfoRes = + await httpClient>( + getGeneralInfoPath + ); + + return getGeneralInfoRes; + } catch (error) { + if ( + axios.isAxiosError>(error) + ) { + return error.response?.data; + } + return undefined; + } + } +} + +export const ClosingApi = new ClosingApiService('/closings'); diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts index 337730e6..44a855f4 100644 --- a/src/services/api/expense.ts +++ b/src/services/api/expense.ts @@ -492,8 +492,8 @@ export class ExpenseApiService extends BaseApiService< }); formData.append( - 'cost_per_kandangs', - JSON.stringify(payload.cost_per_kandangs) + 'expense_nonstocks', + JSON.stringify(payload.expense_nonstocks) ); return formData; @@ -514,8 +514,8 @@ export class ExpenseApiService extends BaseApiService< }); formData.append( - 'cost_per_kandang', - JSON.stringify(payload.cost_per_kandang) + 'expense_nonstocks', + JSON.stringify(payload.expense_nonstocks) ); return formData; diff --git a/src/services/api/inventory.ts b/src/services/api/inventory.ts index e5d3adfc..fa406917 100644 --- a/src/services/api/inventory.ts +++ b/src/services/api/inventory.ts @@ -12,6 +12,7 @@ import { CreateInventoryAdjustmentPayload, InventoryAdjustment, } from '@/types/api/inventory/adjustment'; +import { InventoryProduct } from '@/types/api/inventory/product'; export const ProductWarehouseApi = new BaseApiService< ProductWarehouse, @@ -25,8 +26,14 @@ export const MovementApi = new BaseApiService< unknown >('/inventory/transfers'); -export const inventoryAdjustmentApi = new BaseApiService< +export const InventoryAdjustmentApi = new BaseApiService< InventoryAdjustment, CreateInventoryAdjustmentPayload, unknown >('/inventory/adjustments'); + +export const InventoryProductApi = new BaseApiService< + InventoryProduct, + unknown, + unknown +>('/inventory/product-stocks'); diff --git a/src/services/api/production.ts b/src/services/api/production.ts index 4266f6b7..110a881c 100644 --- a/src/services/api/production.ts +++ b/src/services/api/production.ts @@ -1,4 +1,4 @@ -import { BaseApiService } from './base'; +import { BaseApiService } from '@/services/api/base'; import { BaseApiResponse } from '@/types/api/api-general'; import { CreateProjectFlockPayload, diff --git a/src/services/api/production/project-flock-kandang.ts b/src/services/api/production/project-flock-kandang.ts index b7729325..47c9568e 100644 --- a/src/services/api/production/project-flock-kandang.ts +++ b/src/services/api/production/project-flock-kandang.ts @@ -2,10 +2,189 @@ import { BaseApiService } from '@/services/api/base'; import { BaseProjectFlockKandang, ProjectFlockKandang, + ClosingProjectFlockKandangPayload, + CheckClosingResponse, } from '@/types/api/production/project-flock-kandang'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { httpClient } from '@/services/http/client'; +import axios from 'axios'; -export const ProjectFlockKandangApi = new BaseApiService< +export class ProjectFlockKandangService extends BaseApiService< BaseProjectFlockKandang, ProjectFlockKandang, unknown ->('project-flock-kandang'); +> { + constructor(basePath: string = '') { + super(basePath); + } + + /** + * Close or Unclose Project Flock Kandang + */ + async closing( + id: number, + payload: ClosingProjectFlockKandangPayload + ): Promise | undefined> { + try { + const path = `${this.basePath}/${id}/closing`; + + const headers = { + 'Content-Type': 'application/json', + ...(this.header ?? {}), + }; + + return await httpClient>(path, { + method: 'POST', + body: payload, + headers, + }); + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + + /** + * Check Closing Requirements for Project Flock Kandang + * TODO: Replace with actual API call when backend is ready + */ + async checkClosing( + id: number + ): Promise | undefined> { + // Dummy data - replace with actual API call when backend is ready + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + code: 200, + status: 'success', + message: 'Cek persyaratan closing kandang', + data: { + unfinished_expenses: 2, + stock_remaining: [ + { + id: 1, + product_id: 1, + warehouse_id: 1, + quantity: 0, + product: { + id: 1, + name: 'Pakan Starter', + brand: 'Brand A', + sku: 'PKN-STR-001', + product_price: 15000, + selling_price: 17000, + tax: 0, + expiry_period: 365, + flags: ['active'], + uom: { + id: 1, + name: 'Kg', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + product_category: { + id: 1, + name: 'Pakan', + code: 'PKN', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + suppliers: [], + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + warehouse: { + id: 1, + name: 'Gudang Utama', + type: 'AREA', + area: { + id: 1, + name: 'Area 1', + }, + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01', + updated_at: '2025-01-01', + }, + ], + expenses: [ + { + id: 1, + po_number: 'PO-BOP-LTI-00001', + category: 'NON-BOP', + total: 110000, + status: 'SELESAI', + step_name: 'Approval Finance', + step: 5, + reference_number: 'BOP-LTI-00001', + }, + { + id: 3, + po_number: 'PO-BOP-LTI-00003', + category: 'BOP', + total: 110000, + status: 'SELESAI', + step_name: 'Approval Finance', + step: 5, + reference_number: 'BOP-LTI-00003', + }, + ], + }, + }); + }, 500); // Simulate network delay + }); + + /* + // Original API call - uncomment when backend is ready + try { + const path = `${this.basePath}/${id}/closing/check`; + + return await httpClient>(path, { + method: 'GET', + }); + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + */ + } +} + +export const ProjectFlockKandangApi = new ProjectFlockKandangService( + '/production/project-flock-kandangs' +); diff --git a/src/services/api/production/project-flock.ts b/src/services/api/production/project-flock.ts index ea0ef12e..d92881f6 100644 --- a/src/services/api/production/project-flock.ts +++ b/src/services/api/production/project-flock.ts @@ -141,6 +141,38 @@ export class ProjectFlockService extends BaseApiService< } } + /** + * Resubmit Project Flock + */ + async resubmit( + id: number, + payload: UpdateProjectFlockPayload + ): Promise | undefined> { + try { + const updatePath = `${this.basePath}/${id}/resubmit`; + + const headers = { + 'Content-Type': 'application/json', + ...(this.header ?? {}), + }; + + const updateRes = await httpClient>( + updatePath, + { + method: 'PUT', + body: payload, + headers, + } + ); + return updateRes; + } catch (error: unknown) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + /** * Approve single Project Flock */ diff --git a/src/stores/ui/slices/drawer.slice.ts b/src/stores/ui/slices/drawer.slice.ts new file mode 100644 index 00000000..b92b60c3 --- /dev/null +++ b/src/stores/ui/slices/drawer.slice.ts @@ -0,0 +1,40 @@ +import { DrawerUISlice } from '@/types/stores'; +import { StateCreator } from 'zustand'; + +export const createDrawerUISlice: StateCreator< + DrawerUISlice, + [], + [], + DrawerUISlice +> = (set, get, api) => ({ + // event flag untuk memicu formik validate + triggerValidate: false, + + // dibalik untuk memicu event + toggleValidate: () => { + const current = get().triggerValidate; + set({ triggerValidate: !current }); + }, + + // sistem subscriber sederhana agar form bisa listen perubahan flag + subscribeValidate: (callback: () => void) => { + let prev = get().triggerValidate; + + const unsub = api.subscribe((state) => { + if (state.triggerValidate !== prev) { + prev = state.triggerValidate; + callback(); + } + }); + + return unsub; + }, + + isValid: false, + setIsValid: (isValid: boolean) => set({ isValid }), + subscribeIsValid: (callback: (isValid: boolean) => void) => { + return api.subscribe((state) => { + callback(Boolean(state.isValid)); + }); + }, +}); diff --git a/src/stores/ui/ui.store.ts b/src/stores/ui/ui.store.ts index 49554bc9..cbc5785d 100644 --- a/src/stores/ui/ui.store.ts +++ b/src/stores/ui/ui.store.ts @@ -5,11 +5,13 @@ import { devtools } from 'zustand/middleware'; import { UIStore } from '@/types/stores'; import { createMainUiSlice } from '@/stores/ui/slices/main.slice'; +import { createDrawerUISlice } from '@/stores/ui/slices/drawer.slice'; export const useUiStore = create()( devtools( (...args) => ({ ...createMainUiSlice(...args), + ...createDrawerUISlice(...args), }), { name: 'UIStore', diff --git a/src/styles/daisyui.css b/src/styles/daisyui.css index fc87399f..8eca2c82 100644 --- a/src/styles/daisyui.css +++ b/src/styles/daisyui.css @@ -1,4 +1,9 @@ @layer utilities { + .menu { + --menu-active-fg: var(--color-primary); + --menu-active-bg: transparent; + } + .step.step-success::before { --step-bg: var(--color-success); --step-fg: var(--color-success-content); diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts new file mode 100644 index 00000000..95b2f57f --- /dev/null +++ b/src/types/api/closing.d.ts @@ -0,0 +1,81 @@ +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 { Location } from '@/types/api/master-data/location'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { Product } from '@type/api/master-data/product'; +import { Customer } from '@type/api/master-data/customer'; +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseSales = { + id: number; + realization_date: string; + age: number; + do_number: string; + product: Product; + customer: Customer; + qty: number; + weight: number; + avg_weight: number; + price: number; + total_price: number; + kandang: Kandang; + payment_status: string; +}; + +export type BaseClosingSales = { + project_type: string; + flock_id: number; + period: number; + sales: BaseSales[]; +}; + +export type BaseClosing = { + id: number; + location_id: number; + location_name: string; + project_category: 'GROWING' | 'LAYING'; + period: number; + closing_date?: string; + shed_label: string; + shed_count: number; + sales_paid_amount: number; + sales_remaining_amount: number; + sales_payment_status: string; + project_status: 'Pengajuan' | 'Aktif' | 'Selesai'; +}; + +export type Closing = BaseMetadata & BaseClosing; + +export type BaseClosingGeneralInformation = BaseClosing & { + flock_id: number; + period: number; + project_type: 'GROWING' | 'LAYING'; + population: number; + active_house_count: number; + sales_payment_status: string; + project_status: 'Pengajuan' | 'Aktif' | 'Selesai'; + closing_status: string; +}; + +export type ClosingGeneralInformation = BaseMetadata & + BaseClosingGeneralInformation; + +export type ClosingIncomingSapronak = { + id: number; + date: string; + reference_number: string; + transaction_type: string; + product_name: string; + product_category: string; + product_sub_category: string; + source_warehouse: string; + destination_warehouse: string; + quantity: number; + unit: string; + formatted_quantity: string; + notes: string; +}; + +export type ClosingOutgoingSapronak = ClosingIncomingSapronak; +export type ClosingSales = BaseMetadata & BaseClosingSales; diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts index 71863503..a62066ba 100644 --- a/src/types/api/expense.d.ts +++ b/src/types/api/expense.d.ts @@ -18,7 +18,7 @@ export type BaseExpense = { id: number; path: string; }[]; - expense_date: string; + transaction_date: string; realization_date?: string; grand_total: number; location: BaseLocation; @@ -29,28 +29,23 @@ export type BaseExpense = { name: string; pengajuans?: { id: number; + expense_id: number; + kandang_id: number; + nonstock_id: number; qty: number; - unit_price: number; - total_price: number; + price: number; note?: string; nonstock: Pick; - project_flock_kandang: { - id: number; - kandang_id: number; - }; + created_at: string; }[]; realisasi?: { id: number; + expense_nonstock_id: number; qty: number; - unit_price: number; - total_price: number; - date: string; + price: number; note?: string; nonstock: Pick; - project_flock_kandang: { - id: number; - kandang_id: number; - }; + created_at: string; }[]; }[]; total_pengajuan: number; @@ -65,12 +60,12 @@ export type CreateExpensePayload = { transaction_date: string; supplier_id: number; documents: File[]; - cost_per_kandangs: { + expense_nonstocks: { kandang_id: number; cost_items: { nonstock_id: number; quantity: number; - total_cost: number; + price: number; notes: string; }[]; }[]; @@ -81,12 +76,12 @@ export type UpdateExpensePayload = { transaction_date: string; supplier_id: number; documents: File[]; - cost_per_kandang: { + expense_nonstocks: { kandang_id: number; cost_items: { nonstock_id: number; quantity: number; - total_cost: number; + price: number; notes: string; }[]; }[]; @@ -98,8 +93,7 @@ export type CreateExpenseRealizationPayload = { realizations: { expense_nonstock_id: number; qty: number; - unit_price: number; - total_price: number; + price: number; notes: string; }[]; }; diff --git a/src/types/api/inventory/product.d.ts b/src/types/api/inventory/product.d.ts new file mode 100644 index 00000000..cb8f98a1 --- /dev/null +++ b/src/types/api/inventory/product.d.ts @@ -0,0 +1,48 @@ +import { BaseMetadata, CreatedUser } from '@/types/api/api-general'; +import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; +import { ProductCategory } from '@/types/api/master-data/product-category'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Uom } from '@/types/api/master-data/uom'; +import { Location } from '@/types/api/master-data/location'; + +export type BaseInventoryProduct = { + id: number; + name: string; + brand: string; + sku: string; + product_price: number; + selling_price?: number; + tax?: number; + expiry_period?: number; + uom: Uom; + product_category: ProductCategory; + suppliers: Supplier[]; + flags: string[]; + product_warehouses?: ProductWarehouseStock[]; + total_stock?: number; +}; + +export type ProductWarehouseStock = { + id: number; + product_id: number; + warehouse_id: number; + warehouse_name: string; + location: Location | null; + current_stock: number; + stock_logs: StockLog[]; +}; + +export type StockLog = { + id: number; + increase: number; + decrease: number; + loggable_type: string; + loggable_id: number; + notes: string; + product_warehouse_id: number; + created_by: number; + created_user: CreatedUser; + created_at: string; +}; + +export type InventoryProduct = BaseInventoryProduct & BaseMetadata; diff --git a/src/types/api/production/project-flock-kandang.d.ts b/src/types/api/production/project-flock-kandang.d.ts index b7b22b99..388eed32 100644 --- a/src/types/api/production/project-flock-kandang.d.ts +++ b/src/types/api/production/project-flock-kandang.d.ts @@ -39,3 +39,25 @@ export type LookupProjectFlockKandangPayload = { project_flock_id: number; kandang_id: number; }; + +export type ClosingProjectFlockKandangPayload = { + action: 'close' | 'unclose'; + closed_date?: string; // YYYY-MM-DD, DD-MM-YYYY, or RFC3339 +}; + +export type ClosingExpense = { + id: number; + po_number: string; + category: string; + total: number; + status: string; + step_name: string; + step: number; + reference_number: string; +}; + +export type CheckClosingResponse = { + unfinished_expenses: number; + stock_remaining: ProductWarehouse[]; + expenses: ClosingExpense[]; +}; diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index c5b0aaf8..35c42c38 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -4,6 +4,7 @@ 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 { BaseApproval, BaseMetadata } from '@/types/api/api-general'; +import { Nonstock } from '@/types/api/master-data/nonstock'; export type BaseProjectFlock = { id: number; @@ -22,6 +23,7 @@ export type BaseProjectFlock = { kandangs: (Kandang & { project_flock_kandang_id: number; })[]; + project_budgets?: ProjectFlockBudget[]; approval: BaseApproval; }; @@ -30,6 +32,15 @@ export type PeriodFlock = { next_period: number; }; +export type ProjectFlockBudget = { + id?: number; + project_flock_id?: number; + nonstock_id: number; + nonstock?: Nonstock; + qty: number; + price: number; +}; + export type ProjectFlock = BaseMetadata & BaseProjectFlock; export type CreateProjectFlockPayload = { @@ -39,6 +50,7 @@ export type CreateProjectFlockPayload = { fcr_id: number; location_id: number; kandang_ids: number[]; + project_budgets?: ProjectFlockBudget[]; }; export type UpdateProjectFlockPayload = CreateProjectFlockPayload; diff --git a/src/types/stores.d.ts b/src/types/stores.d.ts index 1a3046ae..37b252fe 100644 --- a/src/types/stores.d.ts +++ b/src/types/stores.d.ts @@ -3,4 +3,13 @@ type MainUiSlice = { setMainDrawerOpen: (open: boolean) => void; }; -export type UIStore = MainUiSlice; +type DrawerUISlice = { + triggerValidate: boolean; + toggleValidate: () => void; + subscribeValidate: (callback: () => void) => void; + isValid: boolean; + setIsValid: (v: boolean) => void; + subscribeIsValid: (callback: (isValid: boolean) => void) => () => void; +}; + +export type UIStore = MainUiSlice & DrawerUISlice; diff --git a/src/types/theme.d.ts b/src/types/theme.d.ts index f83750e4..dcd9e13f 100644 --- a/src/types/theme.d.ts +++ b/src/types/theme.d.ts @@ -1,4 +1,4 @@ -type Color = +export type Color = | 'primary' | 'secondary' | 'accent' @@ -9,4 +9,4 @@ type Color = | 'error' | 'none'; -export { Color }; +export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';