From 096a8d394ef31976ae5d88b48c2ae8110f0eae71 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 18 Dec 2025 18:50:22 +0700 Subject: [PATCH 1/8] Merge branch 'development' into feat/FE/US-335/production-data-report --- .gitignore | 3 + .gitlab-ci.yml | 33 +- next.config.ts | 1 + package-lock.json | 2 +- package.json | 2 +- src/app/closing/detail/page.tsx | 19 +- src/app/globals.css | 53 +- src/app/inventory/adjustment/detail/page.tsx | 5 - .../product/detail}/layout.tsx | 0 src/app/inventory/product/detail/page.tsx | 50 + src/app/inventory/product/page.tsx | 11 + src/app/marketing/page.tsx | 1 + src/app/page.tsx | 32 +- src/app/production/project-flock/add/page.tsx | 10 +- .../chickin/add/kandang/page.tsx | 2 +- .../project-flock/chickin/add/page.tsx | 2 +- .../production/project-flock/chickin/page.tsx | 2 +- .../project-flock/closing/layout.tsx | 11 + .../production/project-flock/closing/page.tsx | 63 + .../project-flock/detail/edit/page.tsx | 2 +- .../production/project-flock/detail/page.tsx | 15 +- src/app/production/project-flock/layout.tsx | 60 + src/app/production/project-flock/page.tsx | 2 +- .../production/recording/detail/edit/page.tsx | 2 +- src/app/production/recording/detail/page.tsx | 2 +- .../production/recording/grading/add/page.tsx | 49 - .../recording/grading/detail/edit/page.tsx | 53 - .../recording/grading/detail/page.tsx | 52 - src/components/Card.tsx | 2 +- src/components/Drawer.tsx | 111 +- src/components/FloatingActionsButton.tsx | 141 ++ src/components/Navbar.tsx | 25 +- src/components/Table.tsx | 162 ++- src/components/dropdown/Dropdown.tsx | 116 ++ src/components/helper/RequireAuth.tsx | 67 +- src/components/helper/drawer/DrawerHeader.tsx | 104 ++ src/components/input/DateInput.tsx | 4 +- src/components/input/RadioInput.tsx | 249 ++-- src/components/pages/ApprovalSteps.tsx | 66 +- .../pages/closing/ClosingDetail.tsx | 21 +- .../closing/ClosingOverheadTabContent.tsx | 19 + .../pages/closing/ClosingOverheadTable.tsx | 162 +++ .../ClosingSapronakCalculationTabContent.tsx | 25 + .../ClosingSapronakCalculationTable.tsx | 221 ++++ .../pages/closing/sale/SalesReportTable.tsx | 285 +++++ .../expense/ExpenseRealizationContent.tsx | 8 +- .../pages/expense/ExpenseRequestContent.tsx | 13 +- .../pages/expense/ExpensesTable.tsx | 6 +- .../form/ExpenseRealizationForm.schema.ts | 8 +- .../expense/form/ExpenseRealizationForm.tsx | 9 +- ...ExpenseRealizationKandangDetailExpense.tsx | 12 +- .../expense/form/ExpenseRequestForm.schema.ts | 16 +- .../pages/expense/form/ExpenseRequestForm.tsx | 53 +- .../ExpenseRequestKandangDetailExpense.tsx | 49 +- .../pages/expense/pdf/ExpensePDF.tsx | 14 +- .../adjustment/InventoryAdjustmentTable.tsx | 6 +- .../form/InventoryAdjustmentForm.tsx | 10 +- .../product/InventoryProductTable.tsx | 233 ++++ .../product/detail/InventoryProductDetail.tsx | 118 ++ .../product/detail/StockLogTable.tsx | 81 ++ .../detail/StockProductWarehouseTable.tsx | 65 + .../marketing/form/MarketingForm.schema.ts | 2 +- .../pages/marketing/form/MarketingForm.tsx | 32 +- .../delivery-order/DeliverOrderProduct.tsx | 2 +- .../marketing/pdf/DeliveryOrderExport.tsx | 2 +- .../pages/marketing/pdf/SalesOrderExport.tsx | 2 +- .../supplier/form/SupplierForm.tsx | 1 - .../production/chickin/form/ChickinForm.tsx | 239 ++-- .../chickin/form/tabs/ChickLogsView.tsx | 212 +-- .../chickin/form/tabs/ChickinFormView.tsx | 213 ++-- .../project-flock/ProjectFlockTable.tsx | 250 ++-- .../chickin/ProjectFlockChickinDetail.tsx | 301 ++++- .../closing/ProjectFlockClosingForm.tsx | 305 +++++ .../detail/ProjectFlockDetail.tsx | 476 +++++++ .../form/ProjectFlockForm.schema.ts | 156 ++- .../project-flock/form/ProjectFlockForm.tsx | 1007 +++++++++------ .../form/ProjectFlockKandangTable.tsx | 259 ++-- .../production/recording/RecordingTable.tsx | 144 +-- .../recording/form/RecordingForm.schema.ts | 61 +- .../recording/form/RecordingForm.tsx | 331 ++--- .../recording/grading/form/GradingForm.tsx | 1051 --------------- .../form/TransferToLayingForm.schema.ts | 4 +- .../pages/purchase/PurchaseTable.tsx | 15 +- .../order/PurchaseOrderAcceptApprovalForm.tsx | 73 +- .../form/order/PurchaseOrderForm.schema.ts | 32 +- .../order/PurchaseOrderStaffApprovalForm.tsx | 50 +- .../request/PurchaseRequestForm.schema.ts | 8 +- .../form/request/PurchaseRequestForm.tsx | 3 +- .../purchase/order/PurchaseOrderDetail.tsx | 153 ++- .../purchase/order/PurchaseOrderInvoice.tsx | 8 +- src/config/approval-line.ts | 39 +- src/config/constant.ts | 10 +- src/dummy/closing.dummy.ts | 1136 +++++++++++++++++ src/lib/auth-helper.ts | 25 + src/lib/helper.ts | 8 + src/services/api/closing.ts | 157 ++- src/services/api/expense.ts | 8 +- src/services/api/inventory.ts | 9 +- src/services/api/production.ts | 26 +- .../api/production/project-flock-kandang.ts | 181 ++- src/services/api/production/project-flock.ts | 32 + src/services/http/client.ts | 5 +- src/stores/ui/slices/drawer.slice.ts | 40 + src/stores/ui/ui.store.ts | 2 + src/types/api/closing.d.ts | 94 +- src/types/api/expense.d.ts | 34 +- src/types/api/inventory/product.d.ts | 48 + .../api/production/project-flock-kandang.d.ts | 40 + src/types/api/production/project-flock.d.ts | 12 + src/types/api/production/recording.d.ts | 48 +- src/types/api/purchase/purchase.d.ts | 19 +- src/types/stores.d.ts | 11 +- 112 files changed, 7158 insertions(+), 3238 deletions(-) rename src/app/{production/recording/grading => inventory/product/detail}/layout.tsx (100%) create mode 100644 src/app/inventory/product/detail/page.tsx create mode 100644 src/app/inventory/product/page.tsx create mode 100644 src/app/production/project-flock/closing/layout.tsx create mode 100644 src/app/production/project-flock/closing/page.tsx create mode 100644 src/app/production/project-flock/layout.tsx delete mode 100644 src/app/production/recording/grading/add/page.tsx delete mode 100644 src/app/production/recording/grading/detail/edit/page.tsx delete mode 100644 src/app/production/recording/grading/detail/page.tsx create mode 100644 src/components/FloatingActionsButton.tsx create mode 100644 src/components/dropdown/Dropdown.tsx create mode 100644 src/components/helper/drawer/DrawerHeader.tsx create mode 100644 src/components/pages/closing/ClosingOverheadTabContent.tsx create mode 100644 src/components/pages/closing/ClosingOverheadTable.tsx create mode 100644 src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx create mode 100644 src/components/pages/closing/ClosingSapronakCalculationTable.tsx create mode 100644 src/components/pages/closing/sale/SalesReportTable.tsx create mode 100644 src/components/pages/inventory/product/InventoryProductTable.tsx create mode 100644 src/components/pages/inventory/product/detail/InventoryProductDetail.tsx create mode 100644 src/components/pages/inventory/product/detail/StockLogTable.tsx create mode 100644 src/components/pages/inventory/product/detail/StockProductWarehouseTable.tsx create mode 100644 src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx create mode 100644 src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx delete mode 100644 src/components/pages/production/recording/grading/form/GradingForm.tsx create mode 100644 src/dummy/closing.dummy.ts create mode 100644 src/lib/auth-helper.ts create mode 100644 src/stores/ui/slices/drawer.slice.ts create mode 100644 src/types/api/inventory/product.d.ts diff --git a/.gitignore b/.gitignore index d86875dd..e47b8ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # idea .idea + +# claude +.claude diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c37bfd35..935cac46 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -73,8 +73,8 @@ stages: if [ "$CI_COMMIT_BRANCH" = "development" ]; then ENVIRONMENT_NAME="WEB-LTI-DEV" - elif [ "$CI_COMMIT_BRANCH" = "master" ]; then - ENVIRONMENT_NAME="WEB-LTI-PROD" + elif [ "$CI_COMMIT_BRANCH" = "staging" ]; then + ENVIRONMENT_NAME="WEB-LTI-STAGING" else ENVIRONMENT_NAME="UNKNOWN" fi @@ -122,11 +122,10 @@ build:dev: environment: name: development variables: - # NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id' - # NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id' NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id' NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id' NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api' + NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' deploy:dev: <<: *deploy_template @@ -140,6 +139,32 @@ deploy:dev: environment: name: development url: https://dev-lti-erp.mbugroup.id + +# ====== STAGING (Branch staging) ====== +build:staging: + <<: *build_template + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' + environment: + name: staging + variables: + NEXT_PUBLIC_LTI_URL: 'https://stg-lti-erp.mbugroup.id' + NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id' + NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api' + NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' + +deploy:staging: + <<: *deploy_template + needs: ['build:staging'] + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' + variables: + S3_BUCKET: 'stg-lti-erp.mbugroup.id' + AWS_REGION: 'ap-southeast-3' + CLOUDFRONT_DISTRIBUTION_ID: 'E2V6PPO1AUIU7H' + environment: + name: staging + url: https://stg-lti-erp.mbugroup.id # ====== PRODUCTION ====== # build:production: # <<: *build_template diff --git a/next.config.ts b/next.config.ts index c781a8ac..b2d25eb6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'export', images: { unoptimized: true }, + trailingSlash: true, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 01bff9ef..f0212474 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", diff --git a/package.json b/package.json index e1f92aaf..52fc6ce2 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", diff --git a/src/app/closing/detail/page.tsx b/src/app/closing/detail/page.tsx index 6225b8dd..1b4ebc45 100644 --- a/src/app/closing/detail/page.tsx +++ b/src/app/closing/detail/page.tsx @@ -19,6 +19,11 @@ const ClosingDetailPage = () => { (id: number) => ClosingApi.getGeneralInfo(id) ); + const { data: salesData, isLoading: isLoadingSales } = useSWR( + closingId ? `sales-${closingId}` : null, + () => ClosingApi.getPenjualan(Number(closingId)) + ); + if (!closingId) { router.back(); @@ -34,14 +39,18 @@ const ClosingDetailPage = () => { return; } + const isLoading = isLoadingClosing || isLoadingSales; + return (
- {isLoadingClosing && ( - - )} + {isLoading && } - {!isLoadingClosing && isResponseSuccess(closing) && ( - + {!isLoading && isResponseSuccess(closing) && ( + )}
); diff --git a/src/app/globals.css b/src/app/globals.css index 3fe7db88..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; 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/production/recording/grading/layout.tsx b/src/app/inventory/product/detail/layout.tsx similarity index 100% rename from src/app/production/recording/grading/layout.tsx rename to src/app/inventory/product/detail/layout.tsx 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/marketing/page.tsx b/src/app/marketing/page.tsx index 99a80b64..c30ee501 100644 --- a/src/app/marketing/page.tsx +++ b/src/app/marketing/page.tsx @@ -7,4 +7,5 @@ const Marketing = () => {
); }; + export default Marketing; diff --git a/src/app/page.tsx b/src/app/page.tsx index db9638df..9cc0177d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,29 @@ -import { redirect } from 'next/navigation'; +'use client'; + +import { useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useAuth } from '@/services/hooks/useAuth'; +import { redirectToSSO } from '@/lib/auth-helper'; export default function Home() { - redirect('/dashboard'); + const { user, isLoadingUser } = useAuth(); - return ( -
-

LTI ERP

-
- ); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (pathname === '/') { + router.replace('/dashboard'); + } + }, [pathname]); + + if (isLoadingUser) { + return ( +
+ +
+ ); + } + + return <>Loading...; } 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..d10bdfa2 --- /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(`get-flock-kandang-id/${projectFlockKandangId}`, () => + ProjectFlockKandangApi.getSingle(parseInt(projectFlockKandangId ?? '')) + ); + + const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR( + `get-flock-id/${projectFlockId}`, + () => ProjectFlockApi.getSingle(parseInt(projectFlockId ?? '')) + ); + + 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..3e9a65b7 --- /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.includes('/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/app/production/recording/detail/edit/page.tsx b/src/app/production/recording/detail/edit/page.tsx index de53a354..ad6c6a9a 100644 --- a/src/app/production/recording/detail/edit/page.tsx +++ b/src/app/production/recording/detail/edit/page.tsx @@ -14,7 +14,7 @@ const RecordingEdit = () => { const { data: recording, isLoading: isLoadingRecording } = useSWR( recordingId, - (id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi + (id: string) => RecordingApi.getSingle(parseInt(id)) ); if (!recordingId) { diff --git a/src/app/production/recording/detail/page.tsx b/src/app/production/recording/detail/page.tsx index 77b82a68..194365a3 100644 --- a/src/app/production/recording/detail/page.tsx +++ b/src/app/production/recording/detail/page.tsx @@ -14,7 +14,7 @@ const RecordingDetail = () => { const { data: recording, isLoading: isLoadingRecording } = useSWR( recordingId, - (id: number) => RecordingApi.getSingle(id) + (id: string) => RecordingApi.getSingle(parseInt(id)) ); if (!recordingId) { diff --git a/src/app/production/recording/grading/add/page.tsx b/src/app/production/recording/grading/add/page.tsx deleted file mode 100644 index 9b918d98..00000000 --- a/src/app/production/recording/grading/add/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; -import useSWR from 'swr'; -import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; -import { RecordingApi } from '@/services/api/production'; -import { isResponseSuccess } from '@/lib/api-helper'; - -const AddGrading = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - - const recordingId = searchParams.get('recording_id'); - - const { data: recording, isLoading: isLoadingRecording } = useSWR( - recordingId && recordingId !== 'new' ? [recordingId] : null, - ([id]) => RecordingApi.getSingle(parseInt(id)) - ); - - if ( - recordingId && - recordingId !== 'new' && - !isLoadingRecording && - (!recording || !isResponseSuccess(recording)) - ) { - router.replace('/404'); - return; - } - - return ( -
- {recordingId && recordingId !== 'new' && isLoadingRecording && ( - - )} - {(!recordingId || - recordingId === 'new' || - (!isLoadingRecording && recording && isResponseSuccess(recording))) && ( - - )} -
- ); -}; - -export default AddGrading; diff --git a/src/app/production/recording/grading/detail/edit/page.tsx b/src/app/production/recording/grading/detail/edit/page.tsx deleted file mode 100644 index 0a65f528..00000000 --- a/src/app/production/recording/grading/detail/edit/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; -import useSWR from 'swr'; -import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; -import { RecordingApi } from '@/services/api/production'; -import { isResponseSuccess } from '@/lib/api-helper'; - -const EditGrading = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - - const recordingId = searchParams.get('recordingId'); - const gradingId = searchParams.get('gradingId'); - - const { data: recording, isLoading: isLoadingRecording } = useSWR( - recordingId ? [recordingId] : null, - ([id]) => RecordingApi.getSingle(parseInt(id)) - ); - - if (!recordingId) { - router.back(); - - return ( -
- -
- ); - } - - if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) { - router.replace('/404'); - return; - } - - return ( -
- {isLoadingRecording && ( - - )} - {!isLoadingRecording && recording && isResponseSuccess(recording) && ( - egg.id === parseInt(gradingId || '0') - )} - /> - )} -
- ); -}; - -export default EditGrading; diff --git a/src/app/production/recording/grading/detail/page.tsx b/src/app/production/recording/grading/detail/page.tsx deleted file mode 100644 index 6a5fbcba..00000000 --- a/src/app/production/recording/grading/detail/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; -import useSWR from 'swr'; -import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; -import { RecordingApi } from '@/services/api/production'; -import { isResponseSuccess } from '@/lib/api-helper'; - -const DetailGrading = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - - const gradingId = searchParams.get('gradingId'); - - const { data: grading, isLoading: isLoadingGrading } = useSWR( - gradingId ? [gradingId] : null, - ([id]) => RecordingApi.getSingle(parseInt(id)) - ); - - if (!gradingId) { - router.back(); - - return ( -
- -
- ); - } - - if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) { - router.replace('/404'); - return; - } - - return ( -
- {isLoadingGrading && ( - - )} - {!isLoadingGrading && grading && isResponseSuccess(grading) && ( - egg.id === parseInt(gradingId) - )} - /> - )} -
- ); -}; - -export default DetailGrading; diff --git a/src/components/Card.tsx b/src/components/Card.tsx index d3ff80b1..ff4c35f2 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -4,7 +4,7 @@ import { HTMLAttributes, ReactNode, useState } from 'react'; import { cn } from '@/lib/helper'; import Image from 'next/image'; -import Collapse from './Collapse'; +import Collapse from '@/components/Collapse'; import { Icon } from '@iconify/react'; export interface CardProps 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 */} +
-
-
-
- + +
+ +
-
- - + } + contentClassName='w-52 mt-3' + > + -
+
); diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 970c5bc1..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; } @@ -53,6 +57,7 @@ export interface TableProps { rowSelection?: Record; setRowSelection?: OnChangeFn>; enableRowSelection?: boolean | ((row: Row) => boolean); + renderFooter?: boolean; withCheckbox?: boolean; rowOptions?: number[]; } @@ -67,18 +72,22 @@ const emptyContentDefaultValue = (
); -const TABLE_DEFAULT_STYLING = { +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 text-base-content/50', + headerColumnClassName: + 'px-4 py-3 border-base-content/10 text-base-content/50', tableBodyClassName: '', - bodyRowClassName: 'border-t border-t-base-content/10', + 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 = ({ @@ -100,6 +109,7 @@ const Table = ({ rowSelection, setRowSelection, enableRowSelection, + renderFooter = false, withCheckbox = false, rowOptions = [10, 20, 50, 100], }: TableProps) => { @@ -214,58 +224,82 @@ const Table = ({ key={headerGroup.id} className={tableClassNames.headerRowClassName} > - {headerGroup.headers.map((header) => ( - -
- {flexRender( - header.column.columnDef.header, - header.getContext() + {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 ( + 1, + }, + tableClassNames.headerColumnClassName )} + > +
1, + })} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} - {header.column.getCanSort() && ( -
- - -
- )} -
- - ))} + {header.column.getCanSort() && ( +
+ + +
+ )} +
+ + ); + })} ))} @@ -290,6 +324,28 @@ const Table = ({ ))} + + {renderFooter && ( + + {table.getAllLeafColumns().map((column) => ( + + {column.columnDef.footer && + flexRender(column.columnDef.footer, { + column, + header: column.columnDef, + table, + } as HeaderContext)} + + ))} + + )} +
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/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 119d74cb..65adf48c 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -1,56 +1,61 @@ 'use client'; import { ReactNode, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import useSWRImmutable from 'swr/immutable'; +import useSWR from 'swr'; import { useAuth } from '@/services/hooks/useAuth'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general'; import { AxiosError } from 'axios'; +import { redirectToSSO } from '@/lib/auth-helper'; interface RequireAuthProps { children?: ReactNode; } const RequireAuth = ({ children }: RequireAuthProps) => { - const router = useRouter(); - const { setUser, setIsLoadingUser } = useAuth(); + const { user, setUser, setIsLoadingUser } = useAuth(); const { data: userResponse, isLoading: isLoadingUserResponse, error: userErrorResponse, - } = useSWRImmutable< + } = useSWR< GetMeResponse & { ok?: boolean }, AxiosError, SWRHttpKey >('/sso/userinfo', httpClientFetcher, { shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshInterval: 0, }); - useEffect(() => { - setIsLoadingUser(isLoadingUserResponse); - }, [isLoadingUserResponse, setIsLoadingUser]); - useEffect(() => { if (isResponseSuccess(userResponse)) { setUser(userResponse.data); - } else if ( - isResponseError(userErrorResponse?.response?.data) && - typeof window !== 'undefined' - ) { - router.replace( - `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}` - ); } - }, [userResponse, userErrorResponse, setIsLoadingUser, setUser]); + }, [userResponse, setUser]); - if (isLoadingUserResponse && !userResponse && !userErrorResponse) { + // Explicitly handle 401 redirect from the component level + useEffect(() => { + if ( + isResponseError(userResponse) && + userErrorResponse?.response?.status === 401 + ) { + // Clear cache to prevent stale data from rendering children + // mutate('/sso/userinfo', undefined, { revalidate: false }); // Optional: if using global mutate + setUser(undefined); + redirectToSSO(); + } + }, [userErrorResponse, setUser, userResponse]); + + useEffect(() => { + setIsLoadingUser(isLoadingUserResponse); + }, [isLoadingUserResponse]); + + if ( + (isLoadingUserResponse && !userResponse && !userErrorResponse) || + (!userResponse && !userErrorResponse) + ) { return (
@@ -58,7 +63,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => { ); } - return <>{isResponseSuccess(userResponse) && children}; + if (userErrorResponse) { + return ( +
+

Authentication Failed

+

+ Please try refreshing the page or contact support if the problem + persists. +

+ +
+ ); + } + + return <>{isResponseSuccess(userResponse) && user && children}; }; export default RequireAuth; 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/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/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx index d5dcabc0..6ae7c13a 100644 --- a/src/components/pages/ApprovalSteps.tsx +++ b/src/components/pages/ApprovalSteps.tsx @@ -144,33 +144,45 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => { export const formatGroupedApprovalsToApprovalSteps = ( approvalLine: ApprovalLine, - groupedApprovals: BaseGroupedApproval[], - latestApproval: BaseApproval + groupedApprovals: BaseGroupedApproval[] | undefined, + latestApproval: BaseApproval | undefined ): ApprovalStepsProps['approvals'] => { const formattedApprovalSteps: ApprovalStepsProps['approvals'] = approvalLine.map((approvalLineItem) => { - const approvalGroup = groupedApprovals.find( + const approvalGroup = groupedApprovals?.find( (approvalGroupItem) => approvalGroupItem.step_number === approvalLineItem.step_number ); const currentStepNumber = approvalLineItem.step_number; const lastStepNumber = - groupedApprovals[groupedApprovals.length - 1]?.step_number; + groupedApprovals?.[groupedApprovals.length - 1]?.step_number; - const isLatestApprovalRejected = latestApproval.action === 'REJECTED'; + const isLatestApprovalRejected = latestApproval?.action === 'REJECTED'; - if (!approvalGroup && currentStepNumber <= lastStepNumber) { - throw new Error( - `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!` - ); + // Only throw error if we have a valid lastStepNumber to compare against + if ( + !approvalGroup && + lastStepNumber !== undefined && + currentStepNumber <= lastStepNumber + ) { + // throw new Error( + // `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!` + // ); } if (!approvalGroup) { - const isWaiting = currentStepNumber === latestApproval.step_number + 1; + // Check if this step is waiting (only if we have latestApproval) + const isWaiting = + latestApproval?.step_number !== undefined && + currentStepNumber === latestApproval.step_number + 1; + + // Check if previous approval was rejected const isPreviousApprovalRejected = - groupedApprovals[groupedApprovals.length - 1].approvals[0].action === - 'REJECTED'; + groupedApprovals && + groupedApprovals.length > 0 && + groupedApprovals[groupedApprovals.length - 1]?.approvals?.[0] + ?.action === 'REJECTED'; return { name: approvalLineItem.step_name, @@ -184,7 +196,11 @@ export const formatGroupedApprovalsToApprovalSteps = ( let approvalStatus: ApprovalStepStatus = 'IDLE'; - if (approvalGroup.step_number <= latestApproval.step_number) { + // Only compare if latestApproval and its step_number exist + if ( + latestApproval?.step_number !== undefined && + approvalGroup.step_number <= latestApproval.step_number + ) { if (approvalGroup.approvals) { switch (approvalGroup?.approvals[0]?.action) { case 'CREATED': @@ -203,6 +219,7 @@ export const formatGroupedApprovalsToApprovalSteps = ( } } } else if ( + latestApproval?.step_number !== undefined && approvalGroup.step_number === latestApproval.step_number + 1 && !isLatestApprovalRejected ) { @@ -353,14 +370,33 @@ const useApprovalSteps = ({ // Formatting Akhir const approvals = useMemo(() => { - if (isLoading || !approvalLines.length || !latestApproval) { + if (isLoading || !approvalLines.length) { return []; } + + // Try to derive latestApproval from groupedApprovals if not provided + let effectiveLatestApproval = latestApproval; + + if (!effectiveLatestApproval && groupedApprovals.length > 0) { + // Get all approvals from grouped data + const allApprovals = groupedApprovals.flatMap((group) => group.approvals); + + if (allApprovals.length > 0) { + // Use the most recent approval (last in array) + effectiveLatestApproval = allApprovals[allApprovals.length - 1]; + } + } + + // If still no latestApproval, return empty + if (!effectiveLatestApproval) { + return []; + } + try { return formatGroupedApprovalsToApprovalSteps( approvalLines, groupedApprovals, - latestApproval + effectiveLatestApproval ); } catch (error) { console.warn('Gagal memformat approval steps:', error); diff --git a/src/components/pages/closing/ClosingDetail.tsx b/src/components/pages/closing/ClosingDetail.tsx index f917cfd8..b814986b 100644 --- a/src/components/pages/closing/ClosingDetail.tsx +++ b/src/components/pages/closing/ClosingDetail.tsx @@ -9,14 +9,25 @@ import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGe import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent'; import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent'; -import { ClosingGeneralInformation } from '@/types/api/closing'; +import { + ClosingGeneralInformation, + BaseClosingSales, +} from '@/types/api/closing'; +import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent'; +import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent'; +import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; interface ClosingDetailProps { id: number; initialValue?: ClosingGeneralInformation; + salesData?: BaseClosingSales; } -const ClosingDetail: React.FC = ({ id, initialValue }) => { +const ClosingDetail: React.FC = ({ + id, + initialValue, + salesData, +}) => { const [activeTab, setActiveTab] = useState('sapronak'); const closingDetailTabs = useMemo(() => { @@ -29,17 +40,17 @@ const ClosingDetail: React.FC = ({ id, initialValue }) => { { id: 'perhitunganSapronak', label: 'Perhitungan Sapronak', - content: 'Perhitungan Sapronak', + content: , }, { id: 'penjualan', label: 'Penjualan', - content: 'Penjualan', + content: , }, { id: 'overhead', label: 'Overhead', - content: 'Overhead', + content: , }, { id: 'hppEkspedisi', diff --git a/src/components/pages/closing/ClosingOverheadTabContent.tsx b/src/components/pages/closing/ClosingOverheadTabContent.tsx new file mode 100644 index 00000000..458cff0f --- /dev/null +++ b/src/components/pages/closing/ClosingOverheadTabContent.tsx @@ -0,0 +1,19 @@ +import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable'; + +interface ClosingOverheadTabContentProps { + projectFlockId: number; +} + +const ClosingOverheadTabContent = ({ + projectFlockId, +}: ClosingOverheadTabContentProps) => { + return ( +
+ {projectFlockId && ( + + )} +
+ ); +}; + +export default ClosingOverheadTabContent; diff --git a/src/components/pages/closing/ClosingOverheadTable.tsx b/src/components/pages/closing/ClosingOverheadTable.tsx new file mode 100644 index 00000000..3df0844d --- /dev/null +++ b/src/components/pages/closing/ClosingOverheadTable.tsx @@ -0,0 +1,162 @@ +import Card from '@/components/Card'; +import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { ClosingApi } from '@/services/api/closing'; +import { Overhead, OverheadTotal } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import useSWR from 'swr'; + +interface ClosingOverheadTableProps { + type?: 'detail'; + projectFlockId: number; +} + +const ClosingOverheadTable = ({ + type, + projectFlockId, +}: ClosingOverheadTableProps) => { + const { data: overhead, isLoading: isLoadingOverhead } = useSWR( + `${ClosingApi.basePath}/${projectFlockId}/overhead`, + () => ClosingApi.getOverhead(projectFlockId), + { + keepPreviousData: true, + } + ); + + // Helper function to create columns with footer support + const createColumns = (total?: OverheadTotal): ColumnDef[] => [ + // Group untuk kolom tanpa footer + { + header: 'Nama Item', + accessorFn: (props) => props.item_name, + footer: 'Total Pengeluaran Overhead', + }, + { + header: 'Satuan', + accessorFn: (props) => props.uom_name, + }, + { + header: 'Budget Pengajuan', + footer: '', + columns: [ + { + id: 'budget_quantity', + header: 'Jumlah', + accessorFn: (props) => + props.budget_quantity ? formatNumber(props.budget_quantity) : '-', + footer: total ? () => formatNumber(total.budget_quantity) : '', + }, + { + id: 'budget_unit_price', + header: 'Harga Satuan', + accessorFn: (props) => + props.budget_unit_price + ? formatCurrency(props.budget_unit_price) + : '-', + footer: '', + }, + { + id: 'budget_total_amount', + header: 'Total', + accessorFn: (props) => + props.budget_total_amount + ? formatCurrency(props.budget_total_amount) + : '-', + footer: total ? () => formatCurrency(total.budget_total_amount) : '', + }, + ], + }, + { + header: 'Realisasi', + footer: '', + columns: [ + { + id: 'actual_date', + header: 'Tanggal', + accessorFn: (props) => + props.actual_date + ? formatDate(props.actual_date, 'DD MMM, YYYY') + : '-', + footer: '', + }, + { + id: 'actual_quantity', + header: 'Jumlah', + accessorFn: (props) => + props.actual_quantity ? formatNumber(props.actual_quantity) : '-', + footer: total ? () => formatNumber(total.actual_quantity) : '', + }, + { + id: 'actual_unit_price', + header: 'Harga Satuan', + accessorFn: (props) => + props.actual_unit_price + ? formatCurrency(props.actual_unit_price) + : '-', + footer: '', + }, + { + id: 'actual_total_amount', + header: 'Total', + accessorFn: (props) => + props.actual_total_amount + ? formatCurrency(props.actual_total_amount) + : '-', + footer: total ? () => formatCurrency(total.actual_total_amount) : '', + }, + ], + }, + { + id: 'cost_per_bird', + header: 'Rp/Ekor', + accessorFn: (props) => + props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-', + footer: total ? () => formatCurrency(total.cost_per_bird) : '', + }, + ]; + + const columns = useMemo( + () => + isResponseSuccess(overhead) + ? createColumns(overhead.data?.total) + : createColumns(), + [overhead] + ); + + return ( + <> + + + data={ + isResponseSuccess(overhead) ? (overhead.data?.overheads ?? []) : [] + } + columns={columns} + className={{ + containerClassName: 'my-4', + headerColumnClassName: cn( + TABLE_DEFAULT_STYLING.headerColumnClassName, + 'whitespace-nowrap' + ), + }} + renderFooter={ + isResponseSuccess(overhead) + ? overhead.data?.overheads.length > 0 + : false + } + /> + + + ); +}; + +export default ClosingOverheadTable; diff --git a/src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx b/src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx new file mode 100644 index 00000000..15e43bbc --- /dev/null +++ b/src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx @@ -0,0 +1,25 @@ +'use client'; + +import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; +import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; +import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable'; + +interface ClosingSapronakCalculationTabContentProps { + projectFlockId?: number; +} + +const ClosingSapronakCalculationTabContent = ({ + projectFlockId, +}: ClosingSapronakCalculationTabContentProps) => { + return ( +
+ {projectFlockId && ( + <> + + + )} +
+ ); +}; + +export default ClosingSapronakCalculationTabContent; diff --git a/src/components/pages/closing/ClosingSapronakCalculationTable.tsx b/src/components/pages/closing/ClosingSapronakCalculationTable.tsx new file mode 100644 index 00000000..445b7d8c --- /dev/null +++ b/src/components/pages/closing/ClosingSapronakCalculationTable.tsx @@ -0,0 +1,221 @@ +'use client'; + +import Card from '@/components/Card'; + +import Table from '@/components/Table'; +import { cn, formatCurrency, formatNumber } from '@/lib/helper'; +import { + RowSapronakCalculation, + TotalSapronakCalculation, +} from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { ClosingApi } from '@/services/api/closing'; +import { isResponseSuccess } from '@/lib/api-helper'; + +interface ClosingSapronakCalculationTableProps { + type?: 'detail'; + projectFlockId: number; +} + +const ClosingSapronakCalculationTable = ({ + type, + projectFlockId, +}: ClosingSapronakCalculationTableProps) => { + const { data: sapronakCalculation, isLoading } = useSWR( + `/closing/sapronak-calculation/${projectFlockId}`, + () => ClosingApi.getPerhitunganSapronak(projectFlockId), + { + keepPreviousData: true, + } + ); + + // Helper function to create columns with footer support + const createColumns = ( + total?: TotalSapronakCalculation + ): ColumnDef[] => [ + { + header: 'Tanggal', + accessorKey: 'tanggal', + cell: (props) => (props.getValue() as string) || '-', + footer: 'Total', + }, + { + header: 'No. Referensi', + accessorKey: 'no_referensi', + cell: (props) => (props.getValue() as string) || '-', + footer: '', + }, + { + header: 'QTY Masuk', + accessorKey: 'qty_masuk', + cell: (props) => formatNumber(props.getValue() as number), + footer: total + ? () => ( +
+ {formatNumber(total.qty_masuk)} +
+ ) + : '', + }, + { + header: 'QTY Keluar', + accessorKey: 'qty_keluar', + cell: (props) => formatNumber(props.getValue() as number), + footer: total + ? () => ( +
+ {formatNumber(total.qty_keluar)} +
+ ) + : '', + }, + { + header: 'QTY Pakai', + accessorKey: 'qty_pakai', + cell: (props) => formatNumber(props.getValue() as number), + footer: total + ? () => ( +
+ {formatNumber(total.qty_pakai)} +
+ ) + : '', + }, + { + header: 'Uraian', + accessorKey: 'uraian', + cell: (props) => (props.getValue() as string) || '-', + footer: '', + }, + { + header: 'Kategori Produk', + accessorKey: 'kategori_produk', + cell: (props) => (props.getValue() as string) || '-', + footer: '', + }, + { + header: 'Harga Beli/Qty (Rp)', + accessorKey: 'harga_beli_per_qty', + cell: (props) => formatCurrency(props.getValue() as number), + footer: total + ? () => ( +
+ {formatCurrency(total.harga_beli_per_qty)} +
+ ) + : '', + }, + { + header: 'Total Harga (Rp)', + accessorKey: 'total_harga', + cell: (props) => formatCurrency(props.getValue() as number), + footer: total + ? () => ( +
+ {formatCurrency(total.total_harga)} +
+ ) + : '', + }, + { + header: 'Keterangan', + accessorKey: 'keterangan', + cell: (props) => (props.getValue() as string) || '-', + footer: '', + }, + ]; + + // Memoize columns untuk setiap kategori + const docBroilerColumns = useMemo( + () => + isResponseSuccess(sapronakCalculation) + ? createColumns(sapronakCalculation.data?.doc_broiler.total) + : createColumns(), + [sapronakCalculation] + ); + + const ovkColumns = useMemo( + () => + isResponseSuccess(sapronakCalculation) + ? createColumns(sapronakCalculation.data?.ovk.total) + : createColumns(), + [sapronakCalculation] + ); + + const pakanColumns = useMemo( + () => + isResponseSuccess(sapronakCalculation) + ? createColumns(sapronakCalculation.data?.pakan.total) + : createColumns(), + [sapronakCalculation] + ); + + return ( +
+ {isResponseSuccess(sapronakCalculation) && ( + <> + + + data={sapronakCalculation.data?.doc_broiler.rows ?? []} + columns={docBroilerColumns} + className={{ + containerClassName: 'my-4', + }} + renderFooter + /> + + + + + data={sapronakCalculation.data?.ovk.rows ?? []} + columns={ovkColumns} + className={{ + containerClassName: 'my-4', + }} + renderFooter + /> + + + + + data={sapronakCalculation.data?.pakan.rows ?? []} + columns={pakanColumns} + className={{ + containerClassName: 'my-4', + }} + renderFooter + /> + + + )} +
+ ); +}; + +export default ClosingSapronakCalculationTable; diff --git a/src/components/pages/closing/sale/SalesReportTable.tsx b/src/components/pages/closing/sale/SalesReportTable.tsx new file mode 100644 index 00000000..89cb6615 --- /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 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< {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( )} @@ -2597,7 +2525,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { (egg, idx) => ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( - + {(type as 'add' | 'edit' | 'detail') !== 'detail' && ( @@ -657,37 +630,6 @@ const PurchaseOrderAcceptApprovalForm = ({ }} /> - ); })} @@ -732,7 +674,8 @@ const PurchaseOrderAcceptApprovalForm = ({ disabled={ !formik.isValid || formik.isSubmitting || - hasQuantityExceededErrors + hasQuantityExceededErrors || + isRejected } > Submit diff --git a/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts index 96836bc6..c7da956d 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts +++ b/src/components/pages/purchase/form/order/PurchaseOrderForm.schema.ts @@ -23,10 +23,12 @@ type PurchaseRequestStaffApprovalFormSchemaType = { }; type PurchaseRequestManagerApprovalFormSchemaType = { + action: 'APPROVED' | 'REJECTED'; notes: string | null; }; type PurchaseRequestAcceptApprovalFormSchemaType = { + action: 'APPROVED' | 'REJECTED'; notes: string | null; items: { purchase_item?: { @@ -45,7 +47,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = { expedition_vendor_id: number; received_qty: number | string; transport_per_item: number | string; - transport_total: number | string; }[]; }; @@ -83,7 +84,6 @@ export type PurchaseAcceptApprovalItemSchema = { expedition_vendor_id: number; received_qty: number | string; transport_per_item: number | string; - transport_total: number | string; }; export type PurchaseDeleteItemsSchema = { @@ -152,6 +152,10 @@ const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema = Yup.object({ + action: Yup.mixed<'APPROVED' | 'REJECTED'>() + .oneOf(['APPROVED', 'REJECTED'], 'Action harus APPROVED atau REJECTED') + .required('Action wajib diisi!') + .default('APPROVED'), notes: Yup.string().nullable().default(null), }); @@ -230,20 +234,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema() - .required('Total biaya transport wajib diisi!') - .test( - 'is-valid-transport-total', - 'Total biaya transport harus berupa angka lebih dari atau sama dengan 0!', - function (value) { - if (value === '' || value === null || value === undefined) - return false; - const numValue = - typeof value === 'string' ? parseFloat(value) : value; - return !isNaN(numValue) && numValue >= 0; - } - ) - .typeError('Total biaya transport harus berupa angka!'), }); export const PurchaseRequestStaffApprovalFormSchema: Yup.ObjectSchema = @@ -368,6 +358,7 @@ export const PurchaseRequestManagerApprovalFormDefaultValues = ( purchase?: Purchase ): PurchaseRequestManagerApprovalFormSchemaType => { return { + action: 'APPROVED', notes: purchase?.notes ?? null, }; }; @@ -378,6 +369,10 @@ export type PurchaseRequestManagerApprovalFormValues = Yup.InferType< export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema = Yup.object({ + action: Yup.mixed<'APPROVED' | 'REJECTED'>() + .oneOf(['APPROVED', 'REJECTED'], 'Action harus APPROVED atau REJECTED') + .required('Action wajib diisi!') + .default('APPROVED'), notes: Yup.string().nullable().default(null), items: Yup.array() .of(PurchaseAcceptApprovalItemObjectSchema) @@ -388,6 +383,7 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema { return { + action: 'APPROVED', notes: purchase?.notes ?? null, items: purchase?.items ? purchase.items.map((item) => ({ @@ -419,7 +415,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = ( expedition_vendor_id: 0, received_qty: '', transport_per_item: '', - transport_total: '', })) : [ { @@ -431,7 +426,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = ( expedition_vendor_id: 0, received_qty: '', transport_per_item: '', - transport_total: '', }, ], }; diff --git a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx index 63756ad9..1fcd7a94 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx +++ b/src/components/pages/purchase/form/order/PurchaseOrderStaffApprovalForm.tsx @@ -21,7 +21,7 @@ import { PurchaseRequestStaffApprovalFormInitialValues, PurchaseRequestStaffApprovalFormSchema, PurchaseStaffApprovalItemSchema, -} from './PurchaseOrderForm.schema'; +} from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema'; import { isResponseError } from '@/lib/api-helper'; import { formatNumber } from '@/lib/helper'; import { PurchaseApi } from '@/services/api/purchase'; @@ -61,7 +61,7 @@ const PurchaseOrderStaffApprovalForm = ({ return 'add'; } - const currentStep = initialValues?.approval?.step_number || 1; + const currentStep = initialValues?.latest_approval?.step_number || 1; switch (currentStep) { case 1: @@ -77,7 +77,9 @@ const PurchaseOrderStaffApprovalForm = ({ // Step 4+ (Penerimaan Barang dan selesai), tidak boleh edit kalau sudah disetujui return 'edit'; } - }, [rawDataApprovals, propType, initialValues?.approval?.step_number]); + }, [rawDataApprovals, propType, initialValues?.latest_approval?.step_number]); + + const isRejected = initialValues?.latest_approval?.action === 'REJECTED'; const router = useRouter(); const searchParams = useSearchParams(); @@ -93,16 +95,16 @@ const PurchaseOrderStaffApprovalForm = ({ // ===== UTILITY FUNCTIONS ===== const canUpdatePurchaseItems = useMemo(() => { - if (!initialValues?.approval) return false; + if (!initialValues?.latest_approval) return false; - const currentStep = initialValues.approval.step_number; + const currentStep = initialValues.latest_approval.step_number; return currentStep >= 3; - }, [initialValues?.approval]); + }, [initialValues?.latest_approval]); const canShowDeleteAddButtons = useMemo(() => { - if (!initialValues?.approval) return false; + if (!initialValues?.latest_approval) return false; - const currentStep = initialValues.approval.step_number; + const currentStep = initialValues.latest_approval.step_number; // Step 2 (Staff Purchase) dengan mode 'add' tidak boleh add/delete items // User hanya boleh input harga dan total harga untuk items yang sudah ada @@ -112,7 +114,7 @@ const PurchaseOrderStaffApprovalForm = ({ // Step 3 (Manager Purchase) boleh add/delete items return currentStep === 3; - }, [initialValues?.approval, type]); + }, [initialValues?.latest_approval, type]); const isRepeaterInputError = ( idx: number, @@ -241,9 +243,8 @@ const PurchaseOrderStaffApprovalForm = ({ ); formik.setFieldValue('items', updatedPurchaseItems); } - } catch (error) { + } catch { toast.error('Terjadi kesalahan saat menghapus item pembelian'); - console.error('Delete item error:', error); } }, [ initialValues?.id, @@ -313,7 +314,9 @@ const PurchaseOrderStaffApprovalForm = ({ const isNewItemForm = !formItem.purchase_item_id || formItem.purchase_item_id === 0; - let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; + let cleanPayload: NonNullable< + UpdateStaffApprovalRequestPayload['items'] + >[0]; if (isNewItemForm) { cleanPayload = { @@ -361,7 +364,9 @@ const PurchaseOrderStaffApprovalForm = ({ const isNewItemForm = !formItem.purchase_item_id || formItem.purchase_item_id === 0; - let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; + let cleanPayload: NonNullable< + UpdateStaffApprovalRequestPayload['items'] + >[0]; if (isNewItemForm) { cleanPayload = { @@ -720,7 +725,10 @@ const PurchaseOrderStaffApprovalForm = ({ 'min-w-52 md:min-w-72 lg:min-w-80', }} bottomLabel={ - 'Previous: ' + purchaseItem.product.name + type === 'edit' + ? 'Previous: ' + + purchaseItem.product.name + : undefined } /> @@ -820,7 +828,11 @@ const PurchaseOrderStaffApprovalForm = ({ thousandSeparator=',' decimalSeparator='.' inputPrefix={'Rp'} - bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}`} + bottomLabel={ + type === 'edit' + ? `Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}` + : undefined + } isError={ isRepeaterInputError( formItemIndex, @@ -858,7 +870,11 @@ const PurchaseOrderStaffApprovalForm = ({ thousandSeparator=',' decimalSeparator='.' inputPrefix={'Rp'} - bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}`} + bottomLabel={ + type === 'edit' + ? `Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}` + : undefined + } isError={ isRepeaterInputError( formItemIndex, @@ -1132,7 +1148,7 @@ const PurchaseOrderStaffApprovalForm = ({ color='primary' className='px-4' isLoading={formik.isSubmitting} - disabled={!formik.isValid || formik.isSubmitting} + disabled={!formik.isValid || formik.isSubmitting || isRejected} > Submit diff --git a/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts b/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts index 414371c3..67a694bc 100644 --- a/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts +++ b/src/components/pages/purchase/form/request/PurchaseRequestForm.schema.ts @@ -78,14 +78,14 @@ export const PurchaseRequestFormSchema: Yup.ObjectSchema { - if (!initialValues?.approval) return null; - return initialValues.approval.step_number; - }, [initialValues?.approval]); + if (!initialValues?.latest_approval) return null; + return initialValues.latest_approval.step_number; + }, [initialValues?.latest_approval]); const { approvals, @@ -166,7 +169,7 @@ const PurchaseOrderDetail = ({ rawDataApprovals, refresh: refreshApprovals, } = useApprovalSteps({ - latestApproval: initialValues?.approval, + latestApproval: initialValues?.latest_approval, approvalLines: PURCHASE_ORDER_APPROVAL_LINE, moduleName: 'PURCHASES', moduleId: initialValues?.id?.toString() ?? '', @@ -177,19 +180,22 @@ const PurchaseOrderDetail = ({ }); const showApprovalButton = - approvalStep !== null && approvalStep >= 1 && approvalStep <= 3; + approvalStep !== null && + approvalStep >= 1 && + approvalStep <= 3 && + initialValues?.latest_approval?.action !== 'REJECTED'; const canDeleteItems = useMemo(() => { - if (!initialValues?.approval) return false; + if (!initialValues?.latest_approval) return false; - const currentStep = initialValues.approval.step_number; + const currentStep = initialValues.latest_approval.step_number; const hasReachedStep5 = rawDataApprovals?.some( (approval) => approval.step_number === 5 ); return currentStep === 3 && !hasReachedStep5; - }, [initialValues?.approval, rawDataApprovals]); + }, [initialValues?.latest_approval, rawDataApprovals]); const handleApprovalClick = () => { if (!approvalStep) return; @@ -216,24 +222,30 @@ const PurchaseOrderDetail = ({ case 1: staffRejectionModal.openModal(); break; + case 2: + managerRejectionModal.openModal(); + break; + case 3: + acceptRejectionModal.openModal(); + break; default: break; } }; const canShowPurchaseOrderInvoice = useMemo(() => { - if (!initialValues?.approval) return false; + if (!initialValues?.latest_approval) return false; - const currentStep = initialValues.approval.step_number; + const currentStep = initialValues.latest_approval.step_number; return currentStep >= 3; - }, [initialValues?.approval]); + }, [initialValues?.latest_approval]); const canShowPenerimaanBarang = useMemo(() => { - if (!initialValues?.approval) return false; + if (!initialValues?.latest_approval) return false; - const currentStep = initialValues.approval.step_number; + const currentStep = initialValues.latest_approval.step_number; return currentStep === 5; - }, [initialValues?.approval]); + }, [initialValues?.latest_approval]); const totalBeforeTax = useMemo(() => { return purchaseOrderItems.reduce( @@ -296,6 +308,33 @@ const PurchaseOrderDetail = ({ [initialValues?.id, searchParams, refetchData] ); + const createAcceptApprovalHandler = useCallback( + async (payload: CreateAcceptApprovalRequestPayload) => { + const purchaseRequestId = searchParams.get('purchaseId') + ? parseInt(searchParams.get('purchaseId')!) + : initialValues?.id || 1; + + if (!purchaseRequestId) { + toast.error('Purchase Request ID is required'); + return; + } + + const res = await PurchaseApi.acceptApproval.create( + purchaseRequestId, + payload + ); + + if (isResponseError(res)) { + toast.error(res.message); + return; + } + toast.success(res?.message as string); + refreshApprovals(); + refetchData?.(); + }, + [initialValues?.id, searchParams, refreshApprovals, refetchData] + ); + // ===== MODAL HANDLERS ===== const handleStaffApprovalModalClose = useCallback(() => { refreshApprovals(); @@ -544,11 +583,6 @@ const PurchaseOrderDetail = ({ accessorKey: 'transport_per_item', cell: (props) => formatCurrency(props.getValue() as number), }, - { - header: 'Transport Total', - accessorKey: 'transport_total', - cell: (props) => formatCurrency(props.getValue() as number), - }, ]; const summaryData = [ @@ -647,7 +681,7 @@ const PurchaseOrderDetail = ({
- + Area @@ -657,7 +691,7 @@ const PurchaseOrderDetail = ({
- + Lokasi @@ -671,7 +705,7 @@ const PurchaseOrderDetail = ({
- + Gudang @@ -685,7 +719,7 @@ const PurchaseOrderDetail = ({
- + Nama Vendor @@ -696,7 +730,7 @@ const PurchaseOrderDetail = ({
- + Kategori Vendor @@ -706,18 +740,7 @@ const PurchaseOrderDetail = ({
- - Tgl. Jatuh Tempo - - - : {formatDate(purchaseData.due_date, 'D MMM YYYY')} ( - {purchaseData.credit_term} hari) - -
-
-
-
- + Nomor @@ -727,7 +750,7 @@ const PurchaseOrderDetail = ({
- + Nomor PO
@@ -925,6 +948,7 @@ const PurchaseOrderDetail = ({ color: 'success', onClick: async (notes) => { const payload: CreateManagerApprovalRequestPayload = { + action: 'APPROVED', notes: notes || null, }; @@ -1028,7 +1052,6 @@ const PurchaseOrderDetail = ({ const payload: CreateStaffApprovalRequestPayload = { action: 'REJECTED', notes: notes || null, - items: [], }; await createStaffApprovalHandler(payload); @@ -1041,6 +1064,60 @@ const PurchaseOrderDetail = ({ }} /> + {/* Accept Rejection Modal */} + { + const payload: CreateAcceptApprovalRequestPayload = { + action: 'REJECTED', + notes: notes || null, + }; + + await createAcceptApprovalHandler(payload); + await refetchData?.(); + acceptRejectionModal.closeModal(); + }, + }} + secondaryButton={{ + text: 'Batal', + }} + /> + + {/* Manager Rejection Modal */} + { + const payload: CreateManagerApprovalRequestPayload = { + action: 'REJECTED', + notes: notes || null, + }; + + await createManagerApprovalHandler(payload); + await refetchData?.(); + managerRejectionModal.closeModal(); + }, + }} + secondaryButton={{ + text: 'Batal', + }} + /> + {/* Delete Confirmation Modal */} { const handleDownloadPDF = async () => { if (!purchaseData) { - alert('No purchase order data available'); + toast.error('No purchase order data available'); return; } @@ -502,9 +503,8 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => { link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); - } catch (error) { - console.error('Error generating PDF:', error); - alert('Failed to generate PDF. Please try again.'); + } catch { + toast.error('Failed to generate PDF. Please try again.'); } finally { setIsGeneratingPDF(false); } diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 3af866c6..5333c016 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -9,9 +9,28 @@ export const PROJECT_FLOCK_APPROVAL_LINE: ApprovalLine = [ step_number: 2, step_name: 'Aktif', }, + { + step_number: 3, + step_name: 'Selesai', + }, ] as const; -export const PROJECT_FLOCK_KANDANG_APPROVAL_LINE: ApprovalLine = [ +export const PROJECT_FLOCK_KANDANGS_APPROVAL_LINE: ApprovalLine = [ + { + step_number: 1, + step_name: 'Pengajuan', + }, + { + step_number: 2, + step_name: 'Disetujui', + }, + { + step_number: 3, + step_name: 'Selesai', + }, +] as const; + +export const CHICKINS_APPROVAL_LINE: ApprovalLine = [ { step_number: 1, step_name: 'Pengajuan', @@ -51,14 +70,10 @@ export const MARKETING_APPROVAL_LINE: ApprovalLine = [ export const RECORDING_APPROVAL_LINE: ApprovalLine = [ { step_number: 1, - step_name: 'Grading-Telur', - }, - { - step_number: 2, step_name: 'Pengajuan', }, { - step_number: 3, + step_number: 2, step_name: 'Disetujui', }, ] as const; @@ -66,14 +81,10 @@ export const RECORDING_APPROVAL_LINE: ApprovalLine = [ export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [ { step_number: 1, - step_name: 'Grading-Telur', - }, - { - step_number: 2, step_name: 'Pengajuan', }, { - step_number: 3, + step_number: 2, step_name: 'Disetujui', }, ] as const; @@ -81,14 +92,10 @@ export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [ export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [ { step_number: 1, - step_name: 'Grading-Telur', - }, - { - step_number: 2, step_name: 'Pengajuan', }, { - step_number: 3, + step_number: 2, step_name: 'Disetujui', }, ] as const; diff --git a/src/config/constant.ts b/src/config/constant.ts index bad4a802..96fc8401 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -50,6 +50,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ link: '/inventory', icon: 'heroicons-outline:folder', submenu: [ + { + text: 'Produk', + link: '/inventory/product', + }, { text: 'Penyesuaian Stok', link: '/inventory/adjustment', @@ -229,14 +233,10 @@ export const APPROVAL_WORKFLOWS = [ steps: [ { step_number: 1, - step_name: 'Grading-Telur', - }, - { - step_number: 2, step_name: 'Pengajuan', }, { - step_number: 3, + step_number: 2, step_name: 'Disetujui', }, ], diff --git a/src/dummy/closing.dummy.ts b/src/dummy/closing.dummy.ts new file mode 100644 index 00000000..3a20cdaf --- /dev/null +++ b/src/dummy/closing.dummy.ts @@ -0,0 +1,1136 @@ +/** + * Dummy Data untuk Closing API + * + * File ini berisi dummy data untuk testing API Closing sebelum backend siap. + * + * Struktur data mengikuti tipe yang didefinisikan di @/types/api/closing.d.ts + * + * @example + * // 1. Menggunakan getAllFetcher dengan SWR: + * import useSWR from 'swr'; + * import { ClosingApi } from '@/services/api/closing'; + * + * const { data, error, isLoading } = useSWR( + * '/closings', + * ClosingApi.getAllFetcher.bind(ClosingApi) + * ); + * + * if (data?.status === 'success') { + * console.log(data.data); // Array of Closing objects + * } + * + * @example + * // 2. Menggunakan getSingle: + * import { ClosingApi } from '@/services/api/closing'; + * + * const response = await ClosingApi.getSingle(1); + * if (response?.status === 'success') { + * console.log(response.data); // Single Closing object + * } else if (response?.status === 'error') { + * console.error(response.message); // Error message + * } + * + * @example + * // 3. Menggunakan getGeneralInfo dengan SWR: + * import useSWR from 'swr'; + * import { ClosingApi } from '@/services/api/closing'; + * + * const closingId = 1; + * const { data, error, isLoading } = useSWR( + * closingId, + * (id: number) => ClosingApi.getGeneralInfo(id) + * ); + * + * if (data?.status === 'success') { + * console.log(data.data); // ClosingGeneralInformation object + * } + * + * @example + * // 4. Menggunakan getAllIncomingSapronakFetcher dengan SWR: + * import useSWR from 'swr'; + * import { ClosingApi } from '@/services/api/closing'; + * + * const { data, error, isLoading } = useSWR( + * `${ClosingApi.basePath}/1/sapronak/incoming`, + * ClosingApi.getAllIncomingSapronakFetcher.bind(ClosingApi) + * ); + * + * if (data?.status === 'success') { + * console.log(data.data); // Array of ClosingIncomingSapronak + * } + * + * @example + * // 5. Menggunakan getAllOutgoingSapronakFetcher dengan SWR: + * import useSWR from 'swr'; + * import { ClosingApi } from '@/services/api/closing'; + * + * const { data, error, isLoading } = useSWR( + * `${ClosingApi.basePath}/1/sapronak/outgoing`, + * ClosingApi.getAllOutgoingSapronakFetcher.bind(ClosingApi) + * ); + * + * if (data?.status === 'success') { + * console.log(data.data); // Array of ClosingOutgoingSapronak + * } + * + * @see {@link /home/sweetpotet/Documents/projects/lti-web-client/src/types/api/closing.d.ts} + */ + +import { format } from 'date-fns'; +import { + Closing, + ClosingGeneralInformation, + ClosingIncomingSapronak, + ClosingOutgoingSapronak, + ClosingOverhead, + ClosingSapronakCalculation, +} from '@/types/api/closing'; +import { CreatedUser, BaseApiResponse } from '@/types/api/api-general'; + +// Waktu saat ini untuk created_at/updated_at +const now = format(new Date(), 'yyyy-MM-dd HH:mm:ss'); +const today = format(new Date(), 'yyyy-MM-dd'); +const yesterday = format( + new Date().setDate(new Date().getDate() - 1), + 'yyyy-MM-dd' +); +const lastWeek = format( + new Date().setDate(new Date().getDate() - 7), + 'yyyy-MM-dd' +); +const lastMonth = format( + new Date().setMonth(new Date().getMonth() - 1), + 'yyyy-MM-dd' +); + +// ====================== +// 👤 Created User +// ====================== +export const createdUser: CreatedUser = { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin Utama', +}; + +// ====================== +// 📊 Closing Dummy Data +// ====================== +export const dummyClosings: Closing[] = [ + // 1. Closing dengan status Pengajuan - GROWING + { + id: 1, + location_id: 1, + location_name: 'Farm Sukajadi', + project_category: 'GROWING', + period: 1, + closing_date: today, + shed_label: 'Kandang A1, A2, A3', + shed_count: 3, + sales_paid_amount: 150000000, + sales_remaining_amount: 50000000, + sales_payment_status: 'Sebagian Lunas', + project_status: 'Pengajuan', + created_user: createdUser, + created_at: now, + updated_at: now, + }, + + // 2. Closing dengan status Aktif - LAYING + { + id: 2, + location_id: 2, + location_name: 'Farm Cihampelas', + project_category: 'LAYING', + period: 2, + closing_date: yesterday, + shed_label: 'Kandang B1, B2', + shed_count: 2, + sales_paid_amount: 200000000, + sales_remaining_amount: 0, + sales_payment_status: 'Lunas', + project_status: 'Aktif', + created_user: createdUser, + created_at: lastWeek, + updated_at: yesterday, + }, + + // 3. Closing dengan status Selesai - GROWING + { + id: 3, + location_id: 3, + location_name: 'Farm Pasteur', + project_category: 'GROWING', + period: 3, + closing_date: lastWeek, + shed_label: 'Kandang C1, C2, C3, C4', + shed_count: 4, + sales_paid_amount: 300000000, + sales_remaining_amount: 25000000, + sales_payment_status: 'Sebagian Lunas', + project_status: 'Selesai', + created_user: createdUser, + created_at: lastMonth, + updated_at: lastWeek, + }, + + // 4. Closing dengan status Aktif - LAYING + { + id: 4, + location_id: 4, + location_name: 'Farm Setiabudi', + project_category: 'LAYING', + period: 1, + closing_date: today, + shed_label: 'Kandang D1', + shed_count: 1, + sales_paid_amount: 75000000, + sales_remaining_amount: 75000000, + sales_payment_status: 'Belum Lunas', + project_status: 'Aktif', + created_user: createdUser, + created_at: yesterday, + updated_at: now, + }, + + // 5. Closing dengan status Selesai - GROWING + { + id: 5, + location_id: 5, + location_name: 'Farm Dago', + project_category: 'GROWING', + period: 4, + closing_date: lastMonth, + shed_label: 'Kandang E1, E2, E3, E4, E5', + shed_count: 5, + sales_paid_amount: 500000000, + sales_remaining_amount: 0, + sales_payment_status: 'Lunas', + project_status: 'Selesai', + created_user: createdUser, + created_at: lastMonth, + updated_at: lastMonth, + }, + + // 6. Closing dengan status Pengajuan - LAYING + { + id: 6, + location_id: 6, + location_name: 'Farm Lembang', + project_category: 'LAYING', + period: 2, + closing_date: undefined, // Belum ada tanggal closing + shed_label: 'Kandang F1, F2', + shed_count: 2, + sales_paid_amount: 0, + sales_remaining_amount: 180000000, + sales_payment_status: 'Belum Lunas', + project_status: 'Pengajuan', + created_user: createdUser, + created_at: now, + updated_at: now, + }, + + // 7. Closing dengan status Aktif - GROWING + { + id: 7, + location_id: 7, + location_name: 'Farm Ciwidey', + project_category: 'GROWING', + period: 1, + closing_date: yesterday, + shed_label: 'Kandang G1, G2, G3', + shed_count: 3, + sales_paid_amount: 120000000, + sales_remaining_amount: 30000000, + sales_payment_status: 'Sebagian Lunas', + project_status: 'Aktif', + created_user: createdUser, + created_at: lastWeek, + updated_at: yesterday, + }, + + // 8. Closing dengan status Selesai - LAYING + { + id: 8, + location_id: 8, + location_name: 'Farm Bandung Timur', + project_category: 'LAYING', + period: 3, + closing_date: lastMonth, + shed_label: 'Kandang H1, H2, H3, H4, H5, H6', + shed_count: 6, + sales_paid_amount: 600000000, + sales_remaining_amount: 0, + sales_payment_status: 'Lunas', + project_status: 'Selesai', + created_user: createdUser, + created_at: lastMonth, + updated_at: lastMonth, + }, +]; + +// ====================== +// 📊 Closing General Information Dummy Data +// ====================== +export const dummyClosingGeneralInformations: ClosingGeneralInformation[] = [ + // 1. General Info - GROWING - Pengajuan + { + id: 1, + location_id: 1, + location_name: 'Farm Sukajadi', + project_category: 'GROWING', + period: 1, + closing_date: today, + shed_label: 'Kandang A1, A2, A3', + shed_count: 3, + sales_paid_amount: 150000000, + sales_remaining_amount: 50000000, + sales_payment_status: 'Sebagian Lunas', + project_status: 'Pengajuan', + flock_id: 101, + project_type: 'GROWING', + population: 15000, + active_house_count: 3, + closing_status: 'Draft', + created_user: createdUser, + created_at: now, + updated_at: now, + }, + + // 2. General Info - LAYING - Aktif + { + id: 2, + location_id: 2, + location_name: 'Farm Cihampelas', + project_category: 'LAYING', + period: 2, + closing_date: yesterday, + shed_label: 'Kandang B1, B2', + shed_count: 2, + sales_paid_amount: 200000000, + sales_remaining_amount: 0, + sales_payment_status: 'Lunas', + project_status: 'Aktif', + flock_id: 102, + project_type: 'LAYING', + population: 10000, + active_house_count: 2, + closing_status: 'In Progress', + created_user: createdUser, + created_at: lastWeek, + updated_at: yesterday, + }, + + // 3. General Info - GROWING - Selesai + { + id: 3, + location_id: 3, + location_name: 'Farm Pasteur', + project_category: 'GROWING', + period: 3, + closing_date: lastWeek, + shed_label: 'Kandang C1, C2, C3, C4', + shed_count: 4, + sales_paid_amount: 300000000, + sales_remaining_amount: 25000000, + sales_payment_status: 'Sebagian Lunas', + project_status: 'Selesai', + flock_id: 103, + project_type: 'GROWING', + population: 20000, + active_house_count: 4, + closing_status: 'Completed', + created_user: createdUser, + created_at: lastMonth, + updated_at: lastWeek, + }, + + // 4. General Info - LAYING - Aktif + { + id: 4, + location_id: 4, + location_name: 'Farm Setiabudi', + project_category: 'LAYING', + period: 1, + closing_date: today, + shed_label: 'Kandang D1', + shed_count: 1, + sales_paid_amount: 75000000, + sales_remaining_amount: 75000000, + sales_payment_status: 'Belum Lunas', + project_status: 'Aktif', + flock_id: 104, + project_type: 'LAYING', + population: 5000, + active_house_count: 1, + closing_status: 'In Progress', + created_user: createdUser, + created_at: yesterday, + updated_at: now, + }, + + // 5. General Info - GROWING - Selesai + { + id: 5, + location_id: 5, + location_name: 'Farm Dago', + project_category: 'GROWING', + period: 4, + closing_date: lastMonth, + shed_label: 'Kandang E1, E2, E3, E4, E5', + shed_count: 5, + sales_paid_amount: 500000000, + sales_remaining_amount: 0, + sales_payment_status: 'Lunas', + project_status: 'Selesai', + flock_id: 105, + project_type: 'GROWING', + population: 25000, + active_house_count: 5, + closing_status: 'Completed', + created_user: createdUser, + created_at: lastMonth, + updated_at: lastMonth, + }, + + // 6. General Info - LAYING - Pengajuan + { + id: 6, + location_id: 6, + location_name: 'Farm Lembang', + project_category: 'LAYING', + period: 2, + closing_date: undefined, + shed_label: 'Kandang F1, F2', + shed_count: 2, + sales_paid_amount: 0, + sales_remaining_amount: 180000000, + sales_payment_status: 'Belum Lunas', + project_status: 'Pengajuan', + flock_id: 106, + project_type: 'LAYING', + population: 12000, + active_house_count: 2, + closing_status: 'Draft', + created_user: createdUser, + created_at: now, + updated_at: now, + }, + + // 7. General Info - GROWING - Aktif + { + id: 7, + location_id: 7, + location_name: 'Farm Ciwidey', + project_category: 'GROWING', + period: 1, + closing_date: yesterday, + shed_label: 'Kandang G1, G2, G3', + shed_count: 3, + sales_paid_amount: 120000000, + sales_remaining_amount: 30000000, + sales_payment_status: 'Sebagian Lunas', + project_status: 'Aktif', + flock_id: 107, + project_type: 'GROWING', + population: 18000, + active_house_count: 3, + closing_status: 'In Progress', + created_user: createdUser, + created_at: lastWeek, + updated_at: yesterday, + }, + + // 8. General Info - LAYING - Selesai + { + id: 8, + location_id: 8, + location_name: 'Farm Bandung Timur', + project_category: 'LAYING', + period: 3, + closing_date: lastMonth, + shed_label: 'Kandang H1, H2, H3, H4, H5, H6', + shed_count: 6, + sales_paid_amount: 600000000, + sales_remaining_amount: 0, + sales_payment_status: 'Lunas', + project_status: 'Selesai', + flock_id: 108, + project_type: 'LAYING', + population: 30000, + active_house_count: 6, + closing_status: 'Completed', + created_user: createdUser, + created_at: lastMonth, + updated_at: lastMonth, + }, +]; + +// ====================== +// 📦 Incoming Sapronak Dummy Data +// ====================== +export const dummyIncomingSapronaks: ClosingIncomingSapronak[] = [ + { + id: 1, + date: today, + reference_number: 'IN-2025-001', + transaction_type: 'Pembelian', + product_name: 'DOC Broiler Cobb 500', + product_category: 'DOC', + product_sub_category: 'DOC Broiler', + source_warehouse: 'Gudang Pusat', + destination_warehouse: 'Kandang A1', + quantity: 5000, + unit: 'Ekor', + formatted_quantity: '5,000 Ekor', + notes: 'DOC berkualitas tinggi dari supplier terpercaya', + }, + { + id: 2, + date: yesterday, + reference_number: 'IN-2025-002', + transaction_type: 'Transfer Masuk', + product_name: 'Pakan Starter BR-1', + product_category: 'Pakan', + product_sub_category: 'Starter', + source_warehouse: 'Gudang Area Bandung', + destination_warehouse: 'Kandang B1', + quantity: 100, + unit: 'Sak', + formatted_quantity: '100 Sak (5,000 Kg)', + notes: 'Pakan starter untuk periode awal', + }, + { + id: 3, + date: lastWeek, + reference_number: 'IN-2025-003', + transaction_type: 'Pembelian', + product_name: 'Vitamin B Complex', + product_category: 'OVK', + product_sub_category: 'Vitamin', + source_warehouse: 'Supplier Medion', + destination_warehouse: 'Gudang Farmasi', + quantity: 50, + unit: 'Botol', + formatted_quantity: '50 Botol', + notes: 'Vitamin untuk meningkatkan daya tahan tubuh', + }, + { + id: 4, + date: today, + reference_number: 'IN-2025-004', + transaction_type: 'Pembelian', + product_name: 'Pakan Finisher BR-2', + product_category: 'Pakan', + product_sub_category: 'Finisher', + source_warehouse: 'Gudang Pusat', + destination_warehouse: 'Kandang C1', + quantity: 200, + unit: 'Sak', + formatted_quantity: '200 Sak (10,000 Kg)', + notes: 'Pakan finisher untuk periode akhir', + }, + { + id: 5, + date: yesterday, + reference_number: 'IN-2025-005', + transaction_type: 'Transfer Masuk', + product_name: 'Antibiotik Enrofloxacin', + product_category: 'OVK', + product_sub_category: 'Obat', + source_warehouse: 'Gudang Area Jakarta', + destination_warehouse: 'Gudang Farmasi', + quantity: 30, + unit: 'Box', + formatted_quantity: '30 Box', + notes: 'Antibiotik untuk pencegahan penyakit', + }, +]; + +// ====================== +// 📤 Outgoing Sapronak Dummy Data +// ====================== +export const dummyOutgoingSapronaks: ClosingOutgoingSapronak[] = [ + { + id: 1, + date: today, + reference_number: 'OUT-2025-001', + transaction_type: 'Pemakaian', + product_name: 'Pakan Starter BR-1', + product_category: 'Pakan', + product_sub_category: 'Starter', + source_warehouse: 'Kandang A1', + destination_warehouse: 'Konsumsi Kandang A1', + quantity: 50, + unit: 'Sak', + formatted_quantity: '50 Sak (2,500 Kg)', + notes: 'Pemakaian pakan harian periode starter', + }, + { + id: 2, + date: yesterday, + reference_number: 'OUT-2025-002', + transaction_type: 'Transfer Keluar', + product_name: 'DOC Broiler Cobb 500', + product_category: 'DOC', + product_sub_category: 'DOC Broiler', + source_warehouse: 'Kandang B1', + destination_warehouse: 'Kandang B2', + quantity: 1000, + unit: 'Ekor', + formatted_quantity: '1,000 Ekor', + notes: 'Transfer DOC ke kandang baru', + }, + { + id: 3, + date: lastWeek, + reference_number: 'OUT-2025-003', + transaction_type: 'Pemakaian', + product_name: 'Vitamin B Complex', + product_category: 'OVK', + product_sub_category: 'Vitamin', + source_warehouse: 'Gudang Farmasi', + destination_warehouse: 'Konsumsi Kandang C1', + quantity: 10, + unit: 'Botol', + formatted_quantity: '10 Botol', + notes: 'Pemberian vitamin untuk meningkatkan kesehatan', + }, + { + id: 4, + date: today, + reference_number: 'OUT-2025-004', + transaction_type: 'Pemakaian', + product_name: 'Pakan Finisher BR-2', + product_category: 'Pakan', + product_sub_category: 'Finisher', + source_warehouse: 'Kandang C1', + destination_warehouse: 'Konsumsi Kandang C1', + quantity: 80, + unit: 'Sak', + formatted_quantity: '80 Sak (4,000 Kg)', + notes: 'Pemakaian pakan harian periode finisher', + }, + { + id: 5, + date: yesterday, + reference_number: 'OUT-2025-005', + transaction_type: 'Pemakaian', + product_name: 'Antibiotik Enrofloxacin', + product_category: 'OVK', + product_sub_category: 'Obat', + source_warehouse: 'Gudang Farmasi', + destination_warehouse: 'Konsumsi Kandang D1', + quantity: 5, + unit: 'Box', + formatted_quantity: '5 Box', + notes: 'Pengobatan untuk ayam yang sakit', + }, + { + id: 6, + date: lastWeek, + reference_number: 'OUT-2025-006', + transaction_type: 'Transfer Keluar', + product_name: 'Pakan Starter BR-1', + product_category: 'Pakan', + product_sub_category: 'Starter', + source_warehouse: 'Kandang E1', + destination_warehouse: 'Kandang E2', + quantity: 30, + unit: 'Sak', + formatted_quantity: '30 Sak (1,500 Kg)', + notes: 'Transfer pakan antar kandang', + }, +]; + +// ====================== +// 📊 Perhitungan Sapronak Dummy Data +// ====================== +export const dummySapronakCalculation: ClosingSapronakCalculation = { + // DOC Broiler Calculation + doc_broiler: { + rows: [ + { + id: 1, + tanggal: today, + no_referensi: 'IN-2025-001', + qty_masuk: 5000, + qty_keluar: 0, + qty_pakai: 0, + uraian: 'DOC Broiler Cobb 500', + kategori_produk: 'DOC Broiler', + harga_beli_per_qty: 8000, + total_harga: 40000000, + keterangan: 'Pembelian DOC dari supplier', + }, + { + id: 2, + tanggal: yesterday, + no_referensi: 'OUT-2025-002', + qty_masuk: 0, + qty_keluar: 1000, + qty_pakai: 0, + uraian: 'DOC Broiler Cobb 500', + kategori_produk: 'DOC Broiler', + harga_beli_per_qty: 8000, + total_harga: 8000000, + keterangan: 'Transfer DOC ke kandang lain', + }, + { + id: 3, + tanggal: lastWeek, + no_referensi: 'USE-2025-001', + qty_masuk: 0, + qty_keluar: 0, + qty_pakai: 50, + uraian: 'DOC Broiler Cobb 500', + kategori_produk: 'DOC Broiler', + harga_beli_per_qty: 8000, + total_harga: 400000, + keterangan: 'Mortalitas DOC', + }, + ], + total: { + label: 'Total DOC Broiler', + qty_masuk: 5000, + qty_keluar: 1000, + qty_pakai: 50, + harga_beli_per_qty: 8000, + total_harga: 48400000, + }, + }, + + // OVK Calculation + ovk: { + rows: [ + { + id: 1, + tanggal: today, + no_referensi: 'IN-2025-003', + qty_masuk: 50, + qty_keluar: 0, + qty_pakai: 0, + uraian: 'Vitamin B Complex', + kategori_produk: 'Vitamin', + harga_beli_per_qty: 150000, + total_harga: 7500000, + keterangan: 'Pembelian vitamin', + }, + { + id: 2, + tanggal: yesterday, + no_referensi: 'IN-2025-005', + qty_masuk: 30, + qty_keluar: 0, + qty_pakai: 0, + uraian: 'Antibiotik Enrofloxacin', + kategori_produk: 'Obat', + harga_beli_per_qty: 250000, + total_harga: 7500000, + keterangan: 'Pembelian antibiotik', + }, + { + id: 3, + tanggal: lastWeek, + no_referensi: 'OUT-2025-003', + qty_masuk: 0, + qty_keluar: 0, + qty_pakai: 10, + uraian: 'Vitamin B Complex', + kategori_produk: 'Vitamin', + harga_beli_per_qty: 150000, + total_harga: 1500000, + keterangan: 'Pemakaian vitamin', + }, + { + id: 4, + tanggal: yesterday, + no_referensi: 'OUT-2025-005', + qty_masuk: 0, + qty_keluar: 0, + qty_pakai: 5, + uraian: 'Antibiotik Enrofloxacin', + kategori_produk: 'Obat', + harga_beli_per_qty: 250000, + total_harga: 1250000, + keterangan: 'Pemakaian antibiotik', + }, + ], + total: { + label: 'Total OVK', + qty_masuk: 80, + qty_keluar: 0, + qty_pakai: 15, + harga_beli_per_qty: 200000, + total_harga: 17750000, + }, + }, + + // Pakan Calculation + pakan: { + rows: [ + { + id: 1, + tanggal: yesterday, + no_referensi: 'IN-2025-002', + qty_masuk: 100, + qty_keluar: 0, + qty_pakai: 0, + uraian: 'Pakan Starter BR-1', + kategori_produk: 'Starter', + harga_beli_per_qty: 450000, + total_harga: 45000000, + keterangan: 'Pembelian pakan starter', + }, + { + id: 2, + tanggal: today, + no_referensi: 'IN-2025-004', + qty_masuk: 200, + qty_keluar: 0, + qty_pakai: 0, + uraian: 'Pakan Finisher BR-2', + kategori_produk: 'Finisher', + harga_beli_per_qty: 480000, + total_harga: 96000000, + keterangan: 'Pembelian pakan finisher', + }, + { + id: 3, + tanggal: today, + no_referensi: 'OUT-2025-001', + qty_masuk: 0, + qty_keluar: 0, + qty_pakai: 50, + uraian: 'Pakan Starter BR-1', + kategori_produk: 'Starter', + harga_beli_per_qty: 450000, + total_harga: 22500000, + keterangan: 'Pemakaian pakan starter', + }, + { + id: 4, + tanggal: today, + no_referensi: 'OUT-2025-004', + qty_masuk: 0, + qty_keluar: 0, + qty_pakai: 80, + uraian: 'Pakan Finisher BR-2', + kategori_produk: 'Finisher', + harga_beli_per_qty: 480000, + total_harga: 38400000, + keterangan: 'Pemakaian pakan finisher', + }, + { + id: 5, + tanggal: lastWeek, + no_referensi: 'OUT-2025-006', + qty_masuk: 0, + qty_keluar: 30, + qty_pakai: 0, + uraian: 'Pakan Starter BR-1', + kategori_produk: 'Starter', + harga_beli_per_qty: 450000, + total_harga: 13500000, + keterangan: 'Transfer pakan ke kandang lain', + }, + ], + total: { + label: 'Total Pakan', + qty_masuk: 300, + qty_keluar: 30, + qty_pakai: 130, + harga_beli_per_qty: 465000, + total_harga: 215400000, + }, + }, +}; + +// ====================== +// 💰 Overhead Dummy Data +// ====================== +export const dummyOverhead: ClosingOverhead = { + overheads: [ + { + item_name: 'Expedisi DOC', + uom_name: 'Ekor', + budget_quantity: 500, + budget_unit_price: 8000, + budget_total_amount: 4000000, + actual_date: '', + actual_quantity: 0, + actual_unit_price: 0, + actual_total_amount: 0, + cost_per_bird: 0, + }, + { + item_name: 'Solar', + uom_name: 'Liter', + budget_quantity: 0, + budget_unit_price: 0, + budget_total_amount: 0, + actual_date: today, + actual_quantity: 20, + actual_unit_price: 10000, + actual_total_amount: 200000, + cost_per_bird: 200, + }, + { + item_name: 'Gaji Karyawan Kandang', + uom_name: 'Orang', + budget_quantity: 3, + budget_unit_price: 3000000, + budget_total_amount: 9000000, + actual_date: today, + actual_quantity: 3, + actual_unit_price: 3200000, + actual_total_amount: 9600000, + cost_per_bird: 640, + }, + { + item_name: 'Listrik Kandang', + uom_name: 'Bulan', + budget_quantity: 1, + budget_unit_price: 2500000, + budget_total_amount: 2500000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 2800000, + actual_total_amount: 2800000, + cost_per_bird: 187, + }, + { + item_name: 'Air Bersih', + uom_name: 'Bulan', + budget_quantity: 1, + budget_unit_price: 500000, + budget_total_amount: 500000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 450000, + actual_total_amount: 450000, + cost_per_bird: 30, + }, + { + item_name: 'Perbaikan Kandang', + uom_name: 'Paket', + budget_quantity: 1, + budget_unit_price: 3000000, + budget_total_amount: 3000000, + actual_date: yesterday, + actual_quantity: 1, + actual_unit_price: 3500000, + actual_total_amount: 3500000, + cost_per_bird: 233, + }, + { + item_name: 'Service Peralatan', + uom_name: 'Kali', + budget_quantity: 2, + budget_unit_price: 500000, + budget_total_amount: 1000000, + actual_date: lastWeek, + actual_quantity: 2, + actual_unit_price: 550000, + actual_total_amount: 1100000, + cost_per_bird: 73, + }, + { + item_name: 'ATK & Supplies', + uom_name: 'Paket', + budget_quantity: 1, + budget_unit_price: 500000, + budget_total_amount: 500000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 450000, + actual_total_amount: 450000, + cost_per_bird: 30, + }, + { + item_name: 'Biaya Komunikasi', + uom_name: 'Bulan', + budget_quantity: 1, + budget_unit_price: 300000, + budget_total_amount: 300000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 320000, + actual_total_amount: 320000, + cost_per_bird: 21, + }, + { + item_name: 'BBM Kendaraan Operasional', + uom_name: 'Liter', + budget_quantity: 200, + budget_unit_price: 10000, + budget_total_amount: 2000000, + actual_date: today, + actual_quantity: 220, + actual_unit_price: 10500, + actual_total_amount: 2310000, + cost_per_bird: 154, + }, + ], + total: { + budget_quantity: 710, + budget_total_amount: 23300000, + actual_quantity: 250, + actual_total_amount: 24530000, + cost_per_bird: 1568, + }, +}; + +// ====================== +// 🔧 Dummy API Response Functions +// ====================== + +/** + * Dummy implementation for getAllFetcher + * Returns all closing records + */ +export const dummyGetAllFetcher = async (): Promise<{ + code: number; + status: 'success'; + message: string; + data: Closing[]; +}> => { + await new Promise((resolve) => setTimeout(resolve, 500)); + return { + code: 200, + status: 'success', + message: 'Data closing berhasil diambil', + data: dummyClosings, + }; +}; + +/** + * Dummy implementation for getSingle + * Returns a single closing by ID + */ +export const dummyGetSingle = async ( + id: number +): Promise | undefined> => { + await new Promise((resolve) => setTimeout(resolve, 300)); + const closing = dummyClosings.find((c) => c.id === id); + + if (!closing) { + return { + code: 404, + status: 'error', + message: `Closing dengan ID ${id} tidak ditemukan`, + }; + } + + return { + code: 200, + status: 'success', + message: 'Data closing berhasil diambil', + data: closing, + }; +}; + +/** + * Dummy implementation for getAllIncomingSapronakFetcher + * Returns all incoming sapronak records + */ +export const dummyGetAllIncomingSapronakFetcher = async (): Promise<{ + code: number; + status: 'success'; + message: string; + data: ClosingIncomingSapronak[]; +}> => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { + code: 200, + status: 'success', + message: 'Data sapronak masuk berhasil diambil', + data: dummyIncomingSapronaks, + }; +}; + +/** + * Dummy implementation for getAllOutgoingSapronakFetcher + * Returns all outgoing sapronak records + */ +export const dummyGetAllOutgoingSapronakFetcher = async (): Promise<{ + code: number; + status: 'success'; + message: string; + data: ClosingOutgoingSapronak[]; +}> => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { + code: 200, + status: 'success', + message: 'Data sapronak keluar berhasil diambil', + data: dummyOutgoingSapronaks, + }; +}; + +/** + * Dummy implementation for getGeneralInfo + * Returns closing general information by ID + */ +export const dummyGetGeneralInfo = async ( + id: number +): Promise | undefined> => { + await new Promise((resolve) => setTimeout(resolve, 300)); + const closingInfo = dummyClosingGeneralInformations.find((c) => c.id == id); + + if (!closingInfo) { + return { + code: 404, + status: 'error', + message: `Closing general information dengan ID ${id} tidak ditemukan`, + }; + } + + return { + code: 200, + status: 'success', + message: 'Data closing general information berhasil diambil', + data: closingInfo, + }; +}; + +/** + * Dummy implementation for getPerhitunganSapronak + * Returns sapronak calculation data + */ +export const dummyGetPerhitunganSapronak = async ( + id: number +): Promise< + | { + code: number; + status: 'success'; + message: string; + data: ClosingSapronakCalculation; + } + | undefined +> => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { + code: 200, + status: 'success', + message: 'Data perhitungan sapronak berhasil diambil', + data: dummySapronakCalculation, + }; +}; + +/** + * Dummy implementation for getOverhead + * Returns overhead data + */ +export const dummyGetOverhead = async ( + id: number +): Promise | undefined> => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { + code: 200, + status: 'success', + message: 'Data overhead berhasil diambil', + data: dummyOverhead, + }; +}; diff --git a/src/lib/auth-helper.ts b/src/lib/auth-helper.ts new file mode 100644 index 00000000..bf05b70e --- /dev/null +++ b/src/lib/auth-helper.ts @@ -0,0 +1,25 @@ +/** + * Redirects the user to the SSO login page with loop protection. + * + * This function checks a session storage timestamp to ensure that redirects + * do not happen too frequently (blocking infinite redirect loops). + */ +export const redirectToSSO = () => { + if (typeof window === 'undefined') return; + + const lastRedirect = sessionStorage.getItem('auth_redirect_timestamp'); + const now = Date.now(); + + // Loop protection: allow redirect only if last one was > 5 seconds ago + // or if no redirect has happened yet. + if (!lastRedirect || now - parseInt(lastRedirect, 10) > 5000) { + sessionStorage.setItem('auth_redirect_timestamp', now.toString()); + // const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`; + + const ltiSsoStart = `${process.env.NEXT_PUBLIC_API_BASE_URL as string}/sso/start?client_id=${process.env.NEXT_PUBLIC_CLIENT_ID as string}&redirect_url=${window.location.href}`; + const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${ltiSsoStart}`; + window.location.href = ssoLoginUrl; + } else { + console.error('Redirect loop detected. Aborting redirect.'); + } +}; diff --git a/src/lib/helper.ts b/src/lib/helper.ts index fe67afef..c69f610f 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -31,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++) { diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index e2c604cc..f85e5331 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -6,19 +6,85 @@ import { ClosingGeneralInformation, ClosingIncomingSapronak, ClosingOutgoingSapronak, + ClosingOverhead, + ClosingSapronakCalculation, ClosingProductionData, } from '@/types/api/closing'; -import { httpClient, httpClientFetcher } from '@/services/http/client'; import { BaseApiResponse } from '@/types/api/api-general'; +import { + dummyGetAllFetcher, + dummyGetSingle, + dummyGetAllIncomingSapronakFetcher, + dummyGetAllOutgoingSapronakFetcher, + dummyGetGeneralInfo, + dummyGetPerhitunganSapronak, + dummyGetOverhead, +} from '@/dummy/closing.dummy'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { ClosingSales } from '@/types/api/closing'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { super(basePath); } + async getAllFetcher(endpoint: string): Promise> { + // TODO: Remove this block when backend is ready + // return await dummyGetAllFetcher(); + + // Uncomment this when backend is ready + return await httpClientFetcher>(endpoint); + } + + async getSingle(id: number): Promise | undefined> { + // TODO: Remove this block when backend is ready + // try { + // return await dummyGetSingle(id); + // } catch (error) { + // if (axios.isAxiosError>(error)) { + // return error.response?.data; + // } + // return undefined; + // } + + // Uncomment this when backend is ready + try { + const getSinglePath = `${this.basePath}/${id}`; + const getSingleRes = + await httpClient>(getSinglePath); + return getSingleRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + + async getPenjualan( + id: number + ): Promise | undefined> { + try { + const getPenjualanPath = `${this.basePath}/${id}/penjualan`; + const getPenjualanRes = + await httpClient>(getPenjualanPath); + + return getPenjualanRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + async getAllIncomingSapronakFetcher( endpoint: string ): Promise> { + // TODO: Remove this block when backend is ready + // return await dummyGetAllIncomingSapronakFetcher(); + + // Uncomment this when backend is ready return await httpClientFetcher>( endpoint ); @@ -27,19 +93,37 @@ export class ClosingApiService extends BaseApiService { async getAllOutgoingSapronakFetcher( endpoint: string ): Promise> { - return await httpClientFetcher>( - endpoint - ); + // TODO: Remove this block when backend is ready + return await dummyGetAllOutgoingSapronakFetcher(); + + // Uncomment this when backend is ready + // return await httpClientFetcher>( + // endpoint + // ); } - async getGeneralInfo(id: number) { + async getGeneralInfo( + id: number + ): Promise | undefined> { + // TODO: Remove this block when backend is ready + // try { + // return await dummyGetGeneralInfo(id); + // } catch (error) { + // if ( + // axios.isAxiosError>(error) + // ) { + // return error.response?.data; + // } + // return undefined; + // } + + // Uncomment this when backend is ready try { const getGeneralInfoPath = `${this.basePath}/${id}`; const getGeneralInfoRes = await httpClient>( getGeneralInfoPath ); - return getGeneralInfoRes; } catch (error) { if ( @@ -66,6 +150,67 @@ export class ClosingApiService extends BaseApiService { return undefined; } } + + async getPerhitunganSapronak( + id: number + ): Promise | undefined> { + // TODO: Remove this block when backend is ready + // try { + // return await dummyGetPerhitunganSapronak(id); + // } catch (error) { + // if ( + // axios.isAxiosError>(error) + // ) { + // return error.response?.data; + // } + // return undefined; + // } + + // Uncomment this when backend is ready + try { + const path = `${this.basePath}/${id}/perhitungan_sapronak`; + return await httpClient>( + path, + { + method: 'GET', + } + ); + } catch (error) { + if ( + axios.isAxiosError>(error) + ) { + return error.response?.data; + } + return undefined; + } + } + + async getOverhead( + id: number + ): Promise | undefined> { + // TODO: Remove this block when backend is ready + // try { + // return await dummyGetOverhead(id); + // } catch (error) { + // if (axios.isAxiosError>(error)) { + // return error.response?.data; + // } + // return undefined; + // } + + // Uncomment this when backend is ready + try { + const path = `${this.basePath}/${id}/overhead`; + return await httpClient>(path, { + method: 'GET', + }); + } 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..8e66d57e 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, @@ -9,8 +9,6 @@ import { CreateRecordingPayload, Recording, UpdateRecordingPayload, - CreateGradingPayload, - UpdateGradingPayload, NextDayRecording, } from '@/types/api/production/recording'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; @@ -64,28 +62,6 @@ export class RecordingService extends BaseApiService< }); } - async createGrading( - payload: CreateGradingPayload - ): Promise | undefined> { - return await this.customRequest>('gradings', { - method: 'POST', - payload, - }); - } - - async updateGrading( - gradingId: number, - payload: UpdateGradingPayload - ): Promise | undefined> { - return await this.customRequest>( - `gradings/${gradingId}`, - { - method: 'PUT', - payload, - } - ); - } - async deleteGrading( gradingId: number ): Promise | undefined> { diff --git a/src/services/api/production/project-flock-kandang.ts b/src/services/api/production/project-flock-kandang.ts index b7729325..f4887e68 100644 --- a/src/services/api/production/project-flock-kandang.ts +++ b/src/services/api/production/project-flock-kandang.ts @@ -2,10 +2,187 @@ 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: id % 2 === 1 ? 2 : 0, + // stock_remaining: [ + // { + // id: 1, + // product_id: 1, + // warehouse_id: 1, + // quantity: id % 2 === 1 ? 100 : 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: id % 2 === 1 ? 'PENGAJUAN' : 'SELESAI', + // step_name: id % 2 === 1 ? 'Approval Finance' : 'Selesai', + // step: id % 2 === 1 ? 1 : 5, + // reference_number: 'BOP-LTI-00001', + // }, + // { + // id: 3, + // po_number: 'PO-BOP-LTI-00003', + // category: 'BOP', + // total: 110000, + // status: id % 2 === 1 ? 'PENGAJUAN' : 'SELESAI', + // step_name: id % 2 === 1 ? 'Approval Finance' : 'Selesai', + // step: id % 2 === 1 ? 1 : 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/services/http/client.ts b/src/services/http/client.ts index f9389a16..68b5282a 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -2,6 +2,8 @@ import axios from 'axios'; import type { AxiosError, AxiosRequestConfig } from 'axios'; import { RequestOptions } from '@/services/http/base'; +import { redirectToSSO } from '@/lib/auth-helper'; + const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 }); @@ -9,8 +11,7 @@ axiosClient.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response?.status === 401) { - const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`; - window.location.href = ssoLoginUrl; + redirectToSSO(); } return Promise.reject(error); 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/types/api/closing.d.ts b/src/types/api/closing.d.ts index f5ea7047..1389f786 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -1,9 +1,34 @@ import { Area } from '@/types/api/master-data/area'; import { Fcr } from '@/types/api/master-data/fcr'; import { Flock } from '@/types/api/master-data/flock'; -import { Kandang } from '@/types/api/master-data/kandang'; import { Location } from '@/types/api/master-data/location'; -import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; +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; @@ -88,3 +113,68 @@ export type ClosingProductionData = { variance_feed_kg: number; }; }; + +// ====== PERHITUNGAN SAPRONAK ====== + +export type RowSapronakCalculation = { + id: number; + tanggal: string; + no_referensi: string; + qty_masuk: number; + qty_keluar: number; + qty_pakai: number; + uraian: string; + kategori_produk: string; + harga_beli_per_qty: number; + total_harga: number; + keterangan: string; +}; + +export type TotalSapronakCalculation = { + label: string; + qty_masuk: number; + qty_keluar: number; + qty_pakai: number; + harga_beli_per_qty: number; + total_harga: number; +}; + +export type ClosingSapronakCalculationItem = { + rows: RowSapronakCalculation[]; + total: TotalSapronakCalculation; +}; + +export type ClosingSapronakCalculation = { + doc_broiler: ClosingSapronakCalculationItem; + ovk: ClosingSapronakCalculationItem; + pakan: ClosingSapronakCalculationItem; +}; + +// ====== OVERHEAD ====== +export type ClosingOverhead = { + overheads: Overhead[]; + total: OverheadTotal; +}; + +export type Overhead = { + item_name: string; + uom_name: string; + budget_quantity: number; + budget_unit_price: number; + budget_total_amount: number; + actual_date: string; + actual_quantity: number; + actual_unit_price: number; + actual_total_amount: number; + cost_per_bird: number; +}; + +export type OverheadTotal = { + budget_quantity: number; + budget_total_amount: number; + actual_quantity: number; + actual_total_amount: number; + cost_per_bird: number; +}; + +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..3a98a6e8 100644 --- a/src/types/api/production/project-flock-kandang.d.ts +++ b/src/types/api/production/project-flock-kandang.d.ts @@ -39,3 +39,43 @@ 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; +}; + +// "flag_name": "PAKAN", +// "product_warehouse_id": 14, +// "product_id": 8, +// "product_name": "281 SPECIAL STARTER", +// "product_category": "Bahan Baku", +// "uom": "Kilogram", +// "quantity": 1100 + +export type StockItem = { + flag_name: string; + product_warehouse_id: number; + product_id: number; + product_name: string; + product_category: string; + uom: string; + quantity: number; +}; + +export type CheckClosingResponse = { + unfinished_expenses: number; + stock_remaining: StockItem[]; + 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/api/production/recording.d.ts b/src/types/api/production/recording.d.ts index e7b28f47..9bed7685 100644 --- a/src/types/api/production/recording.d.ts +++ b/src/types/api/production/recording.d.ts @@ -9,8 +9,7 @@ export type ProductionMetrics = { cum_intake: number; fcr_value: number; total_chick_qty: number; - daily_depletion_rate?: number; - cum_depletion?: number; + cum_depletion: number; }; export type BaseRecording = { @@ -18,42 +17,33 @@ export type BaseRecording = { project_flock_kandang_id: number; record_datetime: string; day: number; - created_by: User; + project_flock_category?: 'GROWING' | 'LAYING'; } & ProductionMetrics; export type RecordingBW = { - id: number; - recording_id: number; avg_weight: number; qty: number; total_weight: number; }; export type RecordingDepletion = { - id: number; - recording_id: number; product_warehouse_id: number; qty: number; product_warehouse: ProductWarehouse; }; export type RecordingStock = { - id: number; - recording_id: number; product_warehouse_id: number; usage_amount?: number; - usage_qty: number; - qty: number; pending_qty: number; product_warehouse: ProductWarehouse; }; export type RecordingEgg = { id: number; - recording_id: number; product_warehouse_id: number; qty: number; - created_by: User; + weight: number; product_warehouse: ProductWarehouse; gradings?: { grade: string; @@ -71,19 +61,12 @@ export type GradingEgg = { export type Recording = BaseMetadata & BaseRecording & { - project_flock_category?: 'GROWING' | 'LAYING'; approval?: BaseApproval; - egg_grading_status?: string | null; - egg_grading_pending_qty?: number | null; - egg_grading_completed_qty?: number | null; + created_user: User; body_weights?: RecordingBW[]; depletions?: RecordingDepletion[]; stocks?: RecordingStock[]; eggs?: RecordingEgg[]; - recording_bws?: RecordingBW[]; - recording_depletions?: RecordingDepletion[]; - recording_stocks?: RecordingStock[]; - recording_eggs?: RecordingEgg[]; grading_eggs?: GradingEgg[]; }; @@ -108,27 +91,10 @@ export type CreateGrowingRecordingPayload = { }[]; }; -export type CreateGradingPayload = { - eggs_grading: { - recording_egg_id: number; - grade: string; - qty: number; - }[]; -}; - -export type UpdateGradingPayload = CreateGradingPayload; - -export type CreateGradingRecordingPayload = { - eggs_grading: { - recording_egg_id: number; - grade: string; - qty: number; - }[]; -}; - export type CreateEggPayload = { product_warehouse_id: number; qty: number; + weight: number; }; export type CreateLayingRecordingPayload = CreateGrowingRecordingPayload & { @@ -137,11 +103,9 @@ export type CreateLayingRecordingPayload = CreateGrowingRecordingPayload & { export type CreateRecordingPayload = | CreateGrowingRecordingPayload - | CreateLayingRecordingPayload - | CreateGradingRecordingPayload; + | CreateLayingRecordingPayload; export type UpdateGrowingRecordingPayload = CreateGrowingRecordingPayload; export type UpdateLayingRecordingPayload = CreateLayingRecordingPayload; -export type UpdateGradingRecordingPayload = CreateGradingRecordingPayload; export type UpdateRecordingPayload = CreateRecordingPayload; diff --git a/src/types/api/purchase/purchase.d.ts b/src/types/api/purchase/purchase.d.ts index 56cbd810..e4de565b 100644 --- a/src/types/api/purchase/purchase.d.ts +++ b/src/types/api/purchase/purchase.d.ts @@ -42,7 +42,6 @@ export type PurchaseItem = { expedition_vendor_name?: string | null; received_qty?: number | null; transport_per_item?: number | null; - transport_total?: number | null; }; export type BasePurchase = { @@ -52,9 +51,8 @@ export type BasePurchase = { po_document_path?: string | null; po_date: string; supplier: Supplier; - credit_term: number; + credit_term?: number; due_date: string; - grand_total: number; notes?: string | null; deleted_at?: string | null; created_by: number; @@ -62,7 +60,7 @@ export type BasePurchase = { location?: Location; warehouse?: Warehouse; items?: PurchaseItem[]; - approval?: BaseApproval; + latest_approval?: BaseApproval; }; export type Purchase = BaseMetadata & BasePurchase; @@ -71,7 +69,7 @@ export type CreatePurchaseRequestPayload = { supplier_id: number; credit_term: number; notes?: string | null; - items: { + items?: { warehouse_id: number; product_id: number; qty: number; @@ -81,7 +79,7 @@ export type CreatePurchaseRequestPayload = { export type CreateStaffApprovalRequestPayload = { action: 'APPROVED' | 'REJECTED'; notes?: string | null; - items: { + items?: { purchase_item_id: number; qty: number; price: number; @@ -92,7 +90,7 @@ export type CreateStaffApprovalRequestPayload = { export type UpdateStaffApprovalRequestPayload = { action: 'APPROVED' | 'REJECTED'; notes?: string | null; - items: Array<{ + items?: Array<{ purchase_item_id?: number; product_id?: number; warehouse_id?: number; @@ -103,12 +101,14 @@ export type UpdateStaffApprovalRequestPayload = { }; export type CreateManagerApprovalRequestPayload = { + action: 'APPROVED' | 'REJECTED'; notes?: string | null; }; export type CreateAcceptApprovalRequestPayload = { - notes?: string; - items: { + action: 'APPROVED' | 'REJECTED'; + notes?: string | null; + items?: { purchase_item_id: number; received_date: string; travel_number: string; @@ -117,7 +117,6 @@ export type CreateAcceptApprovalRequestPayload = { expedition_vendor_id: number; received_qty: number; transport_per_item: number; - transport_total: number; }[]; }; 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; From 5a21a3b44c0726b022333599d2dcff2f90af5522 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 19 Dec 2025 09:38:39 +0700 Subject: [PATCH 2/8] chore(FE-347): adjust UI based on updated ClosingProductionData type --- .../ClosingProductionDataTabContent.tsx | 165 +++++++----------- 1 file changed, 66 insertions(+), 99 deletions(-) diff --git a/src/components/pages/closing/ClosingProductionDataTabContent.tsx b/src/components/pages/closing/ClosingProductionDataTabContent.tsx index ba8a12ed..bffe1707 100644 --- a/src/components/pages/closing/ClosingProductionDataTabContent.tsx +++ b/src/components/pages/closing/ClosingProductionDataTabContent.tsx @@ -33,7 +33,7 @@ const ClosingProductionDataTabContent = ({ ); } - const { purchase, sales, performance, variance } = productionData.data; + const { purchase, sales, performance } = productionData.data; // Helper for consistent row styling const DataRow = ({ @@ -58,39 +58,6 @@ const ClosingProductionDataTabContent = ({
); - // Helper for rows with two values (e.g., Deplesi: Ekor & %) - const DoubleDataRow = ({ - label, - value1, - unit1, - value2, - unit2, - value1ClassName = 'font-bold text-gray-800', - value2ClassName = 'font-bold text-blue-500', - }: { - label: string; - value1: string | number; - unit1: string; - value2: string | number; - unit2: string; - value1ClassName?: string; - value2ClassName?: string; - }) => ( -
- {label} -
-
- {value1} - {unit1} -
-
- {value2} - {unit2} -
-
-
- ); - return (

Data Produksi

@@ -121,17 +88,17 @@ const ClosingProductionDataTabContent = ({ />
@@ -142,27 +109,61 @@ const ClosingProductionDataTabContent = ({

Penjualan

-
- - - - +
+ {/* Chicken Sales */} +
+ + + + +
+ + {/* Egg Sales (if available) */} + {sales.egg && ( + <> +
+
+ + + + +
+ + )}
@@ -178,24 +179,20 @@ const ClosingProductionDataTabContent = ({ Performance
- - -
- - - {/* Variance Section (Pushed to bottom) */} -
-

Selisih

-
- - -
From a6a6ff9f7219faeaa386e4790284aff2b9ee2382 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 19 Dec 2025 09:38:57 +0700 Subject: [PATCH 3/8] feat: create dummyClosingProductionData --- src/dummy/closing.dummy.ts | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/dummy/closing.dummy.ts b/src/dummy/closing.dummy.ts index 3a20cdaf..3b9a9a7b 100644 --- a/src/dummy/closing.dummy.ts +++ b/src/dummy/closing.dummy.ts @@ -83,6 +83,7 @@ import { ClosingIncomingSapronak, ClosingOutgoingSapronak, ClosingOverhead, + ClosingProductionData, ClosingSapronakCalculation, } from '@/types/api/closing'; import { CreatedUser, BaseApiResponse } from '@/types/api/api-general'; @@ -1134,3 +1135,41 @@ export const dummyGetOverhead = async ( data: dummyOverhead, }; }; + +export const dummyClosingProductionData: ClosingProductionData = { + purchase: { + initial_population: 12000, + claim_culling: 150, + final_population: 11850, + feed_in: 24000, + feed_used: 22500, + feed_used_per_head: 1.9, + }, + + sales: { + chicken: { + sales_population: 10500, + sales_weight: 21000, + average_weight: 2.0, + chicken_average_selling_price: 28500, + }, + egg: { + egg_pieces: 185000, + egg_mass_kg: 9250, + average_egg_weight_kg: 0.05, + egg_average_selling_price: 1800, + }, + }, + + performance: { + depletion: 150, + age_day: 35, + mortality_std: 3.5, + mortality_act: 4.2, + deff_mortality: 0.7, + fcr_std: 1.6, + fcr_act: 1.72, + deff_fcr: 0.12, + awg: 60, + }, +}; From d66eaf08c01b1444aacc8210c9372ffccc4d3f17 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 19 Dec 2025 09:39:43 +0700 Subject: [PATCH 4/8] chore(FE-347): set return type for getProductionData method --- src/services/api/closing.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index f85e5331..5e6ced3a 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -11,6 +11,8 @@ import { ClosingProductionData, } from '@/types/api/closing'; import { BaseApiResponse } from '@/types/api/api-general'; + +// TODO: delete these dummy data later import { dummyGetAllFetcher, dummyGetSingle, @@ -19,6 +21,7 @@ import { dummyGetGeneralInfo, dummyGetPerhitunganSapronak, dummyGetOverhead, + dummyClosingProductionData, } from '@/dummy/closing.dummy'; import { httpClient, httpClientFetcher } from '@/services/http/client'; import { ClosingSales } from '@/types/api/closing'; @@ -135,7 +138,9 @@ export class ClosingApiService extends BaseApiService { } } - async getProductionData(id: number) { + async getProductionData( + id: number + ): Promise | undefined> { try { const getProductionDataPath = `${this.basePath}/${id}/production-data`; const getProductionDataRes = await httpClient< From faaa10b74b833bcc306f74b28f554a29ea4375ec Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 19 Dec 2025 09:39:56 +0700 Subject: [PATCH 5/8] chore(FE-347): update ClosingProductionData type --- src/types/api/closing.d.ts | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts index cfafa7f6..6fc8ac71 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -111,33 +111,36 @@ export type ClosingProductionData = { initial_population: number; claim_culling: number; final_population: number; - feed_in_kg: number; - feed_used_kg: number; - feed_used_per_head_kg: number; + feed_in: number; + feed_used: number; + feed_used_per_head: number; }; + sales: { - sales_kg: number; - sales_head: number; - average_weight_kg: number; - average_price_per_kg: number; + chicken: { + sales_population: number; + sales_weight: number; + average_weight: number; + chicken_average_selling_price: number; + }; + egg?: { + egg_pieces: number; + egg_mass_kg: number; + average_egg_weight_kg: number; + egg_average_selling_price: number; + }; }; + performance: { - depletion_head: number; - depletion_percentage: number; - age_days: number; + depletion: number; + age_day: number; mortality_std: number; mortality_act: number; deff_mortality: number; fcr_std: number; fcr_act: number; deff_fcr: number; - adg: number; - ip: number; - }; - variance: { - variance_head: number; - variance_head_percentage: number; - variance_feed_kg: number; + awg: number; }; }; From 206d6c0b4e451135aa29e5384b28108ed1d2ffff Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 22 Dec 2025 15:15:04 +0700 Subject: [PATCH 6/8] Merge branch 'development' into feat/FE/US-335/production-data-report --- package-lock.json | 120 +++- package.json | 3 +- src/app/report/marketing/page.tsx | 11 + src/components/Navbar.tsx | 9 +- src/components/Tabs.tsx | 19 +- src/components/dropdown/Dropdown.tsx | 166 +++--- src/components/helper/RequireAuth.tsx | 3 + src/components/menu/MenuItem.tsx | 17 +- .../report/DailyMarketingReportContent.tsx | 413 +++++++++++++ .../pages/report/DailyMarketingReportPDF.tsx | 550 ++++++++++++++++++ .../pages/report/DailyMarketingsTable.tsx | 255 ++++++++ .../pages/report/MarketingReportContent.tsx | 44 ++ src/config/constant.ts | 37 ++ src/dummy/report/marketing-report.dummy.ts | 139 +++++ src/services/api/closing.ts | 1 + src/services/api/report/marketing-report.ts | 75 +++ src/types/api/master-data/kandang.d.ts | 1 - src/types/api/report/marketing.d.ts | 61 ++ 18 files changed, 1819 insertions(+), 105 deletions(-) create mode 100644 src/app/report/marketing/page.tsx create mode 100644 src/components/pages/report/DailyMarketingReportContent.tsx create mode 100644 src/components/pages/report/DailyMarketingReportPDF.tsx create mode 100644 src/components/pages/report/DailyMarketingsTable.tsx create mode 100644 src/components/pages/report/MarketingReportContent.tsx create mode 100644 src/dummy/report/marketing-report.dummy.ts create mode 100644 src/services/api/report/marketing-report.ts create mode 100644 src/types/api/report/marketing.d.ts diff --git a/package-lock.json b/package-lock.json index f0212474..1e8f3fd8 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.9", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", @@ -26,6 +26,7 @@ "swr": "^2.3.6", "tailwind-merge": "^3.3.1", "use-debounce": "^10.0.6", + "xlsx": "^0.18.5", "yup": "^1.7.0", "zustand": "^5.0.8" }, @@ -1082,9 +1083,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2464,6 +2465,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2924,6 +2934,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2965,6 +2988,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3035,6 +3067,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4180,6 +4224,15 @@ "react": ">=16.8.0" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5654,12 +5707,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -6754,6 +6807,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7515,6 +7580,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7525,6 +7608,27 @@ "node": ">=0.10.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index 52fc6ce2..aa26b7bf 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.9", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", @@ -29,6 +29,7 @@ "swr": "^2.3.6", "tailwind-merge": "^3.3.1", "use-debounce": "^10.0.6", + "xlsx": "^0.18.5", "yup": "^1.7.0", "zustand": "^5.0.8" }, diff --git a/src/app/report/marketing/page.tsx b/src/app/report/marketing/page.tsx new file mode 100644 index 00000000..52a3d4dd --- /dev/null +++ b/src/app/report/marketing/page.tsx @@ -0,0 +1,11 @@ +import MarketingReportContent from '@/components/pages/report/MarketingReportContent'; + +const MarketingReportPage = () => { + return ( +
+ +
+ ); +}; + +export default MarketingReportPage; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index bee92a57..0d5b9bc8 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -54,7 +54,8 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
@@ -62,9 +63,11 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
} - contentClassName='w-52 mt-3' + className={{ + content: 'w-52 mt-3', + }} > - + diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 2ad2477d..8f685452 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -21,6 +21,7 @@ export interface TabsProps className?: | string | { + container?: string; wrapper?: string; tab?: string; content?: string; @@ -53,10 +54,14 @@ const Tabs = ({ onTabChange?.(tabId); }; - const { wrapper: wrapperClassName, tab: tabClassName } = - typeof className === 'object' - ? className - : { wrapper: className, tab: undefined }; + const { + container: containerClassName, + wrapper: wrapperClassName, + tab: tabClassName, + content: contentClassName, + } = typeof className === 'object' + ? className + : { wrapper: className, tab: undefined }; const getTabsClasses = () => { const variantClasses: Record = { @@ -104,7 +109,7 @@ const Tabs = ({ {...props} className={cn( 'w-full', - typeof className === 'string' ? className : undefined + typeof className === 'string' ? className : containerClassName )} >
@@ -121,7 +126,9 @@ const Tabs = ({ ))}
- {activeContent &&
{activeContent}
} + {activeContent && ( +
{activeContent}
+ )}
); }; diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx index 4489231d..5bfa7a7d 100644 --- a/src/components/dropdown/Dropdown.tsx +++ b/src/components/dropdown/Dropdown.tsx @@ -1,111 +1,109 @@ -'use client'; +import React, { ReactNode, useState, useRef } from 'react'; -import { ReactNode, useRef, useEffect, useState } from 'react'; import { cn } from '@/lib/helper'; -interface DropdownProps { +export 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'; + className?: { + wrapper?: string; + trigger?: string; + content?: string; + }; align?: 'start' | 'center' | 'end'; + direction?: 'top' | 'bottom' | 'left' | 'right'; hover?: boolean; - className?: string; - contentClassName?: string; + defaultOpen?: boolean; + open?: boolean; + close?: boolean; + controlled?: boolean; } const Dropdown = ({ trigger, children, - position = 'bottom', - align = 'start', - hover = false, className, - contentClassName, + align, + direction, + hover, + defaultOpen = false, + open, + close, + controlled = false, }: DropdownProps) => { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); 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); + const toggleDropdown = () => { + if (!controlled) { + const newState = !isOpen; + setIsOpen(newState); } - - 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); + const getWrapperClasses = () => { + const openState = controlled ? open : isOpen; + + return cn( + 'dropdown', + { + 'dropdown-start': align === 'start', + 'dropdown-center': align === 'center', + 'dropdown-end': align === 'end', + 'dropdown-top': direction === 'top', + 'dropdown-bottom': direction === 'bottom', + 'dropdown-left': direction === 'left', + 'dropdown-right': direction === 'right', + 'dropdown-hover': hover, + 'dropdown-open': openState && !close, + 'dropdown-close': close, + }, + className?.wrapper + ); }; + const getTriggerClasses = () => { + return cn(className?.trigger); + }; + + const getContentClasses = () => { + return cn( + 'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box', + className?.content + ); + }; + + if (controlled) { + return ( +
+ {trigger} + {open && !close && ( +
+ {children} +
+ )} +
+ ); + } + return ( -
- {/* Trigger Button */} -
+
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleDropdown(); + } + }} + > {trigger}
- - {/* Dropdown Content - Only render when open */} - {isOpen && ( -
setIsOpen(false)} // Close on item click - > + {!close && ( +
{children}
)} diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 65adf48c..9dbd2557 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -27,6 +27,9 @@ const RequireAuth = ({ children }: RequireAuthProps) => { SWRHttpKey >('/sso/userinfo', httpClientFetcher, { shouldRetryOnError: false, + + // refresh every 13 minutes + refreshInterval: 13 * 60 * 1000, }); useEffect(() => { diff --git a/src/components/menu/MenuItem.tsx b/src/components/menu/MenuItem.tsx index dce81dac..61af4b04 100644 --- a/src/components/menu/MenuItem.tsx +++ b/src/components/menu/MenuItem.tsx @@ -8,6 +8,7 @@ interface MenuItemProps { href?: string; icon?: string; active?: boolean; + isLoading?: boolean; onClick?: () => void; className?: string; } @@ -17,6 +18,7 @@ const MenuItem = ({ href, icon, active = false, + isLoading = false, className, onClick, }: MenuItemProps) => { @@ -50,17 +52,28 @@ const MenuItem = ({ return (
  • - {href && ( + {!isLoading && href && ( {menuItemContent} )} - {!href && ( + {!isLoading && !href && ( )} + + {isLoading && ( + + )}
  • ); }; diff --git a/src/components/pages/report/DailyMarketingReportContent.tsx b/src/components/pages/report/DailyMarketingReportContent.tsx new file mode 100644 index 00000000..1eba4ea3 --- /dev/null +++ b/src/components/pages/report/DailyMarketingReportContent.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { ChangeEventHandler, useState } from 'react'; +import { pdf } from '@react-pdf/renderer'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import Dropdown from '@/components/dropdown/Dropdown'; +import DateInput from '@/components/input/DateInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import Menu from '@/components/menu/Menu'; +import MenuItem from '@/components/menu/MenuItem'; +import DailyMarketingsTable from '@/components/pages/report/DailyMarketingsTable'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import DailyMarketingReportPDF from '@/components/pages/report/DailyMarketingReportPDF'; + +import { Area } from '@/types/api/master-data/area'; +import { + AreaApi, + CustomerApi, + LocationApi, + WarehouseApi, +} from '@/services/api/master-data'; +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { Customer } from '@/types/api/master-data/customer'; +import { MarketingReportApi } from '@/services/api/report/marketing-report'; +import { MARKETING_TYPE_OPTIONS } from '@/config/constant'; +import { httpClient } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { isResponseError } from '@/lib/api-helper'; + +const DailyMarketingReportContent = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter({ + initial: { + search: '', + area_id: '', + location_id: '', + warehouse_id: '', + customer_id: '', + start_date: '', + end_date: '', + marketing_type: '', + filter_by: '', + sort_by: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + area_id: 'area_id', + location_id: 'location_id', + warehouse_id: 'warehouse_id', + customer_id: 'customer_id', + start_date: 'start_date', + end_date: 'end_date', + marketing_type: 'marketing_type', + filter_by: 'filter_by', + sort_by: 'sort_by', + }, + }); + + const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`; + + const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = + useState(false); + const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false); + + const [selectedArea, setSelectedArea] = useState(null); + const { + setInputValue: setAreaInputValue, + options: areaOptions, + isLoadingOptions: isLoadingAreaOptions, + } = useSelect(AreaApi.basePath, 'id', 'name'); + + const areaChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedArea(val as OptionType); + updateFilter('area_id', val ? ((val as OptionType).value as string) : ''); + }; + + const [selectedLocation, setSelectedLocation] = useState( + null + ); + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + } = useSelect(LocationApi.basePath, 'id', 'name'); + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + updateFilter( + 'location_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedWarehouse, setSelectedWarehouse] = useState( + null + ); + const { + setInputValue: setWarehouseInputValue, + options: warehouseOptions, + isLoadingOptions: isLoadingWarehouseOptions, + } = useSelect(WarehouseApi.basePath, 'id', 'name'); + + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedWarehouse(val as OptionType); + updateFilter( + 'warehouse_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedCustomer, setSelectedCustomer] = useState( + null + ); + const { + setInputValue: setCustomerInputValue, + options: customerOptions, + isLoadingOptions: isLoadingCustomerOptions, + } = useSelect(CustomerApi.basePath, 'id', 'name'); + + const customerChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedCustomer(val as OptionType); + updateFilter( + 'customer_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const startDateChangeHandler = (e: React.ChangeEvent) => { + updateFilter('start_date', e.target.value ? e.target.value : ''); + }; + + const endDateChangeHandler = (e: React.ChangeEvent) => { + updateFilter('end_date', e.target.value ? e.target.value : ''); + }; + + const [selectedMarketingType, setSelectedMarketingType] = + useState(null); + const marketingTypeChangeHandler = ( + val: OptionType | OptionType[] | null + ) => { + setSelectedMarketingType(val as OptionType); + updateFilter( + 'marketing_type', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const filterByChangeHandler = (filterBy: string) => { + updateFilter('filter_by', filterBy); + }; + + const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => { + updateFilter('sort_by', sort); + }; + + const exportToExcelHandler = async () => { + setIsLoadingExportingToExcel(true); + + await MarketingReportApi.exportDailyMarketingToExcel( + getTableFilterQueryString() + ); + + setIsLoadingExportingToExcel(false); + }; + + const exportToPdfHandler = async () => { + setIsLoadingExportingToPdf(true); + + const params = new URLSearchParams(getTableFilterQueryString()); + + params.set('limit', '9999999'); + + const queryString = `?${params.toString()}`; + + try { + const dailyMarketingsReport = await httpClient< + BaseApiResponse + >(`${MarketingReportApi.basePath}${queryString}`); + + if (isResponseError(dailyMarketingsReport)) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + return; + } + + const openPdf = async () => { + const dailyMarketingReportPdfBlob = await pdf( + + ).toBlob(); + + const dailyMarketingReportPdfUrl = URL.createObjectURL( + dailyMarketingReportPdfBlob + ); + window.open(dailyMarketingReportPdfUrl, '_blank'); + }; + + const downloadPdf = async () => { + const blob = await pdf( + + ).toBlob(); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'laporan-penjualan-harian.pdf'; + link.click(); + + URL.revokeObjectURL(url); + }; + + await openPdf(); + } catch (error) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + } + + setIsLoadingExportingToPdf(false); + }; + + const handleReset = () => { + setSelectedArea(null); + setSelectedLocation(null); + setSelectedWarehouse(null); + setSelectedCustomer(null); + setSelectedMarketingType(null); + resetFilter(); + }; + + return ( +
    +
    +

    Penjualan Harian

    +
    + + {/* Filters */} +
    +
    + + + + + + + + + + + +
    + +
    + + +
    + + + + + + Export{' '} + + + } + > + + + + + +
    +
    +
    + + +
    + ); +}; + +export default DailyMarketingReportContent; diff --git a/src/components/pages/report/DailyMarketingReportPDF.tsx b/src/components/pages/report/DailyMarketingReportPDF.tsx new file mode 100644 index 00000000..337892b3 --- /dev/null +++ b/src/components/pages/report/DailyMarketingReportPDF.tsx @@ -0,0 +1,550 @@ +'use client'; + +import { + Document, + Image, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; + +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; + +interface DailyMarketingReportPDFProps { + data?: DailyMarketingReport; +} + +const DailyMarketingReportPDFStyle = StyleSheet.create({ + page: { + paddingTop: 24, + paddingBottom: 64, + paddingHorizontal: 16, // Reduce padding to fit more columns + orientation: 'landscape', + }, + + companyInfoHeader: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + companyLogo: { + width: 64, + height: 'auto', + }, + companyInfoHeaderDate: { + paddingTop: 8, + fontSize: 10, + }, + companyName: { + fontSize: 12, + fontWeight: 'bold', + marginBottom: 4, + }, + companyAddress: { + fontSize: 8, + maxWidth: 400, + marginBottom: 10, + }, + + title: { + marginTop: 16, + fontSize: 14, + lineHeight: '150%', + textAlign: 'center', + fontFamily: 'Times-Roman', + fontWeight: 'bold', + }, + + footer: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + + position: 'absolute', + fontSize: 8, + bottom: 30, + left: 0, + right: 0, + textAlign: 'center', + color: 'grey', + }, + + // Table Styles + table: { + width: '100%', + marginTop: 16, + borderWidth: 1, + borderColor: '#000000', + borderBottomWidth: 0, + fontSize: 7, // Smaller font for report + }, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + alignItems: 'center', + minHeight: 20, + }, + tableHeader: { + backgroundColor: '#f0f0f0', + fontWeight: 'bold', + }, + + // Columns definition (Total 100%) + colNo: { + width: '3%', + padding: 2, + textAlign: 'center', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSoDate: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colDoDate: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colAging: { + width: '3%', + padding: 2, + textAlign: 'center', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colWarehouse: { + width: '7%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colCustomer: { + width: '9%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, // Reduced slightly + colSales: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colProduct: { + width: '8%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, // Reduced slightly + colDoNumber: { + width: '7%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colVehicle: { + width: '5%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colMarketingType: { + width: '5%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colQty: { + width: '4%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colAvgWeight: { + width: '4%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colTotalWeight: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSalesPrice: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colHppPrice: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSalesAmount: { + width: '6%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colHppAmount: { width: '6%', padding: 2, textAlign: 'right' }, // Last column + + // Text inside columns + cellText: { + fontSize: 6, + }, + headerText: { + fontSize: 7, + fontWeight: 'bold', + textAlign: 'center', + }, + + // Utils + doubleDivider: { + width: '100%', + height: 6, + borderTop: '2px solid black', + borderBottom: '2px solid black', + }, + + // Summary + summaryContainer: { + marginTop: 12, + flexDirection: 'row', + justifyContent: 'flex-end', + width: '100%', + }, + summaryTable: { + width: '30%', + borderWidth: 1, + borderColor: '#000000', + fontSize: 8, + }, + summaryRow: { + flexDirection: 'row', + padding: 2, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + summaryLabel: { + width: '50%', + fontWeight: 'bold', + }, + summaryValue: { + width: '50%', + textAlign: 'right', + }, +}); + +const DailyMarketingReportPDF = ({ data }: DailyMarketingReportPDFProps) => { + const rows = data?.rows || []; + const summary = data?.summary; + + return ( + + + + + + + + {formatDate(Date.now(), 'DD MMMM YYYY')} + + + + + + PT LUMBUNG TELUR INDONESIA + + + SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. + Cipedes, Kec. Sukajadi, Kota Bandung 40162 + + + + + + + + Laporan Penjualan Harian + + + {/* Data Table */} + + {/* Header */} + + + No + + + + Tgl SO + + + + + Tgl DO + + + + Aging + + + + Gudang + + + + + Pelanggan + + + + Sales + + + + Produk + + + + No DO + + + + Plat No + + + + Tipe + + + Qty + + + + Rerata + + + + Berat + + + + Hrg Jual + + + + + HPP/kg + + + + + Total Jual + + + + + Total HPP + + + + + {/* Rows */} + {rows.map((row, index) => ( + + + + {index + 1} + + + + + {formatDate(row.so_date, 'DD/MM/YYYY')} + + + + + {formatDate(row.do_date, 'DD/MM/YYYY')} + + + + + {row.aging_days} + + + + + {row.warehouse?.name} + + + + + {row.customer?.name} + + + + + {row.sales} + + + + + {row.product?.name} + + + + + {row.do_number} + + + + + {row.vehicle_number} + + + + + {row.marketing_type} + + + + + {formatNumber(row.qty)} + + + + + {formatNumber(row.average_weight_kg)} + + + + + {formatNumber(row.total_weight_kg)} + + + + + {formatCurrency(row.sales_price_per_kg)} + + + + + {formatCurrency(row.hpp_price_per_kg)} + + + + + {formatCurrency(row.sales_amount)} + + + + + {formatCurrency(row.hpp_amount)} + + + + ))} + + + {/* Summary */} + + + + + Total Qty: + + + {formatNumber(summary?.total_qty ?? 0)} + + + + + Total Berat (kg): + + + {formatNumber(summary?.total_weight_kg ?? 0)} + + + + + Total Penjualan: + + + {formatCurrency(summary?.total_sales_amount ?? 0)} + + + + + Total HPP: + + + {formatCurrency(summary?.total_hpp_amount ?? 0)} + + + + + + + + `${pageNumber} / ${totalPages}` + } + fixed + /> + + + + ); +}; + +export default DailyMarketingReportPDF; diff --git a/src/components/pages/report/DailyMarketingsTable.tsx b/src/components/pages/report/DailyMarketingsTable.tsx new file mode 100644 index 00000000..d6914cf1 --- /dev/null +++ b/src/components/pages/report/DailyMarketingsTable.tsx @@ -0,0 +1,255 @@ +'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, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { DailyMarketingRow } from '@/types/api/report/marketing'; +import { MarketingReportApi } from '@/services/api/report/marketing-report'; + +interface DailyMarketingsTableProps { + dailyMarketingsReportUrl: string; + onSetPage: (page: number) => void; + pageSize: number; + onSetPageSize: (pageSize: number) => void; + searchValue: string; + onSearchChange: ChangeEventHandler; + onFilterByChange: (filterBy: string) => void; + onSortByChange: (sort: 'asc' | 'desc' | '') => void; +} + +const DailyMarketingsTable = ({ + dailyMarketingsReportUrl, + onSetPage, + pageSize, + onSetPageSize, + searchValue, + onSearchChange, + onFilterByChange, + onSortByChange, +}: DailyMarketingsTableProps) => { + const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR( + dailyMarketingsReportUrl, + MarketingReportApi.getAllDailyMarketingFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + + const dailyMarketingColumns: ColumnDef[] = [ + { + header: 'No', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'so_date', + header: 'Tanggal Jual', + cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'), + footer: 'Total', + }, + { + accessorKey: 'do_date', + header: 'Tanggal DO', + cell: (props) => formatDate(props.row.original.do_date, 'DD-MMM-YYYY'), + }, + { + accessorKey: 'aging_days', + header: 'Aging', + cell: (props) => `${props.row.original.aging_days} hari`, + }, + { + accessorKey: 'warehouse.name', + header: 'Gudang', + }, + { + accessorKey: 'customer.name', + header: 'Pelanggan', + }, + { + accessorKey: 'do_number', + header: 'No. DO', + }, + { + accessorKey: 'sales', + header: 'Sales/Marketing', + }, + { + accessorKey: 'vehicle_number', + header: 'No. Polisi', + cell: (props) => ( + {props.row.original.vehicle_number} + ), + }, + { + accessorKey: 'marketing_type', + header: 'Marketing Type', + }, + { + accessorKey: 'product.name', + header: 'Produk', + }, + { + accessorKey: 'qty', + header: 'Kuantitas', + cell: (props) => formatNumber(props.row.original.qty), + footer: () => { + const totalQty = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_qty + : 0; + + return formatNumber(totalQty); + }, + }, + { + accessorKey: 'average_weight_kg', + header: 'Bobot Rata-Rata (Kg)', + cell: (props) => formatNumber(props.row.original.average_weight_kg), + }, + { + accessorKey: 'total_weight_kg', + header: 'Bobot Total (Kg)', + cell: (props) => formatNumber(props.row.original.total_weight_kg), + footer: () => { + const totalWeightKg = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_weight_kg + : 0; + + return formatNumber(totalWeightKg); + }, + }, + { + accessorKey: 'sales_price_per_kg', + header: 'Harga Jual (Rp)', + cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), + }, + { + accessorKey: 'hpp_price_per_kg', + header: 'HPP (Rp)', + cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), + }, + { + accessorKey: 'sales_amount', + header: 'Total (Rp)', + cell: (props) => formatCurrency(props.row.original.sales_amount), + footer: () => { + const totalSalesAmount = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_sales_amount + : 0; + + return formatCurrency(totalSalesAmount); + }, + }, + ]; + + useEffect(() => { + if (sorting.length === 1) { + onFilterByChange(sorting[0].id); + onSortByChange(sorting[0].desc ? 'desc' : 'asc'); + } else { + onFilterByChange(''); + onSortByChange(''); + } + }, [sorting]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.rows.length > 0 + : false + ); + } + }, [dailyMarketings, isResponseSuccess]); + + return ( + + +
    Penjualan Harian
    + + +
    + } + className='w-full!' + titleClassName='w-full p-0!' + > +
    +
    +
    + +
    +
    + + + data={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.data.rows + : [] + } + columns={dailyMarketingColumns} + pageSize={pageSize} + onPageSizeChange={onSetPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.meta?.total_results + : 0 + } + onPageChange={onSetPage} + isLoading={isLoadingDailyMarketings} + sorting={sorting} + setSorting={setSorting} + renderFooter={true} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(dailyMarketings) && + dailyMarketings?.data?.rows.length === 0, + }), + }} + /> +
    + + + ); +}; + +export default DailyMarketingsTable; diff --git a/src/components/pages/report/MarketingReportContent.tsx b/src/components/pages/report/MarketingReportContent.tsx new file mode 100644 index 00000000..160de8b2 --- /dev/null +++ b/src/components/pages/report/MarketingReportContent.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { JSX, useState } from 'react'; + +import Tabs from '@/components/Tabs'; +import DailyMarketingReportContent from '@/components/pages/report/DailyMarketingReportContent'; + +type MarketingReportTabType = + | 'daily' + | 'transaction' + | 'hpp-comparison' + | 'daily-hpp'; + +const marketingReportTabs: { + id: MarketingReportTabType; + label: string; + content: JSX.Element; +}[] = [ + { + id: 'daily', + label: 'Penjualan Harian', + content: , + }, +]; + +const MarketingReportContent = () => { + const [activeTab, setActiveTab] = useState('daily'); + + return ( +
    + +
    + ); +}; + +export default MarketingReportContent; diff --git a/src/config/constant.ts b/src/config/constant.ts index 96fc8401..c16862af 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -45,6 +45,17 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ link: '/closing', icon: 'heroicons-outline:presentation-chart-bar', }, + { + text: 'Laporan', + link: '/report', + icon: 'heroicons-outline:document-text', + submenu: [ + { + text: 'Penjualan', + link: '/report/marketing', + }, + ], + }, { text: 'Persediaan', link: '/inventory', @@ -251,3 +262,29 @@ export const ACCEPTED_FILE_TYPE = { 'image/*': [], }, }; + +export const FILTER_TYPE_OPTIONS = [ + { + label: 'Tanggal Realisasi', + value: 'REALIZATION_DATE', + }, + { + label: 'Tanggal DO', + value: 'DO_DATE', + }, +]; + +export const MARKETING_TYPE_OPTIONS = [ + { + label: 'Ayam', + value: 'ayam', + }, + { + label: 'Telur', + value: 'telur', + }, + { + label: 'Trading', + value: 'trading', + }, +]; diff --git a/src/dummy/report/marketing-report.dummy.ts b/src/dummy/report/marketing-report.dummy.ts new file mode 100644 index 00000000..ea5af398 --- /dev/null +++ b/src/dummy/report/marketing-report.dummy.ts @@ -0,0 +1,139 @@ +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; + +// TODO: delete this later +export const DAILY_MARKETING_DUMMY_DATA: BaseApiResponse = + { + code: 200, + status: 'success', + message: 'Get daily marketing report successfully', + meta: { + page: 1, + limit: 10, + total_pages: 1, + total_results: 2, + }, + data: { + rows: [ + { + // metadata + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-12-01T08:00:00Z', + updated_at: '2025-12-01T08:00:00Z', + + // row data + no: 1, + so_date: '2025-12-01', + do_date: '2025-12-08', + aging_days: 7, + + warehouse: { + id: 1, + name: 'Warehouse Kandang A', + type: 'KANDANG', + area: { + id: 1, + name: 'Area Barat', + }, + location: { + id: 1, + name: 'Farm Bandung', + address: 'Jl. Raya Farm No. 1', + area: null, + }, + kandang: { + id: 1, + name: 'Kandang A1', + status: 'ACTIVE', + capacity: 5000, + location: null, + pic: null, + }, + }, + + customer: { + id: 1, + name: 'PT Maju Jaya', + pic_id: 10, + pic: { + id: 10, + id_user: 210, + email: 'pic@majujaya.com', + name: 'Budi Santoso', + }, + type: 'BROILER', + address: 'Jl. Industri No. 10', + phone: '08123456789', + email: 'contact@majujaya.com', + account_number: '1234567890', + }, + + sales: 'Andi Wijaya', + + product: { + id: 1, + name: 'Live Chicken', + brand: 'LTI Farm', + sku: 'LC-001', + product_price: 18_000, + selling_price: 20_000, + tax: 0, + expiry_period: 0, + uom: { + id: 1, + name: 'Kg', + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + product_category: { + id: 1, + code: 'BROILER', + name: 'Broiler Chicken', + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + suppliers: [], + flags: ['LIVE'], + }, + + do_number: 'DO-2025-0001', + vehicle_number: 'B 1234 CD', + marketing_type: 'REGULAR', + + qty: 1000, + average_weight_kg: 1.8, + total_weight_kg: 1800, + + sales_price_per_kg: 20_000, + hpp_price_per_kg: 18_000, + + sales_amount: 36_000_000, + hpp_amount: 32_400_000, + }, + ], + + summary: { + total_qty: 1000, + total_weight_kg: 1800, + total_sales_amount: 36_000_000, + total_hpp_amount: 32_400_000, + }, + }, + }; diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index 5e6ced3a..21ae1cf8 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -25,6 +25,7 @@ import { } from '@/dummy/closing.dummy'; import { httpClient, httpClientFetcher } from '@/services/http/client'; import { ClosingSales } from '@/types/api/closing'; +import { sleep } from '@/lib/helper'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { diff --git a/src/services/api/report/marketing-report.ts b/src/services/api/report/marketing-report.ts new file mode 100644 index 00000000..b1bcafae --- /dev/null +++ b/src/services/api/report/marketing-report.ts @@ -0,0 +1,75 @@ +import * as XLSX from 'xlsx'; +import toast from 'react-hot-toast'; + +import { BaseApiService } from '@/services/api/base'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { formatDate, sleep } from '@/lib/helper'; + +export class MarketingReportApiService extends BaseApiService< + DailyMarketingReport, + unknown, + unknown +> { + constructor(basePath: string = '/reports/marketings/daily-marketing') { + super(basePath); + } + + async getAllDailyMarketingFetcher( + endpoint: string + ): Promise> { + return await httpClientFetcher>( + endpoint + ); + } + + async exportDailyMarketingToExcel(initialQueryString: string) { + const params = new URLSearchParams(initialQueryString); + + params.set('limit', '9999999'); + + const queryString = `?${params.toString()}`; + + try { + const dailyMarketingsReport = await httpClientFetcher< + BaseApiResponse + >(`${this.basePath}${queryString}`); + + if (isResponseError(dailyMarketingsReport)) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + return; + } + + const rows = dailyMarketingsReport.data.rows; + + const formattedRows = []; + + for (let i = 0; i < rows.length; i++) { + formattedRows.push({ + ...rows[i], + created_user: rows[i].created_user.name, + created_at: formatDate(rows[i].created_at, 'YYYY-MM-DD'), + updated_at: formatDate(rows[i].updated_at, 'YYYY-MM-DD'), + warehouse: rows[i].warehouse.name, + customer: rows[i].customer.name, + product: rows[i].product.name, + }); + } + + const ws = XLSX.utils.json_to_sheet(formattedRows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'laporan-penjualan-harian'); + + // triggers download in browser + XLSX.writeFile(wb, 'laporan-penjualan-harian.xlsx'); + } catch (error) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + } + } +} + +export const MarketingReportApi = new MarketingReportApiService( + '/reports/marketings/daily-marketing' +); diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts index c9c14882..eafa0334 100644 --- a/src/types/api/master-data/kandang.d.ts +++ b/src/types/api/master-data/kandang.d.ts @@ -10,7 +10,6 @@ export type BaseKandang = { capacity: number; pic: BaseUser; project_flock_kandang_id?: number; - capacity: number; }; export type Kandang = BaseMetadata & BaseKandang; diff --git a/src/types/api/report/marketing.d.ts b/src/types/api/report/marketing.d.ts new file mode 100644 index 00000000..d1e81f77 --- /dev/null +++ b/src/types/api/report/marketing.d.ts @@ -0,0 +1,61 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { BaseCustomer, Customer } from '@/types/api/master-data/customer'; +import { + BaseWarehouseArea, + BaseWarehouseKandang, + BaseWarehouseLocation, + Warehouse, +} from '@/types/api/master-data/warehouse'; +import { Location } from '@/types/api/master-data/location'; +import { Area } from '@/types/api/master-data/area'; +import { BaseProduct } from '@/types/api/master-data/product'; + +export type BaseDailyMarketingRow = { + no: number; + so_date: string; // e.g. "01-Dec-2025" + do_date: string; // e.g. "08-Dec-2025" + aging_days: number; + + warehouse: BaseWarehouseArea | BaseWarehouseLocation | BaseWarehouseKandang; + customer: BaseCustomer; + sales: string; + product: BaseProduct; + + do_number: string; + vehicle_number: string; + marketing_type: string; + + qty: number; + average_weight_kg: number; + total_weight_kg: number; + + sales_price_per_kg: number; + hpp_price_per_kg: number; + + sales_amount: number; + hpp_amount: number; +}; + +export type DailyMarketingRow = BaseMetadata & BaseDailyMarketingRow; + +export interface SalesSummary { + total_qty: number; + total_weight_kg: number; + total_sales_amount: number; + total_hpp_amount: number; +} + +export type DailyMarketingReport = { + rows: DailyMarketingRow[]; + summary: SalesSummary; +}; + +export type MarketingReportFilters = { + area_id?: number; + location_id?: number; + warehouse_id?: number; + customer_id?: number; + start_date?: string; + end_date?: string; + date_type?: 'realized' | 'transaction'; +}; From 7ea16d6a8a9b113dd74b6898bff669c41c26ebd8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 22 Dec 2025 15:23:15 +0700 Subject: [PATCH 7/8] Merge branch 'development' into feat/FE/US-335/production-data-report --- src/services/api/closing.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index 21ae1cf8..5e3dded8 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -148,6 +148,7 @@ export class ClosingApiService extends BaseApiService { BaseApiResponse >(getProductionDataPath); + // return getProductionDataRes; } catch (error) { if (axios.isAxiosError>(error)) { From ea32056ca880f8b7c4e1e72052ec2564abf3c9db Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 22 Dec 2025 15:29:39 +0700 Subject: [PATCH 8/8] feat(FE-347): adjust getProductionData method --- src/services/api/closing.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index c77d49f1..21ae1cf8 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -143,12 +143,11 @@ export class ClosingApiService extends BaseApiService { id: number ): Promise | undefined> { try { - // const getProductionDataPath = `${this.basePath}/${id}/production-data`; - // const getProductionDataRes = await httpClient< - // BaseApiResponse - // >(getProductionDataPath); + const getProductionDataPath = `${this.basePath}/${id}/production-data`; + const getProductionDataRes = await httpClient< + BaseApiResponse + >(getProductionDataPath); - // return getProductionDataRes; } catch (error) { if (axios.isAxiosError>(error)) {
    {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..b6c5a2c0 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'; +import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line'; 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, @@ -31,114 +34,154 @@ const ChickinFormKandang = ({ refresh: refreshApprovals, } = useApprovalSteps({ latestApproval: initialValues?.approval, - approvalLines: PROJECT_FLOCK_KANDANG_APPROVAL_LINE, - moduleName: 'PROJECT_FLOCK_KANDANGS', + approvalLines: CHICKINS_APPROVAL_LINE, + moduleName: 'CHICKINS', moduleId: initialValues?.id.toString() ?? '', }); 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..99eb1cb3 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 <= 2 && ( + + )} + {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..26072927 --- /dev/null +++ b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx @@ -0,0 +1,305 @@ +'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, + StockItem, +} 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( + projectFlockKandang?.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) + : true; + }, [closingData]); + + const errorExpense = useMemo(() => { + return isResponseSuccess(closingData) + ? closingData?.data?.expenses.every((expense) => expense.step < 5) + : true; + }, [closingData]); + + const isCanCloseValid = true; + + 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.step_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', + }} + /> + {/* {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_category', + }, + { + header: 'Quantity', + accessorKey: 'quantity', + }, + { + header: 'UOM', + accessorKey: 'uom', + }, + ]} + 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..92510a8d --- /dev/null +++ b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx @@ -0,0 +1,476 @@ +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, + PROJECT_FLOCK_KANDANGS_APPROVAL_LINE, +} from '@/config/approval-line'; +import useSWR from 'swr'; +import { ProjectFlockKandangApi } from '@/services/api/production'; + +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 { data: projectFlockKandang, isLoading: projectFlockKandangLoading } = + useSWR( + selectedKandangId + ? `${ProjectFlockKandangApi.basePath}/get-detail/${selectedKandangId}` + : null, + selectedKandangId + ? () => + ProjectFlockKandangApi.getSingle( + Number(selectedKandang?.project_flock_kandang_id) + ) + : null + ); + + const { + approvals, + isLoading: approvalsLoading, + refresh: refreshApprovals, + } = useApprovalSteps({ + latestApproval: projectFlock?.approval, + approvalLines: PROJECT_FLOCK_APPROVAL_LINE, + moduleName: 'PROJECT_FLOCKS', + moduleId: projectFlock?.id.toString() ?? '', + }); + + const { approvals: kandangApprovals, isLoading: kandangApprovalsLoading } = + useApprovalSteps({ + latestApproval: + selectedKandangId && isResponseSuccess(projectFlockKandang) + ? projectFlockKandang?.data?.approval + : undefined, + approvalLines: PROJECT_FLOCK_KANDANGS_APPROVAL_LINE, + moduleName: 'PROJECT_FLOCK_KANDANGS', + moduleId: + selectedKandangId && isResponseSuccess(projectFlockKandang) + ? projectFlockKandang?.data?.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

    + {kandangApprovals && !kandangApprovalsLoading && ( + + )} + {/* 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..65cead2a 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -35,33 +35,32 @@ const RowOptionsMenu = ({ deleteClickHandler, approveClickHandler, rejectClickHandler, - isGradingCompleted, }: { type: 'dropdown' | 'collapse'; props: CellContext; deleteClickHandler: () => void; approveClickHandler: () => void; rejectClickHandler: () => void; - isGradingCompleted: (recording: Recording) => boolean; }) => { - const isLayingCategory = - props.row.original.project_flock_category === 'LAYING'; - const isRecordingApproved = (recording: Recording) => { return ( recording.approval?.action === 'APPROVED' && - recording.approval?.step_name === 'Disetujui' && - recording.approval?.step_number === 3 + recording.approval?.step_number === 2 && + recording.approval?.step_name === 'Disetujui' ); }; + const isRecordingRejected = (recording: Recording) => { + return recording.approval?.action === 'REJECTED'; + }; + const isApproved = isRecordingApproved(props.row.original); - const isGradingDone = isGradingCompleted(props.row.original); + const isRejected = isRecordingRejected(props.row.original); return ( - {!isApproved && !(isLayingCategory && !isGradingDone) && ( + {!isApproved && !isRejected && ( {type === 'detail' && + initialValues?.approval && !isRecordingApproved(initialValues) && - (!isLayingCategory || hasGradingData(initialValues)) && ( + !isRecordingRejected(initialValues) && (
    + { {formik.values.stocks?.map((stock, idx) => (
    + { {formik.values.depletions?.map((depletion, idx) => (
    + { * + + Berat (gram) + + * + + Action
    + { /> -
    - -
    + +
    + @@ -2779,46 +2730,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Right side actions */}
    - {type === 'detail' && isLayingCategory && ( - - - - )} - {type === 'edit' && (
    - {isLayingCategory && ( - - - - )}
    )}
    @@ -2981,7 +2819,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Approve Confirmation Modal */} {(type as 'add' | 'edit' | 'detail') === 'detail' && !isRecordingApproved(initialValues) && - (!isLayingCategory || hasGradingData(initialValues)) && ( + !isRecordingRejected(initialValues) && ( { {/* Reject Confirmation Modal */} {(type as 'add' | 'edit' | 'detail') === 'detail' && - !isRecordingApproved(initialValues) && - (!isLayingCategory || hasGradingData(initialValues)) && ( + !isRecordingApproved(initialValues) && ( { - // HOOKS & ROUTER - const router = useRouter(); - const searchParams = useSearchParams(); - const recordingId = searchParams.get('recording_id'); - - // STATE MANAGEMENT - const [selectedGradingItems, setSelectedGradingItems] = useState( - [] - ); - const [gradingFormErrorMessage, setGradingFormErrorMessage] = useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const deleteModal = useModal(); - - // API DATA FETCHING - const recordingUrl = useMemo(() => { - const recordingIdToUse = recordingId; - if (!recordingIdToUse) return null; - return `${RecordingApi.basePath}/${recordingIdToUse}`; - }, [recordingId]); - - const { data: recordingData } = useSWR( - recordingUrl, - recordingUrl ? RecordingApi.getAllFetcher : null - ); - - // DATA PROCESSING - const recording = - recordingData?.status === 'success' - ? (recordingData.data as unknown as Recording) - : undefined; - - const projectFlockKandangUrl = useMemo(() => { - if (!recording?.project_flock_kandang_id) return null; - return `${ProjectFlockKandangApi.basePath}/${recording.project_flock_kandang_id}`; - }, [recording?.project_flock_kandang_id]); - - const { data: projectFlockKandangData } = useSWR( - projectFlockKandangUrl, - projectFlockKandangUrl ? ProjectFlockKandangApi.getAllFetcher : null - ); - - const projectFlockKandang = - projectFlockKandangData?.status === 'success' - ? (projectFlockKandangData.data as unknown as ProjectFlockKandang) - : undefined; - - const konsumsiBaikEggData = useMemo(() => { - if (!recording?.eggs) return null; - - const konsumsiBaikEgg = recording.eggs.find((egg: RecordingEgg) => - egg.product_warehouse?.product?.name - ?.toLowerCase() - .includes('konsumsi baik') - ); - - return konsumsiBaikEgg || null; - }, [recording]); - - const totalKonsumsiBaikEggs = konsumsiBaikEggData?.qty || 0; - const konsumsiBaikEggId = konsumsiBaikEggData?.id; - - const isDataLoading = - !recording || - (totalKonsumsiBaikEggs === 0 && - recording?.project_flock_category === 'LAYING'); - - // FORM HANDLERS - const createGradingHandler = useCallback( - async (payload: CreateGradingPayload) => { - const res = (await RecordingApi.createGrading(payload)) as - | BaseApiResponse - | undefined; - - if (!res || isResponseError(res)) { - setGradingFormErrorMessage(res?.message || 'Failed to add Grading'); - return; - } - - toast.success(res?.message || 'Successfully added Grading!'); - router.push('/production/recording'); - }, - [router] - ); - - const updateGradingHandler = useCallback( - async (gradingId: number, payload: UpdateGradingPayload) => { - const res = (await RecordingApi.updateGrading(gradingId, payload)) as - | BaseApiResponse - | undefined; - - if (!res || isResponseError(res)) { - setGradingFormErrorMessage(res?.message || 'Failed to update Grading'); - return; - } - toast.success(res?.message || 'Successfully updated Grading!'); - router.refresh(); - router.push('/production/recording'); - }, - [router] - ); - - const deleteRecordingClickHandler = useCallback(() => { - deleteModal.openModal(); - }, [deleteModal]); - - const confirmationModalDeleteClickHandler = useCallback(async () => { - if (!initialValues?.id) return; - - setIsDeleteLoading(true); - try { - const res = (await RecordingApi.deleteGrading(initialValues.id)) as - | BaseApiResponse - | undefined; - - if (!res || isResponseError(res)) { - setGradingFormErrorMessage(res?.message || 'Failed to delete Grading'); - return; - } - deleteModal.closeModal(); - toast.success(res?.message || 'Successfully delete Grading!'); - router.push('/production/recording'); - } catch (err) { - console.error(err); - setGradingFormErrorMessage('Failed to delete Grading'); - } finally { - setIsDeleteLoading(false); - } - }, [deleteModal, initialValues?.id, router]); - - // FORMIK SETUP - const formikInitialValues = useMemo(() => { - let recordingEggId: number | undefined = konsumsiBaikEggId; - - if (!recordingEggId && initialValues?.id) { - recordingEggId = initialValues.id; - } - - if (!recordingEggId) { - recordingEggId = parseInt(recordingId || '0') || 0; - } - - let gradingData: { - recording_egg_id: number; - grade: string; - qty: number; - }[] = []; - - if (initialValues?.grading_eggs && initialValues.grading_eggs.length > 0) { - gradingData = initialValues.grading_eggs.map((grading: GradingEgg) => ({ - recording_egg_id: recordingEggId, - grade: grading.grade, - qty: grading.qty, - })); - } else if (initialValues?.gradings && initialValues.gradings.length > 0) { - gradingData = initialValues.gradings.map( - (grading: { grade: string; qty: number }) => ({ - recording_egg_id: recordingEggId, - grade: grading.grade, - qty: grading.qty, - }) - ); - } - - return getRecordingGradingFormInitialValues({ - recording_egg_id: recordingEggId, - eggs_grading: gradingData, - }); - }, [initialValues, recordingId, konsumsiBaikEggId]); - - const formik = useFormik({ - initialValues: formikInitialValues, - enableReinitialize: true, - validationSchema: (() => { - return type === 'edit' - ? UpdateRecordingGradingFormSchema - : RecordingGradingFormSchema; - })(), - validateOnChange: true, - validateOnBlur: true, - onSubmit: async (values) => { - const gradingPayload = { - eggs_grading: (values.eggs_grading ?? []).map((grading) => ({ - recording_egg_id: grading.recording_egg_id, - grade: grading.grade, - qty: grading.qty || 0, - })), - }; - - switch (type) { - case 'add': - await createGradingHandler(gradingPayload as CreateGradingPayload); - break; - case 'edit': - await updateGradingHandler( - initialValues?.id as number, - gradingPayload as UpdateGradingPayload - ); - break; - } - }, - }); - - const currentGradingTotal = useMemo(() => { - return (formik.values.eggs_grading || []).reduce((total, grading) => { - return total + (Number(grading.qty) || 0); - }, 0); - }, [formik.values.eggs_grading]); - - const isGradingExceedsAvailable = currentGradingTotal > totalKonsumsiBaikEggs; - const isGradingIncomplete = - currentGradingTotal < totalKonsumsiBaikEggs && totalKonsumsiBaikEggs > 0; - const hasUserStartedGrading = currentGradingTotal > 0; - - // GRADING HANDLERS - const addGrading = () => { - let recordingEggId: number | undefined = konsumsiBaikEggId; - - if (!recordingEggId && initialValues?.id) { - recordingEggId = initialValues.id; - } - - if (!recordingEggId) { - recordingEggId = parseInt(recordingId || '0') || 0; - } - - const newGrading = [ - ...(formik.values.eggs_grading || []), - { - recording_egg_id: recordingEggId, - grade: '', - qty: '', - }, - ]; - formik.setFieldValue('eggs_grading', newGrading); - }; - - const handleGradingGradeChangeWrapper = useCallback( - (idx: number) => (selectedOption: OptionType | OptionType[] | null) => { - const option = selectedOption as OptionType | null; - formik.setFieldValue(`eggs_grading.${idx}.grade`, option?.label || ''); - }, - [formik] - ); - - const handleGradingQtyChangeWrapper = useCallback( - (idx: number) => (e: React.ChangeEvent) => { - const value = parseFloat(e.target.value) || 0; - formik.setFieldValue(`eggs_grading.${idx}.qty`, value); - }, - [formik] - ); - - const removeGrading = (idx: number) => { - const updatedGrading = formik.values.eggs_grading?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('eggs_grading', updatedGrading); - }; - - const removeSelectedGrading = () => { - const updatedGrading = formik.values.eggs_grading?.filter( - (_, idx) => !selectedGradingItems.includes(idx) - ); - formik.setFieldValue('eggs_grading', updatedGrading); - setSelectedGradingItems([]); - }; - - // VALIDATION HELPERS - const isRepeaterInputError = ( - arrayName: 'eggs_grading', - column: string, - idx: number - ) => { - const touched = formik.touched as Record; - const errors = formik.errors as Record; - - if (!touched[arrayName] || !Array.isArray(touched[arrayName])) { - return { - isError: false, - errorMessage: '', - }; - } - - const touchedField = (touched[arrayName] as unknown[])?.[idx] as Record< - string, - unknown - >; - const errorField = (errors[arrayName] as unknown[])?.[idx] as Record< - string, - unknown - >; - - return { - isError: touchedField && Boolean(errorField?.[column]), - errorMessage: - touchedField && errorField?.[column] - ? (errorField[column] as string) - : '', - }; - }; - - // EFFECTS - useEffect(() => { - if (isDataLoading) { - toast.dismiss('grading-exceeds'); - toast.dismiss('grading-incomplete'); - return; - } - - if (isGradingExceedsAvailable && currentGradingTotal > 0) { - toast.error( - `Total grading (${currentGradingTotal}) melebihi telur yang tersedia (${totalKonsumsiBaikEggs})!`, - { - id: 'grading-exceeds', - duration: 3000, - } - ); - toast.dismiss('grading-incomplete'); - } else if (isGradingIncomplete && hasUserStartedGrading) { - toast.error( - `Total grading (${currentGradingTotal}) tidak sama dengan total telur konsumsi baik yang tersedia (${totalKonsumsiBaikEggs})! Semua telur harus digrading.`, - { - id: 'grading-incomplete', - duration: 3000, - } - ); - toast.dismiss('grading-exceeds'); - } else { - toast.dismiss('grading-exceeds'); - toast.dismiss('grading-incomplete'); - } - }, [ - isDataLoading, - isGradingExceedsAvailable, - isGradingIncomplete, - hasUserStartedGrading, - currentGradingTotal, - totalKonsumsiBaikEggs, - ]); - - useEffect(() => { - if ( - konsumsiBaikEggId && - formik.values.eggs_grading && - formik.values.eggs_grading.length === 0 - ) { - formik.setFieldValue('eggs_grading', [ - { recording_egg_id: konsumsiBaikEggId, grade: '', qty: '' }, - ]); - } - }, [konsumsiBaikEggId, formik.values.eggs_grading.length]); - - return ( - <> -
    -
    - -

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

    -
    - -
    - {/* Basic Info Card */} - -
    - {/* Status Approval */} - {recording?.approval && ( -
    - Status Approval -
    - - {(() => { - const actionText = (() => { - switch (recording.approval.action) { - case 'APPROVED': - return 'Disetujui'; - case 'REJECTED': - return 'Ditolak'; - case 'CREATED': - return 'Dibuat'; - case 'UPDATED': - return 'Diperbarui'; - default: - return recording.approval.action; - } - })(); - - const stepName = recording.approval.step_name; - - if (stepName === actionText) { - return stepName; - } - - return `${stepName} - ${actionText}`; - })()} - -
    -
    - )} - {/* Recording Info */} -
    - Lokasi -

    - {projectFlockKandang?.project_flock?.location?.name || '-'} -

    -
    -
    - Project Flock -

    - {projectFlockKandang?.project_flock?.flock_name || '-'} -

    -
    -
    - Kandang -

    - {projectFlockKandang?.kandang?.name || '-'} -

    -
    -
    - Tanggal Recording -

    - {recording - ? formatDate(recording.record_datetime, 'DD MMMM YYYY') - : '-'} -

    -
    -
    - Hari -

    Hari ke-{recording?.day || '-'}

    -
    -
    - Kategori -

    - - {recording?.project_flock_category || '-'} - -

    -
    -
    - Periode -

    - - Periode {projectFlockKandang?.project_flock?.period || '-'} - -

    -
    -
    - -
    - {/* Additional Recording Info */} -
    -
    -
    - -
    - - Detail Recording - -
    -
    -
    -

    Area

    -

    - {projectFlockKandang?.project_flock?.area?.name || '-'} -

    -
    -
    -

    Status Kandang

    -

    - {projectFlockKandang?.kandang?.status || '-'} -

    -
    -
    -
    - - {/* Total Telur Konsumsi Baik Info */} -
    -
    -
    -

    - Total Telur Konsumsi Baik -

    -
    -

    - {isDataLoading ? ( - - ) : ( - totalKonsumsiBaikEggs - )}{' '} - - telur - -

    -
    -
    -
    - -
    -
    - - {/* Progress Bar */} -
    -
    - Total yang digrading: - - {isDataLoading ? ( - - ) : ( - `${currentGradingTotal} / ${totalKonsumsiBaikEggs}` - )} - -
    -
    -
    -
    - {!isDataLoading && isGradingExceedsAvailable && ( -
    - - Melebihi batas tersedia -
    - )} - {!isDataLoading && - isGradingIncomplete && - hasUserStartedGrading && ( -
    - - - Grading belum lengkap, semua telur harus digrading - -
    - )} - {isDataLoading && ( -
    - - Memuat data telur konsumsi baik... -
    - )} -
    -
    -
    - - - {/* Grading Table */} - -
    - - - - {type !== 'detail' && ( - - )} - - - {type !== 'detail' && } - - - - {formik.values.eggs_grading?.map((grading, idx) => ( - - {type !== 'detail' && ( - - )} - - - {type !== 'detail' && ( - - )} - - ))} - -
    - 0 - } - onChange={( - e: React.ChangeEvent - ) => { - if (e.target.checked) { - setSelectedGradingItems( - formik.values.eggs_grading?.map( - (_, idx) => idx - ) ?? [] - ); - } else { - setSelectedGradingItems([]); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - - Grade - - * - - - Jumlah - - * - - Action
    - - ) => { - if (e.target.checked) { - setSelectedGradingItems([ - ...selectedGradingItems, - idx, - ]); - } else { - setSelectedGradingItems( - selectedGradingItems.filter((i) => i !== idx) - ); - } - }} - classNames={{ - wrapper: 'flex justify-center', - checkbox: 'checkbox checkbox-sm', - }} - /> - - - - - -
    - -
    -
    -
    - {type !== 'detail' && ( -
    - {selectedGradingItems.length > 0 && ( - - )} - -
    - )} -
    - - {/* Action buttons */} -
    - {type !== 'add' && ( -
    - {deleteRecordingClickHandler && ( - - )} - {type !== 'edit' && initialValues && ( - - )} -
    - )} - {type !== 'detail' && ( -
    - - -
    - )} -
    - {gradingFormErrorMessage && ( -
    - - {gradingFormErrorMessage} -
    - )} - -
    - - {/* ===== MODALS ===== */} - {type !== 'add' && ( - <> - - - )} - - ); -}; - -export default GradingForm; diff --git a/src/components/pages/production/transfer-to-laying/form/TransferToLayingForm.schema.ts b/src/components/pages/production/transfer-to-laying/form/TransferToLayingForm.schema.ts index 2e26a36d..42387992 100644 --- a/src/components/pages/production/transfer-to-laying/form/TransferToLayingForm.schema.ts +++ b/src/components/pages/production/transfer-to-laying/form/TransferToLayingForm.schema.ts @@ -226,9 +226,7 @@ export const getFilledTransferToLayingFormInitialValues = async ( // targetKandang.target_project_flock_kandang.kandang.capacity, // TODO: integrate this to real API kandang capacity - maxQuantity: - targetKandang.target_project_flock_kandang.kandang.capacity ?? - Infinity, + maxQuantity: Infinity, })) : [], diff --git a/src/components/pages/purchase/PurchaseTable.tsx b/src/components/pages/purchase/PurchaseTable.tsx index bf59d5ee..a77e1158 100644 --- a/src/components/pages/purchase/PurchaseTable.tsx +++ b/src/components/pages/purchase/PurchaseTable.tsx @@ -16,7 +16,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; -import { cn, formatDate, formatCurrency } from '@/lib/helper'; +import { cn, formatDate } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; @@ -136,14 +136,6 @@ const PurchaseTable = () => { ? formatDate(props.row.original.po_date, 'DD MMM YYYY') : '-', }, - { - accessorKey: 'due_date', - header: 'Jatuh Tempo', - cell: (props) => - props.row.original.due_date - ? formatDate(props.row.original.due_date, 'DD MMM YYYY') - : '-', - }, { header: 'Aging', cell: (props) => { @@ -156,11 +148,6 @@ const PurchaseTable = () => { return `${diffDays} hari`; }, }, - { - accessorKey: 'grand_total', - header: 'Total (Rp.)', - cell: (props) => formatCurrency(props.row.original.grand_total), - }, { header: 'Aksi', cell: (props) => { diff --git a/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx index 7909ade9..15106c5e 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx +++ b/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx @@ -18,7 +18,7 @@ import { PurchaseRequestAcceptApprovalFormDefaultValues, PurchaseRequestAcceptApprovalFormInitialValues, PurchaseRequestAcceptApprovalFormSchema, -} from './PurchaseOrderForm.schema'; +} from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema'; import { isResponseError } from '@/lib/api-helper'; import { PurchaseApi } from '@/services/api/purchase'; import { @@ -52,6 +52,8 @@ const PurchaseOrderAcceptApprovalForm = ({ const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = useState(''); + const isRejected = initialValues?.latest_approval?.action === 'REJECTED'; + // ===== UTILITY FUNCTIONS ===== const isRepeaterInputError = ( idx: number, @@ -64,7 +66,6 @@ const PurchaseOrderAcceptApprovalForm = ({ | 'expedition_vendor_id' | 'received_qty' | 'transport_per_item' - | 'transport_total' ): { isError: boolean; errorMessage: string } => { const touchedItem = formik.touched.items?.[idx]; const errorItem = formik.errors.items?.[idx] as @@ -163,6 +164,7 @@ const PurchaseOrderAcceptApprovalForm = ({ validateOnBlur: true, onSubmit: async (values) => { const payload: CreateAcceptApprovalRequestPayload = { + action: 'APPROVED', notes: values.notes || '', items: values.items?.map((formItem) => { @@ -181,10 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({ typeof formItem.transport_per_item === 'string' ? parseFloat(formItem.transport_per_item) || 0 : formItem.transport_per_item || 0, - transport_total: - typeof formItem.transport_total === 'string' - ? parseFloat(formItem.transport_total) || 0 - : formItem.transport_total || 0, }; }) || [], }; @@ -239,9 +237,8 @@ const PurchaseOrderAcceptApprovalForm = ({ vehicle_number: item.vehicle_number || '', expedition_vendor: null, expedition_vendor_id: 0, - received_qty: '', + received_qty: item.total_qty || '', transport_per_item: '', - transport_total: '', }; }); formik.setFieldValue('items', updatedItems); @@ -301,7 +298,7 @@ const PurchaseOrderAcceptApprovalForm = ({ // ===== PURCHASE ITEM OPERATIONS ===== const handlePurchaseItemChange = ( idx: number, - field: 'received_qty' | 'transport_per_item' | 'transport_total', + field: 'received_qty' | 'transport_per_item', value: string | number ) => { const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; @@ -318,26 +315,6 @@ const PurchaseOrderAcceptApprovalForm = ({ : parseFloat( formik.values.items?.[idx]?.transport_per_item as string ) || 0; - - if (receivedQty > 0 && transportPerItem >= 0) { - const calculatedTransportTotal = receivedQty * transportPerItem; - formik.setFieldValue( - `items.${idx}.transport_total`, - calculatedTransportTotal - ); - } - } - - if (field === 'transport_total') { - const receivedQty = - parseFloat(formik.values.items?.[idx]?.received_qty as string) || 0; - if (receivedQty > 0 && numValue >= 0) { - const calculatedTransportPerItem = numValue / receivedQty; - formik.setFieldValue( - `items.${idx}.transport_per_item`, - calculatedTransportPerItem - ); - } } }; @@ -386,10 +363,6 @@ const PurchaseOrderAcceptApprovalForm = ({ Transport/Item * -
    - Total Transport - * -
    - - handlePurchaseItemChange( - idx, - 'transport_total', - e.target.value - ) - } - onBlur={formik.handleBlur} - placeholder='Masukkan total transport' - allowNegative={false} - decimalScale={2} - thousandSeparator=',' - decimalSeparator='.' - inputPrefix={'Rp'} - isError={ - isRepeaterInputError(idx, 'transport_total').isError - } - errorMessage={ - isRepeaterInputError(idx, 'transport_total') - .errorMessage - } - className={{ - wrapper: 'min-w-40 md:min-w-52 lg:min-w-64', - }} - /> -