Compare commits

..

1 Commits

Author SHA1 Message Date
Rivaldi A N S 4beb3d4f91 Merge branch 'dev/randy' into 'fix/FE/master-data-and-production'
[FIX/FE][US#33-74] Fix issue in master data and adjusmen in project flock

See merge request mbugroup/lti-web-client!120
2025-12-30 02:47:23 +00:00
245 changed files with 2490 additions and 30622 deletions
+25 -21
View File
@@ -2,17 +2,6 @@ stages:
- build - build
- deploy - deploy
# ==========================================================
# ✅ Global defaults
# ==========================================================
default:
tags:
- server-development-biznet
interruptible: true
# ==========================================================
# 🏗️ Build Template
# ==========================================================
.build_template: &build_template .build_template: &build_template
stage: build stage: build
image: node:20-alpine image: node:20-alpine
@@ -50,9 +39,6 @@ default:
- out/ - out/
expire_in: 1 week expire_in: 1 week
# ==========================================================
# 🚀 Deploy Template
# ==========================================================
.deploy_template: &deploy_template .deploy_template: &deploy_template
stage: deploy stage: deploy
image: image:
@@ -96,11 +82,11 @@ default:
if [ "$STATUS" = "success" ]; then if [ "$STATUS" = "success" ]; then
COLOR=3066993 COLOR=3066993
TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded" TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded"
DESC="Deployment job on branch \${CI_COMMIT_REF_NAME}\ completed successfully." DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."
else else
COLOR=15158332 COLOR=15158332
TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed" TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed"
DESC="Deployment job on branch \${CI_COMMIT_REF_NAME}\ encountered issues." DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` encountered issues."
fi fi
jq -n \ jq -n \
@@ -128,9 +114,7 @@ default:
curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL" curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL"
# ========================================================== # ====== DEVELOPMENT (Branch development) ======
# ==== DEVELOPMENT (Branch development) ======
# ==========================================================
build:dev: build:dev:
<<: *build_template <<: *build_template
rules: rules:
@@ -156,9 +140,7 @@ deploy:dev:
name: development name: development
url: https://dev-lti-erp.mbugroup.id url: https://dev-lti-erp.mbugroup.id
# ==========================================================
# ====== STAGING (Branch staging) ====== # ====== STAGING (Branch staging) ======
# ==========================================================
build:staging: build:staging:
<<: *build_template <<: *build_template
rules: rules:
@@ -183,3 +165,25 @@ deploy:staging:
environment: environment:
name: staging name: staging
url: https://stg-lti-erp.mbugroup.id url: https://stg-lti-erp.mbugroup.id
# ====== PRODUCTION ======
# build:production:
# <<: *build_template
# rules:
# # pilih salah satu: pakai branch master ATAU pakai tags rilis
# - if: '$CI_COMMIT_BRANCH == "master"'
# # - if: '$CI_COMMIT_TAG' # kalau mau rilis via tag, uncomment ini dan hapus baris di atas
# environment:
# name: production
# deploy:production:
# <<: *deploy_template
# needs: ["build:production"]
# rules:
# - if: '$CI_COMMIT_BRANCH == "master"'
# # - if: '$CI_COMMIT_TAG' # selaras dengan rule di build:production
# variables:
# S3_BUCKET: "lti-erp.mbugroup.id"
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
# environment:
# name: production
+30 -3211
View File
File diff suppressed because it is too large Load Diff
+2 -14
View File
@@ -15,34 +15,22 @@
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lucide-react": "^0.562.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.9", "next": "15.5.9",
"next-themes": "^0.4.6", "react": "19.1.0",
"radix-ui": "^1.4.3",
"react": "^19.1.2",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "^19.1.2", "react-dom": "19.1.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.70.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-resizable-panels": "2.1.7",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"swr": "^2.3.6", "swr": "^2.3.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6", "use-debounce": "^10.0.6",
"vaul": "^1.1.2",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.0", "yup": "^1.7.0",
"zustand": "^5.0.8" "zustand": "^5.0.8"
+1 -27
View File
@@ -7,33 +7,18 @@ import ClosingDetail from '@/components/pages/closing/ClosingDetail';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { FlockApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production';
const ClosingDetailPage = () => { const ClosingDetailPage = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const closingId = searchParams.get('closingId'); const closingId = searchParams.get('closingId');
const kandangId = searchParams.get('kandangId'); // project flock kandang ID
const { data: closing, isLoading: isLoadingClosing } = useSWR( const { data: closing, isLoading: isLoadingClosing } = useSWR(
closingId, closingId,
(id: number) => ClosingApi.getGeneralInfo(id) (id: number) => ClosingApi.getGeneralInfo(id)
); );
// WORKAROUND - get flock data from closing ID
const { data: projectData, isLoading: isLoadingProject } = useSWR(
`flock-${closingId}`,
() => ProjectFlockApi.getSingle(Number(closingId))
);
// WORKAROUND - get kandang data from closing ID
const { data: kandangData, isLoading: isLoadingKandang } = useSWR(
kandangId ? `kandang-${closingId}-${kandangId}` : null,
() => ProjectFlockKandangApi.getSingle(Number(kandangId))
);
const { data: salesData, isLoading: isLoadingSales } = useSWR( const { data: salesData, isLoading: isLoadingSales } = useSWR(
closingId ? `sales-${closingId}` : null, closingId ? `sales-${closingId}` : null,
() => ClosingApi.getPenjualan(Number(closingId)) () => ClosingApi.getPenjualan(Number(closingId))
@@ -59,12 +44,7 @@ const ClosingDetailPage = () => {
return; return;
} }
const isLoading = const isLoading = isLoadingClosing || isLoadingSales || isLoadingHppEkspedisi;
isLoadingClosing ||
isLoadingSales ||
isLoadingHppEkspedisi ||
isLoadingProject ||
isLoadingKandang;
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-row justify-center'>
@@ -80,12 +60,6 @@ const ClosingDetailPage = () => {
? hppEkspedisiData.data ? hppEkspedisiData.data
: undefined : undefined
} }
projectData={
isResponseSuccess(projectData) ? projectData.data : undefined
}
kandangData={
isResponseSuccess(kandangData) ? kandangData.data : undefined
}
/> />
)} )}
</div> </div>
@@ -1,11 +0,0 @@
import { DailyChecklistContent } from '@/figma-make/components/pages/daily-checklist/DailyChecklistContent';
const DailyChecklistPage = () => {
return (
<section className='w-full'>
<DailyChecklistContent />
</section>
);
};
export default DailyChecklistPage;
@@ -1,11 +0,0 @@
import { Dashboard as DashboardDailyChecklist } from '@/figma-make/components/pages/dashboard/Dashboard';
const DailyChecklistDashboardPage = () => {
return (
<section className='w-full'>
<DashboardDailyChecklist />
</section>
);
};
export default DailyChecklistDashboardPage;
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -1,11 +0,0 @@
import { DetailDailyChecklistContent } from '@/figma-make/components/pages/list-daily-checklist/detail/DetailDailyChecklistContent';
const ListDailyChecklistDetailPage = () => {
return (
<section className='w-full'>
<DetailDailyChecklistContent />
</section>
);
};
export default ListDailyChecklistDetailPage;
@@ -1,11 +0,0 @@
import { ListDailyChecklistContent } from '@/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent';
const ListDailyChecklistPage = () => {
return (
<section className='w-full'>
<ListDailyChecklistContent />
</section>
);
};
export default ListDailyChecklistPage;
@@ -1,11 +0,0 @@
import { MasterAktivitasContent } from '@/figma-make/components/pages/master-data/activity/MasterAktivitasContent';
const MasterAktivitasPage = () => {
return (
<section className='w-full'>
<MasterAktivitasContent />
</section>
);
};
export default MasterAktivitasPage;
@@ -1,11 +0,0 @@
import { MasterConfigurationContent } from '@/figma-make/components/pages/master-data/configuration/MasterConfigurationContent';
const MasterConfigurationPage = () => {
return (
<section className='w-full'>
<MasterConfigurationContent />
</section>
);
};
export default MasterConfigurationPage;
@@ -1,11 +0,0 @@
import { MasterEmployeeContent } from '@/figma-make/components/pages/master-data/employee/MasterEmployeeContent';
const MasterEmployeePage = () => {
return (
<section className='w-full'>
<MasterEmployeeContent />
</section>
);
};
export default MasterEmployeePage;
-11
View File
@@ -1,11 +0,0 @@
import { DailyChecklistReportsContent } from '@/figma-make/components/pages/reports/DailyChecklistReportsContent';
const DailyChecklistReportsPage = () => {
return (
<section className='w-full'>
<DailyChecklistReportsContent />
</section>
);
};
export default DailyChecklistReportsPage;
+5 -3
View File
@@ -1,7 +1,9 @@
import DashboardProduction from '@/components/pages/dashboard/DashboardProduction';
const Dashboard = () => { const Dashboard = () => {
return <DashboardProduction />; return (
<section className='w-full p-4'>
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
</section>
);
}; };
export default Dashboard; export default Dashboard;
+1 -1
View File
@@ -37,7 +37,7 @@ const ExpenseRealization = () => {
const isExpenseCanBeRealized = const isExpenseCanBeRealized =
isResponseSuccess(expense) && isResponseSuccess(expense) &&
expense.data.latest_approval.action !== 'REJECTED' && expense.data.latest_approval.action !== 'REJECTED' &&
expense.data.latest_approval.step_number === 4; expense.data.latest_approval.step_number === 3;
if (isResponseSuccess(expense) && !isExpenseCanBeRealized) { if (isResponseSuccess(expense) && !isExpenseCanBeRealized) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
-1
View File
@@ -1,7 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin "daisyui"; @plugin "daisyui";
@import '../styles/daisyui.css'; @import '../styles/daisyui.css';
@import '../figma-make/styles/theme.css';
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: 'lti'; name: 'lti';
-2
View File
@@ -3,7 +3,6 @@ import { Inter } from 'next/font/google';
import '@/app/globals.css'; import '@/app/globals.css';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Toaster as SonnerToaster } from '@/figma-make/components/base/sonner';
import MainDrawer from '@/components/MainDrawer'; import MainDrawer from '@/components/MainDrawer';
import RequireAuth from '@/components/helper/RequireAuth'; import RequireAuth from '@/components/helper/RequireAuth';
@@ -36,7 +35,6 @@ export default function RootLayout({
</RequireAuth> </RequireAuth>
<Toaster /> <Toaster />
<SonnerToaster position='top-right' />
</body> </body>
</html> </html>
); );
@@ -1,7 +0,0 @@
import UniformityForm from '@/components/pages/production/uniformity/form/UniformityForm';
const AddUniformity = () => {
return <UniformityForm formType='add' />;
};
export default AddUniformity;
@@ -1,49 +0,0 @@
'use client';
import UniformityDetail from '@/components/pages/production/uniformity/detail/UniformityDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { UniformityApi } from '@/services/api/uniformity';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const UniformityDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const uniformityId = searchParams.get('uniformityId');
const { data: uniformity, isLoading: isLoadingUniformity } = useSWR(
uniformityId,
(id: string) => UniformityApi.getUniformityDetail(parseInt(id))
);
if (!uniformityId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingUniformity && (!uniformity || isResponseError(uniformity))) {
router.replace('/404');
return;
}
return (
<div className='w-full h-full flex flex-col justify-center'>
{isLoadingUniformity && (
<div className='w-full flex flex-row justify-center items-center p-4 min-h-screen'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{isResponseSuccess(uniformity) && (
<UniformityDetail initialValues={uniformity.data} />
)}
</div>
);
};
export default UniformityDetailPage;
-10
View File
@@ -1,10 +0,0 @@
import { ReactNode } from 'react';
import UniformityPageWrapper from '@/components/pages/production/uniformity/UniformityPageWrapper';
export default function UniformityLayout({
children,
}: {
children: ReactNode;
}) {
return <UniformityPageWrapper>{children}</UniformityPageWrapper>;
}
-7
View File
@@ -1,7 +0,0 @@
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable';
const Uniformity = () => {
return <UniformityTable />;
};
export default Uniformity;
-7
View File
@@ -1,7 +0,0 @@
import FinanceTabs from '@/components/pages/report/finance/FinanceTabs';
const Finance = () => {
return <FinanceTabs />;
};
export default Finance;
-11
View File
@@ -1,11 +0,0 @@
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
const ProductionResultReportPage = () => {
return (
<section className='w-full max-w-7xl pb-16'>
<ProductionResultContent />
</section>
);
};
export default ProductionResultReportPage;
+14 -34
View File
@@ -3,25 +3,29 @@
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import type { Color, Variant, Size } from '@/types/theme';
export interface BadgeProps export interface BadgeProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'className'> { extends Omit<HTMLAttributes<HTMLSpanElement>, 'className'> {
children?: ReactNode; children?: ReactNode;
className?: { className?: {
badge?: string; badge?: string;
status?: string;
}; };
statusIndicator?: boolean; variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
variant?: Variant; color?:
color?: Color; | 'neutral'
size?: Size; | 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
} }
const Badge = ({ const Badge = ({
children, children,
className, className,
statusIndicator = false,
variant = 'default', variant = 'default',
color, color,
size = 'md', size = 'md',
@@ -30,7 +34,7 @@ const Badge = ({
const getBadgeClasses = () => { const getBadgeClasses = () => {
const baseClasses = 'badge'; const baseClasses = 'badge';
const variantClasses: Record<Variant, string> = { const variantClasses = {
default: '', default: '',
outline: 'badge-outline', outline: 'badge-outline',
ghost: 'badge-ghost', ghost: 'badge-ghost',
@@ -38,7 +42,7 @@ const Badge = ({
dash: 'badge-dash', dash: 'badge-dash',
}; };
const colorClasses: Record<Color, string> = { const colorClasses = {
neutral: 'badge-neutral', neutral: 'badge-neutral',
primary: 'badge-primary', primary: 'badge-primary',
secondary: 'badge-secondary', secondary: 'badge-secondary',
@@ -47,10 +51,9 @@ const Badge = ({
success: 'badge-success', success: 'badge-success',
warning: 'badge-warning', warning: 'badge-warning',
error: 'badge-error', error: 'badge-error',
none: '',
}; };
const sizeClasses: Record<Size, string> = { const sizeClasses = {
xs: 'badge-xs', xs: 'badge-xs',
sm: 'badge-sm', sm: 'badge-sm',
md: 'badge-md', md: 'badge-md',
@@ -67,31 +70,8 @@ const Badge = ({
); );
}; };
const getStatusClasses = () => {
if (!statusIndicator) return '';
const statusIndicatorClasses: Record<Color, string> = {
neutral: 'bg-neutral',
primary: 'bg-primary',
secondary: 'bg-secondary',
accent: 'bg-accent',
info: 'bg-info',
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error',
none: '',
};
return cn(
'w-2.5 h-2.5 rounded-full',
color && statusIndicatorClasses[color],
className?.status
);
};
return ( return (
<span className={getBadgeClasses()} {...props}> <span className={getBadgeClasses()} {...props}>
{statusIndicator && <span className={getStatusClasses()} />}
{children} {children}
</span> </span>
); );
+6 -42
View File
@@ -15,8 +15,6 @@ interface DrawerProps {
className?: DrawerClassName; className?: DrawerClassName;
onBackdropClick?: () => void; onBackdropClick?: () => void;
closeOnBackdropClick?: boolean; closeOnBackdropClick?: boolean;
expandedContent?: ReactNode;
expandedWidth?: string;
} }
type DrawerClassName = { type DrawerClassName = {
@@ -38,8 +36,6 @@ const Drawer = ({
className, className,
onBackdropClick, onBackdropClick,
closeOnBackdropClick = true, closeOnBackdropClick = true,
expandedContent,
expandedWidth = 'w-[400px]',
}: DrawerProps) => { }: DrawerProps) => {
const getDrawerClassNames = (): DrawerClassName => { const getDrawerClassNames = (): DrawerClassName => {
const baseClassNames = { const baseClassNames = {
@@ -50,21 +46,12 @@ const Drawer = ({
drawerSidebarContent: 'min-h-full bg-base-100', drawerSidebarContent: 'min-h-full bg-base-100',
}; };
const getSidebarWidth = () => {
if (variant === 'sidebar') {
return expandedContent
? 'w-full lg:min-w-[600px] lg:max-w-[600px]'
: 'w-full max-w-[300px] lg:w-[300px]';
}
return 'w-full sm:min-w-120 sm:w-fit';
};
if (variant === 'sidebar') { if (variant === 'sidebar') {
return { return {
...baseClassNames, ...baseClassNames,
drawerSidebarContent: cn( drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent, baseClassNames.drawerSidebarContent,
getSidebarWidth() 'w-full max-w-[300px] lg:w-[300px]'
), ),
}; };
} else if (variant === 'right') { } else if (variant === 'right') {
@@ -73,11 +60,11 @@ const Drawer = ({
drawer: cn(baseClassNames.drawer, 'drawer-end'), drawer: cn(baseClassNames.drawer, 'drawer-end'),
drawerSide: cn( drawerSide: cn(
baseClassNames.drawerSide, baseClassNames.drawerSide,
'border-l border-solid border-gray-200 sm:drawer-side w-screen top-0 right-0 fixed z-21' 'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
), ),
drawerSidebarContent: cn( drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent, baseClassNames.drawerSidebarContent,
getSidebarWidth() 'w-full sm:min-w-120 sm:w-fit'
), ),
}; };
} else if (variant === 'left') { } else if (variant === 'left') {
@@ -89,7 +76,7 @@ const Drawer = ({
), ),
drawerSidebarContent: cn( drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent, baseClassNames.drawerSidebarContent,
getSidebarWidth() 'w-full sm:min-w-120 sm:w-fit'
), ),
}; };
} }
@@ -151,38 +138,15 @@ const Drawer = ({
onClick={closeDrawer} onClick={closeDrawer}
/> />
{/* Sidebar Content - Full height container */} {/* Sidebar Content */}
<div
className={cn(
'flex h-screen bg-base-100 overflow-hidden',
variant === 'right' && 'flex-row'
)}
>
{/* Primary Sidebar Content */}
<div <div
className={cn( className={cn(
varianClassName?.drawerSidebarContent, varianClassName?.drawerSidebarContent,
className?.drawerContent, className?.drawerContent
'overflow-y-auto'
)} )}
> >
{sidebarContent} {sidebarContent}
</div> </div>
{/* Expanded Drawer (Right side, side-by-side) */}
{expandedContent && (
<div
className={cn(
'border-l border-gray-200 bg-white flex flex-col h-full',
expandedWidth
)}
>
<div className='overflow-y-auto flex-1 h-full'>
{expandedContent}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
); );
+3 -3
View File
@@ -39,8 +39,8 @@ const FloatingActionsButton = ({
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB // Jika tidak ada baris yang dipilih, jangan tampilkan FAB
const positionStyles = const positionStyles =
selectedRowIds.length > 0 selectedRowIds.length > 0
? 'bottom-[5%] opacity-100' ? 'bottom-[10%] opacity-100'
: 'bottom-[-5%] opacity-0'; : 'bottom-[-10%] opacity-0';
// Helper untuk menentukan gaya warna tombol approval // Helper untuk menentukan gaya warna tombol approval
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => { const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
@@ -60,7 +60,7 @@ const FloatingActionsButton = ({
// Container utama FAB // Container utama FAB
<div <div
className={cn( className={cn(
`fixed ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`, `absolute ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
'mx-auto w-full max-w-sm sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform', 'mx-auto w-full max-w-sm sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
'bg-slate-950 backdrop-blur-md' 'bg-slate-950 backdrop-blur-md'
)} )}
+1 -3
View File
@@ -67,9 +67,7 @@ const MainDrawer = ({
const pathname = usePathname(); const pathname = usePathname();
const { permissionCheck } = useAuth(); const { permissionCheck } = useAuth();
const formattedPathname = pathname.endsWith('/') ? pathname : `${pathname}/`; const isPermitted = ROUTE_PERMISSIONS[pathname]?.some((permission) =>
const isPermitted = ROUTE_PERMISSIONS[formattedPathname]?.some((permission) =>
permissionCheck(permission) permissionCheck(permission)
); );
+7 -24
View File
@@ -5,7 +5,6 @@ import useSWR from 'swr';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { AuthApi } from '@/services/api/auth';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general'; import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
@@ -29,8 +28,8 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
>('/sso/userinfo', httpClientFetcher, { >('/sso/userinfo', httpClientFetcher, {
shouldRetryOnError: false, shouldRetryOnError: false,
// refresh every 12 minutes // refresh every 13 minutes
refreshInterval: 12 * 60 * 1000, refreshInterval: 13 * 60 * 1000,
}); });
useEffect(() => { useEffect(() => {
@@ -56,25 +55,6 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
setIsLoadingUser(isLoadingUserResponse); setIsLoadingUser(isLoadingUserResponse);
}, [isLoadingUserResponse]); }, [isLoadingUserResponse]);
useEffect(() => {
const interval = setInterval(
async () => {
await AuthApi.refresh();
},
12 * 60 * 1000
);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const refreshUserSession = async () => {
await AuthApi.refresh();
};
refreshUserSession();
}, []);
if ( if (
(isLoadingUserResponse && !userResponse && !userErrorResponse) || (isLoadingUserResponse && !userResponse && !userErrorResponse) ||
(!userResponse && !userErrorResponse) (!userResponse && !userErrorResponse)
@@ -86,7 +66,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
); );
} }
if (!isLoadingUserResponse && userErrorResponse) { if (userErrorResponse) {
return ( return (
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'> <div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
<h2 className='text-2xl font-bold text-error'>Authentication Failed</h2> <h2 className='text-2xl font-bold text-error'>Authentication Failed</h2>
@@ -94,7 +74,10 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
Please try refreshing the page or contact support if the problem Please try refreshing the page or contact support if the problem
persists. persists.
</p> </p>
<button className='btn btn-primary' onClick={() => redirectToSSO()}> <button
className='btn btn-primary'
onClick={() => window.location.reload()}
>
Retry Retry
</button> </button>
</div> </div>
-49
View File
@@ -1,49 +0,0 @@
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import { useState } from 'react';
/**
* Alert Unique Error List
* @param formErrorList - Array of error messages
* @param onClose - Function to close the alert
*/
const AlertErrorList = ({
formErrorList,
onClose,
}: {
formErrorList: string[];
onClose: () => void;
}) => {
if (formErrorList.length === 0) return null;
return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'>
<div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} />
<span className='font-semibold'>
Terdapat {formErrorList.length} error pada form:
</span>
</div>
<Button
onClick={onClose}
variant='link'
className='ml-auto p-0 w-fit text-white'
color='none'
>
<Icon icon='material-symbols:close' width={24} height={24} />
</Button>
</div>
<ul className='list-disc list-inside pl-8 space-y-1 w-full'>
{formErrorList.map((error, index) => (
<li key={index} className='text-sm'>
{error}
</li>
))}
</ul>
</Alert>
);
};
export default AlertErrorList;
-8
View File
@@ -33,7 +33,6 @@ const FileInput = ({
isError, isError,
errorMessage, errorMessage,
disabled = false, disabled = false,
required = false,
onChange, onChange,
onBlur, onBlur,
readOnly = false, readOnly = false,
@@ -57,13 +56,6 @@ const FileInput = ({
)} )}
> >
{label} {label}
{required && (
<>
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label> </label>
)} )}
+37 -104
View File
@@ -8,13 +8,10 @@ import Button, { ButtonProps } from '@/components/Button';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export type IconPosition = 'left' | 'center' | 'right';
export interface ConfirmationModalProps { export interface ConfirmationModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
type?: 'info' | 'success' | 'error'; type?: 'info' | 'success' | 'error';
text?: string; text?: string;
subtitleText?: string;
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
primaryButton?: ButtonProps & { primaryButton?: ButtonProps & {
text?: string; text?: string;
@@ -27,84 +24,17 @@ export interface ConfirmationModalProps {
modalBox?: string; modalBox?: string;
}; };
children?: React.ReactNode; children?: React.ReactNode;
iconSize?: number;
iconPosition?: IconPosition;
} }
const iconConfig = {
info: {
icon: 'material-symbols:info-outline-rounded',
iconClassName: 'text-info-content',
bgClassName: 'bg-info',
outerRingClassName: 'bg-info/20',
borderClassName: 'border-info',
},
success: {
icon: 'heroicons:check',
iconClassName: 'text-white',
bgClassName: 'bg-[#00D390]',
outerRingClassName: 'bg-[#00D3901F]',
borderClassName: 'border-[#CCF7EB]',
},
error: {
icon: 'solar:danger-triangle-linear',
iconClassName: 'text-error-content',
bgClassName: 'bg-[#f03338]',
outerRingClassName: 'bg-[#f3cdcd]',
borderClassName: 'border-[#fff0ef]',
},
} as const;
const ConfirmationModalIcon = ({
type,
size = 24,
}: {
type: 'info' | 'success' | 'error';
size?: number;
}) => {
const config = iconConfig[type];
return (
<div className='flex items-center justify-center p-2'>
<div
className={cn(
'rounded-full border-4 p-1',
config.outerRingClassName,
config.borderClassName
)}
>
<div className={cn('rounded-full p-1', config.outerRingClassName)}>
<div
className={cn(
'rounded-full p-3 flex items-center justify-center',
config.bgClassName
)}
>
<Icon
icon={config.icon}
width={size}
height={size}
className={config.iconClassName}
/>
</div>
</div>
</div>
</div>
);
};
const ConfirmationModal = ({ const ConfirmationModal = ({
ref, ref,
type = 'info', type = 'info',
text, text,
subtitleText,
closeOnBackdrop, closeOnBackdrop,
primaryButton, primaryButton,
secondaryButton, secondaryButton,
className, className,
children, children,
iconSize = 32,
iconPosition = 'center',
}: ConfirmationModalProps) => { }: ConfirmationModalProps) => {
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false); const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
@@ -125,52 +55,55 @@ const ConfirmationModal = ({
return ( return (
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}> <Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
<div className='w-full flex flex-col gap-4'> <div className='w-full flex flex-col gap-4'>
{iconPosition === 'center' ? ( <div
<> className={cn(
<div className='w-fit mx-auto'> 'w-fit p-4 mx-auto flex flex-row justify-center items-center rounded-full',
<ConfirmationModalIcon type={type} size={iconSize} /> {
'bg-error': type === 'error',
'bg-info': type === 'info',
'bg-success': type === 'success',
}
)}
>
{type === 'info' && (
<Icon
icon='material-symbols:info-outline-rounded'
width={64}
height={64}
className='text-info-content'
/>
)}
{type === 'success' && (
<Icon
icon='qlementine-icons:success-12'
width={64}
height={64}
className='text-success-content'
/>
)}
{type === 'error' && (
<Icon
icon='solar:danger-triangle-linear'
width={64}
height={64}
className='text-error-content'
/>
)}
</div> </div>
<p className='text-center font-medium'> <p className='text-center font-medium'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'} {text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p> </p>
{subtitleText && (
<p className='text-center text-sm text-gray-400'>
{subtitleText}
</p>
)}
</>
) : (
<div
className={cn('flex flex-row items-center gap-4', {
'flex-row': iconPosition === 'left',
'flex-row-reverse': iconPosition === 'right',
})}
>
<div className='w-fit'>
<ConfirmationModalIcon type={type} size={iconSize} />
</div>
<div className='flex flex-col gap-1'>
<p className='font-medium'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p>
{subtitleText && (
<p className='text-sm text-gray-400'>{subtitleText}</p>
)}
</div>
</div>
)}
{children && <div className='w-full'>{children}</div>} {children && <div className='w-full'>{children}</div>}
<div className='w-full flex flex-row gap-2'> <div className='w-full flex flex-row gap-2'>
{secondaryButton && secondaryButton.text && ( {secondaryButton && secondaryButton.text && (
<Button <Button
{...secondaryButton} {...secondaryButton}
variant='outline' variant='ghost'
color={secondaryButton?.color} color={secondaryButton?.color}
isLoading={secondaryButton?.isLoading} isLoading={secondaryButton?.isLoading}
disabled={ disabled={
+1 -1
View File
@@ -309,7 +309,7 @@ const useApprovalSteps = ({
moduleId: string; moduleId: string;
params?: { params?: {
page?: number; page?: number;
limit: number | string; limit: number;
search?: string; search?: string;
group_step_number?: boolean; group_step_number?: boolean;
}; };
+4 -28
View File
@@ -19,16 +19,12 @@ import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverhea
import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent'; import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent';
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable'; import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable';
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
interface ClosingDetailProps { interface ClosingDetailProps {
id: number; id: number;
initialValue?: ClosingGeneralInformation; initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales; salesData?: BaseClosingSales;
hppExpeditionData?: ClosingHppExpedition; hppExpeditionData?: ClosingHppExpedition;
projectData?: ProjectFlock;
kandangData?: ProjectFlockKandang;
} }
const ClosingDetail: React.FC<ClosingDetailProps> = ({ const ClosingDetail: React.FC<ClosingDetailProps> = ({
@@ -36,8 +32,6 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
initialValue, initialValue,
salesData, salesData,
hppExpeditionData, hppExpeditionData,
projectData,
kandangData,
}) => { }) => {
const [activeTab, setActiveTab] = useState<string>('sapronak'); const [activeTab, setActiveTab] = useState<string>('sapronak');
@@ -51,12 +45,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
{ {
id: 'perhitunganSapronak', id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak', label: 'Perhitungan Sapronak',
content: ( content: <ClosingSapronakCalculationTabContent projectFlockId={id} />,
<ClosingSapronakCalculationTabContent
closingGeneralInformation={initialValue}
projectFlockId={id}
/>
),
}, },
{ {
id: 'penjualan', id: 'penjualan',
@@ -93,9 +82,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
<section className='w-full max-w-7xl pb-16'> <section className='w-full max-w-7xl pb-16'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href={ href='/closing'
!kandangData ? '/closing' : `/closing/detail/?closingId=${id}`
}
variant='link' variant='link'
className='w-fit p-0 text-primary' className='w-fit p-0 text-primary'
> >
@@ -106,18 +93,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
<h1 className='text-2xl font-bold text-center'>Detail Closing</h1> <h1 className='text-2xl font-bold text-center'>Detail Closing</h1>
</header> </header>
<ClosingGeneralInformationTable <ClosingGeneralInformationTable initialValue={initialValue} />
initialValue={initialValue}
projectData={projectData}
kandangData={kandangData}
/>
{!kandangData && (
<ClosingKandangList
initialValue={initialValue}
projectData={projectData}
/>
)}
<Tabs <Tabs
activeTabId={activeTab} activeTabId={activeTab}
@@ -23,14 +23,6 @@ type HppTableRow =
type?: never; type?: never;
budgeting?: never; budgeting?: never;
realization?: never; realization?: never;
}
| {
type: string;
group_name: string;
group_index: number;
isGroupHeader: false;
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
}; };
type ProfitLossTableRow = type ProfitLossTableRow =
@@ -60,117 +52,25 @@ const ClosingFinanceTable = ({
() => ClosingApi.getFinance(projectFlockId) () => ClosingApi.getFinance(projectFlockId)
); );
const staticHppRows: Array<{ const hppTableData: HppTableRow[] = isResponseSuccess(finance)
group_name: string; ? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [
type: string; // Group header row
group_index: number;
}> = [
{ {
group_name: 'HPP dan Pengeluaran', group_name: hpp.group_name,
type: 'Pembelian PAKAN', group_index: groupIndex,
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian STARTER',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian DOC',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian PULLET',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian LAYER',
group_index: 0,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Pengeluaran Overhead',
group_index: 1,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Beban Ekspedisi',
group_index: 1,
},
];
const hppTableData: HppTableRow[] = [
{
group_name: 'HPP dan Pengeluaran',
group_index: 0,
isGroupHeader: true as const, isGroupHeader: true as const,
}, },
...staticHppRows // Data rows
.filter((row) => row.group_index === 0) ...hpp.data.map((item) => ({
.map((staticRow) => { group_name: hpp.group_name,
const apiData = isResponseSuccess(finance) group_index: groupIndex,
? finance.data.hpp_purchases.hpp type: item.type,
.find((g) => g.group_name === staticRow.group_name) budgeting: item.budgeting,
?.data.find((d) => d.type === staticRow.type) realization: item.realization,
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const, isGroupHeader: false as const,
}; })),
}), ])
{ : [];
group_name: 'HPP dan Bahan Baku',
group_index: 1,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 1)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const,
};
}),
{
group_name: 'HPP',
group_index: 2,
isGroupHeader: true as const,
},
];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance) const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [ ? [
@@ -317,8 +217,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_bird' && return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp_purchases.summary_hpp.budgeting
?.rp_per_bird || 0 .rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -333,8 +233,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_kg' && return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp_purchases.summary_hpp.budgeting
?.rp_per_kg || 0 .rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -349,8 +249,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_amount' && return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp_purchases.summary_hpp.budgeting
?.amount || 0 .amount || 0
) )
: '-'; : '-';
}, },
@@ -371,8 +271,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_bird' && return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp_purchases.summary_hpp.realization
?.realization?.rp_per_bird || 0 .rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -387,8 +287,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_kg' && return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp_purchases.summary_hpp.realization
?.realization?.rp_per_kg || 0 .rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -403,8 +303,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_amount' && return props.column.id === 'realization_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp_purchases.summary_hpp.realization
?.realization?.amount || 0 .amount || 0
) )
: '-'; : '-';
}, },
@@ -1,29 +1,12 @@
import { ClosingGeneralInformation } from '@/types/api/closing'; import { ClosingGeneralInformation } from '@/types/api/closing';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { useMemo } from 'react';
interface ClosingGeneralInformationProps { interface ClosingGeneralInformationProps {
initialValue?: ClosingGeneralInformation; initialValue?: ClosingGeneralInformation;
projectData?: ProjectFlock;
kandangData?: ProjectFlockKandang;
} }
const ClosingGeneralInformationTable = ({ const ClosingGeneralInformationTable = ({
initialValue, initialValue,
projectData,
kandangData,
}: ClosingGeneralInformationProps) => { }: ClosingGeneralInformationProps) => {
const chickinPopulation = useMemo(() => {
if (kandangData) {
return kandangData?.chickins?.reduce(
(acc, chickin) => acc + chickin.usage_qty,
0
);
}
return 0;
}, [kandangData]);
return ( return (
<div className='w-full my-4 @container'> <div className='w-full my-4 @container'>
<div className='flex flex-col @sm:flex-row gap-4'> <div className='flex flex-col @sm:flex-row gap-4'>
@@ -34,9 +17,7 @@ const ClosingGeneralInformationTable = ({
<tr> <tr>
<td>Lokasi</td> <td>Lokasi</td>
<td>:</td> <td>:</td>
<td> <td>{initialValue?.location_name}</td>
{initialValue?.location_name ?? projectData?.location?.name}
</td>
</tr> </tr>
<tr> <tr>
<td>Periode</td> <td>Periode</td>
@@ -44,22 +25,14 @@ const ClosingGeneralInformationTable = ({
<td>{initialValue?.period}</td> <td>{initialValue?.period}</td>
</tr> </tr>
<tr> <tr>
<td>Project Flock</td> <td>Kategori</td>
<td>:</td> <td>:</td>
<td> <td>{initialValue?.project_category}</td>
{initialValue?.project_flock?.name ??
projectData?.flock_name}
</td>
</tr> </tr>
<tr> <tr>
<td>Populasi</td> <td>Populasi</td>
<td>:</td> <td>:</td>
<td> <td>{initialValue?.population} Ekor</td>
{!kandangData
? (initialValue?.population ?? 0)
: (chickinPopulation ?? 0)}{' '}
Ekor
</td>
</tr> </tr>
<tr> <tr>
<td>Jenis Project</td> <td>Jenis Project</td>
@@ -67,13 +40,9 @@ const ClosingGeneralInformationTable = ({
<td>{initialValue?.project_type}</td> <td>{initialValue?.project_type}</td>
</tr> </tr>
<tr className='table-row @sm:hidden'> <tr className='table-row @sm:hidden'>
<td>Kandang {!kandangData && 'Aktif'}</td> <td>Kandang Aktif</td>
<td>:</td> <td>:</td>
<td> <td>{initialValue?.active_house_count} Kandang</td>
{!kandangData
? `${initialValue?.active_house_count} Kandang`
: kandangData?.kandang?.name}
</td>
</tr> </tr>
<tr className='table-row @sm:hidden'> <tr className='table-row @sm:hidden'>
<td>Status Pembayaran Penjualan</td> <td>Status Pembayaran Penjualan</td>
@@ -100,13 +69,9 @@ const ClosingGeneralInformationTable = ({
<table className='table table-zebra table-sm'> <table className='table table-zebra table-sm'>
<tbody> <tbody>
<tr> <tr>
<td>Kandang {!kandangData && 'Aktif'}</td> <td>Kandang Aktif</td>
<td>:</td> <td>:</td>
<td> <td>{initialValue?.active_house_count} Kandang</td>
{!kandangData
? `${initialValue?.active_house_count} Kandang`
: kandangData?.kandang?.name}
</td>
</tr> </tr>
<tr> <tr>
<td>Status Pembayaran Penjualan</td> <td>Status Pembayaran Penjualan</td>
@@ -1,37 +0,0 @@
import Button from '@/components/Button';
import { ClosingGeneralInformation } from '@/types/api/closing';
import { ProjectFlock } from '@/types/api/production/project-flock';
const ClosingKandangList = ({
initialValue,
projectData,
}: {
initialValue?: ClosingGeneralInformation;
projectData?: ProjectFlock;
}) => {
return (
<div className='w-full my-4 @container'>
<div className='flex flex-col @sm:flex-row gap-4'>
<div className='w-full'>
<div className='overflow-x-auto'>
<h1 className='font-bold my-4'>Kandang</h1>
<div className='flex flex-wrap gap-2 mb-4'>
{projectData?.kandangs?.map((kandang) => (
<Button
key={kandang.id}
variant='outline'
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
className='min-w-32'
>
{kandang.name}
</Button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default ClosingKandangList;
@@ -96,6 +96,11 @@ const ClosingProductionDataTabContent = ({
value={formatNumber(purchase.feed_used)} value={formatNumber(purchase.feed_used)}
unit='Kg' unit='Kg'
/> />
<DataRow
label='Pakan Terpakai per Ekor'
value={formatNumber(purchase.feed_used_per_head)}
unit='Kg'
/>
</div> </div>
</section> </section>
@@ -119,12 +124,14 @@ const ClosingProductionDataTabContent = ({
/> />
<DataRow <DataRow
label='Bobot Rata-Rata' label='Bobot Rata-Rata'
value={formatNumber(sales.chicken.avg_weight)} value={formatNumber(sales.chicken.average_weight)}
unit='Kg/Ekor' unit='Kg/Ekor'
/> />
<DataRow <DataRow
label='Harga Jual Rata-Rata' label='Harga Jual Rata-Rata'
value={formatNumber(sales.chicken.avg_selling_price)} value={formatNumber(
sales.chicken.chicken_average_selling_price
)}
unit='Rupiah' unit='Rupiah'
/> />
</div> </div>
@@ -141,17 +148,17 @@ const ClosingProductionDataTabContent = ({
/> />
<DataRow <DataRow
label='Telur (Kg)' label='Telur (Kg)'
value={formatNumber(sales.egg.egg_mass)} value={formatNumber(sales.egg.egg_mass_kg)}
unit='Kg' unit='Kg'
/> />
<DataRow <DataRow
label='Berat Telur Rata-Rata' label='Berat Telur Rata-Rata'
value={formatNumber(sales.egg.avg_egg_weight)} value={formatNumber(sales.egg.average_egg_weight_kg)}
unit='Kg' unit='Kg'
/> />
<DataRow <DataRow
label='Harga Jual Telur Rata-Rata' label='Harga Jual Telur Rata-Rata'
value={formatNumber(sales.egg.avg_selling_price)} value={formatNumber(sales.egg.egg_average_selling_price)}
unit='Rupiah' unit='Rupiah'
/> />
</div> </div>
@@ -184,37 +191,17 @@ const ClosingProductionDataTabContent = ({
/> />
<DataRow <DataRow
label='Mortalitas Std' label='Mortalitas Std'
value={formatNumber(performance.mor_std)} value={formatNumber(performance.mortality_std)}
unitClassName='hidden' unitClassName='hidden'
/> />
<DataRow <DataRow
label='Mortalitas Act' label='Mortalitas Act'
value={formatNumber(performance.mor_act)} value={formatNumber(performance.mortality_act)}
unitClassName='hidden' unitClassName='hidden'
/> />
<DataRow <DataRow
label='DEFF Mortalitas' label='DEFF Mortalitas'
value={formatNumber(performance.mor_diff)} value={formatNumber(performance.deff_mortality)}
unitClassName='hidden'
/>
<DataRow
label='AWG Std'
value={formatNumber(performance.awg_std)}
unit='Gr/Hari'
/>
<DataRow
label='AWG Act'
value={formatNumber(performance.awg_act)}
unit='Gr/Hari'
/>
<DataRow
label='Feed Intake Std'
value={formatNumber(performance.feed_intake_std)}
unitClassName='hidden'
/>
<DataRow
label='Feed Intake Act'
value={formatNumber(performance.feed_intake)}
unitClassName='hidden' unitClassName='hidden'
/> />
<DataRow <DataRow
@@ -229,70 +216,14 @@ const ClosingProductionDataTabContent = ({
/> />
<DataRow <DataRow
label='DEFF FCR' label='DEFF FCR'
value={formatNumber(performance.fcr_diff)} value={formatNumber(performance.deff_fcr)}
unitClassName='hidden' unitClassName='hidden'
/> />
{/* Laying Specific Fields */}
{performance.hen_day_act !== undefined && (
<>
<DataRow <DataRow
label='Hen Day Std' label='AWG'
value={formatNumber(performance.hen_day_std!)} value={formatNumber(performance.awg)}
unit='%' unit='Gr/Hari'
/> />
<DataRow
label='Hen Day Act'
value={formatNumber(performance.hen_day_act)}
unit='%'
/>
</>
)}
{performance.egg_mass !== undefined && (
<>
<DataRow
label='Egg Mass Std'
value={formatNumber(performance.egg_mass_std!)}
unit='Kg'
/>
<DataRow
label='Egg Mass Act'
value={formatNumber(performance.egg_mass)}
unit='Kg'
/>
</>
)}
{performance.egg_weight !== undefined && (
<>
<DataRow
label='Egg Weight Std'
value={formatNumber(performance.egg_weight_std!)}
unit='Gr'
/>
<DataRow
label='Egg Weight Act'
value={formatNumber(performance.egg_weight)}
unit='Gr'
/>
</>
)}
{performance.hen_housed_act !== undefined && (
<>
<DataRow
label='Hen Housed Std'
value={formatNumber(performance.hen_housed_std!)}
unit='%'
/>
<DataRow
label='Hen Housed Act'
value={formatNumber(performance.hen_housed_act)}
unit='%'
/>
</>
)}
</div> </div>
</section> </section>
</div> </div>
@@ -1,25 +1,21 @@
'use client'; 'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable'; import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingSapronakCalculationTabContentProps { interface ClosingSapronakCalculationTabContentProps {
projectFlockId?: number; projectFlockId?: number;
closingGeneralInformation?: ClosingGeneralInformation;
} }
const ClosingSapronakCalculationTabContent = ({ const ClosingSapronakCalculationTabContent = ({
projectFlockId, projectFlockId,
closingGeneralInformation,
}: ClosingSapronakCalculationTabContentProps) => { }: ClosingSapronakCalculationTabContentProps) => {
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{projectFlockId && ( {projectFlockId && (
<> <>
<ClosingSapronakCalculationTable <ClosingSapronakCalculationTable projectFlockId={projectFlockId} />
closingGeneralInformation={closingGeneralInformation}
projectFlockId={projectFlockId}
/>
</> </>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import Card from '@/components/Card'; import Card from '@/components/Card';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { import {
RowSapronakCalculation, RowSapronakCalculation,
TotalSapronakCalculation, TotalSapronakCalculation,
@@ -13,24 +13,19 @@ import { useMemo } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { ClosingGeneralInformation } from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
interface ClosingSapronakCalculationTableProps { interface ClosingSapronakCalculationTableProps {
type?: 'detail';
projectFlockId: number; projectFlockId: number;
closingGeneralInformation?: ClosingGeneralInformation;
} }
const ClosingSapronakCalculationTable = ({ const ClosingSapronakCalculationTable = ({
type,
projectFlockId, projectFlockId,
closingGeneralInformation,
}: ClosingSapronakCalculationTableProps) => { }: ClosingSapronakCalculationTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: sapronakCalculation, isLoading } = useSWR( const { data: sapronakCalculation, isLoading } = useSWR(
`/closing/sapronak-calculation/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`, `/closing/sapronak-calculation/${projectFlockId}`,
() => ClosingApi.getPerhitunganSapronak(projectFlockId, Number(kandangId)), () => ClosingApi.getPerhitunganSapronak(projectFlockId),
{ {
keepPreviousData: true, keepPreviousData: true,
} }
@@ -42,121 +37,101 @@ const ClosingSapronakCalculationTable = ({
): ColumnDef<RowSapronakCalculation>[] => [ ): ColumnDef<RowSapronakCalculation>[] => [
{ {
header: 'Tanggal', header: 'Tanggal',
accessorKey: 'date', accessorKey: 'tanggal',
cell: (props) => cell: (props) => (props.getValue() as string) || '-',
props.row.original.date
? formatDate(props.row.original.date, 'DD MMM YYYY')
: '-',
footer: 'Total', footer: 'Total',
}, },
{ {
header: 'No. Referensi', header: 'No. Referensi',
accessorKey: 'reference_number', accessorKey: 'no_referensi',
cell: (props) => (props.row.original.reference_number as string) || '-', cell: (props) => (props.getValue() as string) || '-',
footer: '', footer: '',
}, },
{ {
header: 'QTY Masuk', header: 'QTY Masuk',
accessorKey: 'qty_in', accessorKey: 'qty_masuk',
cell: (props) => cell: (props) => formatNumber(props.getValue() as number),
props.row.original.qty_in
? formatNumber(props.row.original.qty_in as number)
: '0',
footer: total footer: total
? () => ( ? () => (
<div className='font-semibold text-gray-900'> <div className='font-semibold text-gray-900'>
{total?.qty_in ? formatNumber(total?.qty_in) : '0'} {formatNumber(total.qty_masuk)}
</div> </div>
) )
: '', : '',
}, },
{ {
header: 'QTY Keluar', header: 'QTY Keluar',
accessorKey: 'qty_out', accessorKey: 'qty_keluar',
cell: (props) => cell: (props) => formatNumber(props.getValue() as number),
props.row.original.qty_out
? formatNumber(props.row.original.qty_out as number)
: '0',
footer: total footer: total
? () => ( ? () => (
<div className='font-semibold text-gray-900'> <div className='font-semibold text-gray-900'>
{total?.qty_out ? formatNumber(total?.qty_out) : '0'} {formatNumber(total.qty_keluar)}
</div> </div>
) )
: '', : '',
}, },
{ {
header: 'QTY Pakai', header: 'QTY Pakai',
accessorKey: 'qty_used', accessorKey: 'qty_pakai',
cell: (props) => cell: (props) => formatNumber(props.getValue() as number),
props.row.original.qty_used
? formatNumber(props.row.original.qty_used as number)
: '0',
footer: total footer: total
? () => ( ? () => (
<div className='font-semibold text-gray-900'> <div className='font-semibold text-gray-900'>
{total?.qty_used ? formatNumber(total?.qty_used) : '0'} {formatNumber(total.qty_pakai)}
</div> </div>
) )
: '', : '',
}, },
{ {
header: 'Uraian', header: 'Uraian',
accessorKey: 'description', accessorKey: 'uraian',
cell: (props) => (props.row.original.description as string) || '-', cell: (props) => (props.getValue() as string) || '-',
footer: '', footer: '',
}, },
{ {
header: 'Kategori Produk', header: 'Kategori Produk',
accessorKey: 'product_category', accessorKey: 'kategori_produk',
cell: (props) => (props.row.original.product_category as string) || '-', cell: (props) => (props.getValue() as string) || '-',
footer: '', footer: '',
}, },
{ {
header: 'Harga Beli/Qty (Rp)', header: 'Harga Beli/Qty (Rp)',
accessorKey: 'unit_price', accessorKey: 'harga_beli_per_qty',
cell: (props) => cell: (props) => formatCurrency(props.getValue() as number),
props.row.original.unit_price
? formatCurrency(props.row.original.unit_price as number)
: '-',
footer: total footer: total
? () => ( ? () => (
<div className='font-semibold text-gray-900'> <div className='font-semibold text-gray-900'>
{total?.avg_unit_price {formatCurrency(total.harga_beli_per_qty)}
? formatCurrency(total?.avg_unit_price)
: '-'}
</div> </div>
) )
: '', : '',
}, },
{ {
header: 'Total Harga (Rp)', header: 'Total Harga (Rp)',
accessorKey: 'total_amount', accessorKey: 'total_harga',
cell: (props) => cell: (props) => formatCurrency(props.getValue() as number),
props.row.original.total_amount
? formatCurrency(props.row.original.total_amount as number)
: '-',
footer: total footer: total
? () => ( ? () => (
<div className='font-semibold text-gray-900'> <div className='font-semibold text-gray-900'>
{total?.total_amount ? formatCurrency(total?.total_amount) : '-'} {formatCurrency(total.total_harga)}
</div> </div>
) )
: '', : '',
}, },
{ {
header: 'Keterangan', header: 'Keterangan',
accessorKey: 'notes', accessorKey: 'keterangan',
cell: (props) => (props.row.original.notes as string) || '-', cell: (props) => (props.getValue() as string) || '-',
footer: '', footer: '',
}, },
]; ];
// Memoize columns untuk setiap kategori // Memoize columns untuk setiap kategori
const docColumns = useMemo( const docBroilerColumns = useMemo(
() => () =>
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.doc?.total) ? createColumns(sapronakCalculation.data?.doc_broiler.total)
: createColumns(), : createColumns(),
[sapronakCalculation] [sapronakCalculation]
); );
@@ -164,7 +139,7 @@ const ClosingSapronakCalculationTable = ({
const ovkColumns = useMemo( const ovkColumns = useMemo(
() => () =>
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.ovk?.total) ? createColumns(sapronakCalculation.data?.ovk.total)
: createColumns(), : createColumns(),
[sapronakCalculation] [sapronakCalculation]
); );
@@ -172,20 +147,15 @@ const ClosingSapronakCalculationTable = ({
const pakanColumns = useMemo( const pakanColumns = useMemo(
() => () =>
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.pakan?.total) ? createColumns(sapronakCalculation.data?.pakan.total)
: createColumns(), : createColumns(),
[sapronakCalculation] [sapronakCalculation]
); );
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
{/* Table DOC jika kategori Project Flock Growing */}
<Card <Card
title={ title='DOC Broiler'
closingGeneralInformation?.project_type == 'GROWING'
? 'DOC'
: 'Pullet'
}
collapsible collapsible
defaultCollapsed={false} defaultCollapsed={false}
className={{ className={{
@@ -196,17 +166,14 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation> <Table<RowSapronakCalculation>
data={ data={
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.doc?.rows ?? []) ? (sapronakCalculation.data?.doc_broiler.rows ?? [])
: [] : []
} }
columns={docColumns} columns={docBroilerColumns}
className={{ className={{
containerClassName: 'my-4', containerClassName: 'my-4',
}} }}
renderFooter={ renderFooter={isResponseSuccess(sapronakCalculation)}
isResponseSuccess(sapronakCalculation) &&
sapronakCalculation.data?.doc?.rows.length > 0
}
/> />
</Card> </Card>
@@ -222,17 +189,14 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation> <Table<RowSapronakCalculation>
data={ data={
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.ovk?.rows ?? []) ? (sapronakCalculation.data?.ovk.rows ?? [])
: [] : []
} }
columns={ovkColumns} columns={ovkColumns}
className={{ className={{
containerClassName: 'my-4', containerClassName: 'my-4',
}} }}
renderFooter={ renderFooter={isResponseSuccess(sapronakCalculation)}
isResponseSuccess(sapronakCalculation) &&
sapronakCalculation.data?.ovk?.rows.length > 0
}
/> />
</Card> </Card>
@@ -248,17 +212,14 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation> <Table<RowSapronakCalculation>
data={ data={
isResponseSuccess(sapronakCalculation) isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.pakan?.rows ?? []) ? (sapronakCalculation.data?.pakan.rows ?? [])
: [] : []
} }
columns={pakanColumns} columns={pakanColumns}
className={{ className={{
containerClassName: 'my-4', containerClassName: 'my-4',
}} }}
renderFooter={ renderFooter={isResponseSuccess(sapronakCalculation)}
isResponseSuccess(sapronakCalculation) &&
sapronakCalculation.data?.pakan?.rows.length > 0
}
/> />
</Card> </Card>
</div> </div>
@@ -126,6 +126,28 @@ const ClosingsTable = () => {
accessorKey: 'shed_label', accessorKey: 'shed_label',
header: 'Jumlah Kandang', header: 'Jumlah Kandang',
}, },
{
accessorKey: 'sales_paid_amount',
header: 'Jumlah Sudah Bayar',
cell: (props) => (
<span className='text-success'>
{formatCurrency(props.row.original.sales_paid_amount)}
</span>
),
},
{
accessorKey: 'sales_remaining_amount',
header: 'Jumlah Sisa Bayar',
cell: (props) => (
<span className='text-error'>
{formatCurrency(props.row.original.sales_remaining_amount)}
</span>
),
},
{
accessorKey: 'sales_payment_status',
header: 'Status Pembayaran',
},
{ {
accessorKey: 'project_status', accessorKey: 'project_status',
header: 'Status', header: 'Status',
@@ -215,31 +215,31 @@ const SalesReportTable = ({
return kandang?.name || '-'; return kandang?.name || '-';
}, },
}, },
// { {
// id: 'payment_status', id: 'payment_status',
// accessorKey: 'payment_status', accessorKey: 'payment_status',
// header: 'Status Pembayaran', header: 'Status Pembayaran',
// cell: (props) => { cell: (props) => {
// const status = props.getValue() as string; const status = props.getValue() as string;
// const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
// if (!status) return 'neutral'; if (!status) return 'neutral';
// switch (status.toLowerCase()) { switch (status.toLowerCase()) {
// case 'paid': case 'paid':
// return 'success'; return 'success';
// case 'tempo': case 'tempo':
// return 'warning'; return 'warning';
// default: default:
// return 'neutral'; return 'neutral';
// } }
// }; };
// return ( return (
// <Badge variant='soft' size='sm' color={getStatusColor(status)}> <Badge variant='soft' size='sm' color={getStatusColor(status)}>
// {status || '-'} {status || '-'}
// </Badge> </Badge>
// ); );
// }, },
// }, },
], ],
[] []
); );
@@ -1,491 +0,0 @@
'use client';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useState } from 'react';
import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import {
DashboardFilterType,
getDashboardFilterSchema,
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import {
DashboardFilter,
DashboardMeta,
} from '@/types/api/dashboard/dashboard';
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
// Helper function to normalize values to array
const normalizeToArray = (
value: OptionType | OptionType[] | null | undefined
): number[] => {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((v) => Number(v.value));
}
return [Number(value.value)];
};
const DashboardProduction = () => {
const filterModal = useModal();
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
'OVERVIEW'
);
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
// ===== FETCH DATA =====
const {
data: dashboardProductionResponse,
isLoading: isLoadingDashboardProductionData,
mutate: refreshDashboardProductionData,
} = useSWR(endpointUrl, () =>
DashboardApi.getDashboardProductionFetcher(endpointUrl)
);
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.data
: undefined;
// ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } =
useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' },
{ value: 'KANDANG', label: 'Kandang' },
];
// ===== FORMIK =====
const formik = useFormik({
initialValues: {
startDate: '',
endDate: '',
flock: [] as OptionType[],
location: [] as OptionType[],
kandang: [] as OptionType[],
analysisMode: analysisMode,
comparisonType: '',
lokasiIds: [],
flockIds: [],
kandangIds: [],
} as DashboardFilterType,
validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => {
console.log(values);
handleApplyFilter({
start_date: values.startDate || '',
end_date: values.endDate || '',
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(values.location),
flock_ids: normalizeToArray(values.flock),
kandang_ids: normalizeToArray(values.kandang),
comparison_type: values.comparisonType,
});
},
});
const handleResetFilter = () => {
formik.resetForm();
setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
};
const handleApplyFilter = (values: DashboardFilter) => {
console.log(values);
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
if (values.start_date) params.start_date = values.start_date;
if (values.end_date) params.end_date = values.end_date;
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
if (values.location_ids.length > 0)
params.location_ids = values.location_ids.toString();
if (values.flock_ids.length > 0)
params.flock_ids = values.flock_ids.toString();
if (values.kandang_ids.length > 0)
params.kandang_ids = values.kandang_ids.toString();
if (values.comparison_type) params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
console.log(endpointUrl);
filterModal.closeModal();
refreshDashboardProductionData();
formik.resetForm();
};
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
);
}
return (
<>
<section className='w-full p-4 space-y-6'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
<div></div>
<div className='flex flex-row justify-end gap-2'>
<Button
variant='outline'
className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg'
>
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} />
Export
<Icon icon='heroicons:chevron-down' width={20} height={20} />
</Button>
</div>
</div>
{/* Dashboard Stats */}
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} />
{/* Use DashboardLineChart component or skeleton */}
{isLoadingDashboardProductionData ? (
<DashboardLineChartSkeleton />
) : dashboardProductionData &&
dashboardProductionData.charts &&
Object.keys(dashboardProductionData.charts).length > 0 ? (
<DashboardLineChart
analysisMode={
isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.meta
? (
dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode
}
data={dashboardProductionData}
/>
) : (
<DashboardLineChartSkeleton
meta={
isResponseSuccess(dashboardProductionResponse)
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
: undefined
}
/>
)}
</section>
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-xl',
}}
>
<div className='space-y-6'>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300'>
<div className='flex items-center gap-2 ms-4'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={() => filterModal.closeModal()}
className='text-gray-500 hover:text-gray-700 me-4 '
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form
className='space-y-4'
onSubmit={handleFormSubmit}
onReset={handleResetFilter}
>
{/* Rentang Waktu */}
<div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label>
<div className='flex items-center gap-2'>
<DateInput
name='startDate'
placeholder='Tanggal Mulai'
value={formik.values.startDate}
errorMessage={formik.errors.startDate}
onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={
Boolean(formik.errors.startDate) &&
Boolean(formik.touched.startDate)
}
/>
<span className='hidden md:block text-center'></span>
<DateInput
name='endDate'
placeholder='Tanggal Akhir'
value={formik.values.endDate}
errorMessage={formik.errors.endDate}
onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={
Boolean(formik.errors.endDate) &&
Boolean(formik.touched.endDate)
}
/>
</div>
</div>
{/* Analysis Mode */}
<div className='px-4'>
<label className='block mb-3'>Analysis Mode</label>
<RadioGroup
name='analysisMode'
value={formik.values.analysisMode}
onChange={(e) => {
formik.handleChange(e);
setAnalysisMode(e.target.value as 'OVERVIEW' | 'COMPARISON');
// Reset all dependent fields when analysis mode changes
formik.setFieldValue('location', []);
formik.setFieldValue('flock', []);
formik.setFieldValue('kandang', []);
formik.setFieldValue('comparisonType', '');
setSelectedLocationIds([]);
}}
color='primary'
className={{
wrapper: 'w-full my-6 font-semibold text-neutral-500',
}}
>
<RadioGroupItem
color='primary'
value='OVERVIEW'
label='Performance Overview'
/>
<RadioGroupItem
color='primary'
value='COMPARISON'
label='Performance Comparison'
/>
</RadioGroup>
</div>
{formik.values.analysisMode === 'COMPARISON' && (
<div className='px-4'>
<SelectInput
label='Compared By'
value={comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType
)}
onChange={(selected) =>
formik.setFieldValue(
'comparisonType',
selected ? (selected as OptionType).value : ''
)
}
errorMessage={formik.errors.comparisonType as string}
options={comparisonTypeOptions}
isLoading={isLoadingLocationOptions}
isError={
Boolean(formik.errors.comparisonType) &&
Boolean(formik.touched.comparisonType)
}
/>
</div>
)}
{/* Location */}
<div className='px-4'>
<SelectInput
label='Farm'
value={formik.values.location}
onChange={(selected) => {
formik.setFieldValue('location', selected);
// Update selectedLocationIds for kandang filter
setSelectedLocationIds(normalizeToArray(selected));
// Reset dependent fields when location changes
formik.setFieldValue('flock', []);
formik.setFieldValue('kandang', []);
}}
errorMessage={formik.errors.location as string}
options={locationOptions}
isLoading={isLoadingLocationOptions}
isMulti={
comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType
)?.value === 'FARM'
}
isError={
Boolean(formik.errors.location) &&
Boolean(formik.touched.location)
}
/>
</div>
{/* Flock */}
{!(
formik.values.analysisMode === 'COMPARISON' &&
!(
formik.values.comparisonType === 'FLOCK' ||
formik.values.comparisonType === 'KANDANG'
)
) && (
<div className='px-4'>
<SelectInput
label='Flock'
value={formik.values.flock}
onChange={(selected) =>
formik.setFieldValue('flock', selected)
}
errorMessage={formik.errors.flock as string}
options={flockOptions}
isLoading={isLoadingFlockOptions}
isMulti={
comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType
)?.value === 'FLOCK'
}
isError={
Boolean(formik.errors.flock) &&
Boolean(formik.touched.flock)
}
/>
</div>
)}
{/* Kandang */}
{!(
formik.values.analysisMode === 'COMPARISON' &&
!(formik.values.comparisonType === 'KANDANG')
) && (
<div className='px-4'>
<SelectInput
label='Kandang'
value={formik.values.kandang}
onChange={(selected) =>
formik.setFieldValue('kandang', selected)
}
errorMessage={formik.errors.kandang as string}
options={kandangOptions}
isLoading={isLoadingKandangOptions}
isMulti={
comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType
)?.value === 'KANDANG'
}
isError={
Boolean(formik.errors.kandang) &&
Boolean(formik.touched.kandang)
}
/>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='soft'
className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilter}
>
Reset Filter
</Button>
<Button type='submit' className='me-4 min-w-36 rounded-lg'>
Terapkan Filter
</Button>
</div>
</form>
</div>
</Modal>
</>
);
};
export default DashboardProduction;
@@ -1,545 +0,0 @@
import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import {
Dashboard,
DashboardOverviewCharts,
DashboardComparisonCharts,
DashboardChartsSeries,
DashboardChartsDataset,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { useState, useEffect } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type DashboardLineChartProps = {
analysisMode: 'OVERVIEW' | 'COMPARISON';
data: Dashboard;
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts
): charts is DashboardOverviewCharts {
return 'deplesi' in charts;
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts
): charts is DashboardComparisonCharts {
return 'location' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
body_weight: '#10B981',
std_body_weight: '#10B981',
act_laying: '#1062B9',
std_laying: '#1062B9',
act_egg_weight: '#10B981',
std_egg_weight: '#10B981',
act_feed_intake: '#F52419',
std_feed_intake: '#F52419',
act_uniformity: '#F59E0B',
std_uniformity: '#F59E0B',
act_fcr: '#10B981',
std_fcr: '#10B981',
act_fcr_cum: '#F52419',
std_fcr_cum: '#10B981',
normal: '#10B981',
abnormal: '#F52419',
act_deplesi: '#10B981',
std_deplesi: '#10B981',
};
const defaultLineColors: string[] = [
'#10B981',
'#1062B9',
'#F52419',
'#F59E0B',
'#7F56D9',
];
// Helper function to get line color
const getLineColor = (
seriesId: string | number,
index: number,
mode: 'OVERVIEW' | 'COMPARISON'
): string => {
// For COMPARISON mode, use default colors with cycling
if (mode === 'COMPARISON') {
return defaultLineColors[index % defaultLineColors.length];
}
// For OVERVIEW mode, use predefined colors or fallback to default
const predefinedColor = lineColors[seriesId];
if (predefinedColor) {
return predefinedColor;
}
// Fallback to default colors with cycling
return defaultLineColors[index % defaultLineColors.length];
};
const DashboardLineChart = ({
analysisMode,
data,
}: DashboardLineChartProps) => {
const [chartData, setChartData] =
useState<keyof DashboardOverviewCharts>('body_weight');
const [open, setOpen] = useState(false);
// Track which series are visible (by series id)
const [visibleSeries, setVisibleSeries] = useState<Set<string | number>>(
new Set()
);
// Mapping for chart type labels
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
body_weight: 'Body Weight',
performance: 'Performance',
fcr: 'FCR',
quality_control: 'Quality Control',
deplesi: 'Deplesi',
};
// Initialize all series as visible when chartData changes
useEffect(() => {
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Set all series as visible by default
const allSeriesIds = new Set(seriesData.map((s) => s.id));
setVisibleSeries(allSeriesIds);
}, [chartData, analysisMode, data.charts]);
return (
<Card
className={{
wrapper: 'w-full rounded-lg',
}}
variant='bordered'
>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
<div className='text-lg font-semibold'>
Performance{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
{analysisMode == 'OVERVIEW' && (
<Dropdown
align='end'
direction='bottom'
trigger={
<Button
variant='outline'
color='none'
className='text-neutral-500 hover:text-neutral-700 rounded-lg px-4 py-2 border-neutral-300'
onClick={() => setOpen(!open)}
>
{chartTypeLabels[chartData]}{' '}
<div className='divider divider-horizontal p-0 m-0 before:bg-neutral-300 after:bg-neutral-300'></div>
<Icon icon='heroicons:chevron-down' width={20} height={20} />
</Button>
}
className={{
content: 'w-52 mt-3',
}}
controlled={open}
>
<Menu>
<MenuItem
title='Body weight'
onClick={() => {
setChartData('body_weight');
setOpen(!open);
}}
/>
<MenuItem
title='Performance'
onClick={() => {
setChartData('performance');
setOpen(!open);
}}
/>
<MenuItem
title='FCR'
onClick={() => {
setChartData('fcr');
setOpen(!open);
}}
/>
<MenuItem
title='Quality Control'
onClick={() => {
setChartData('quality_control');
setOpen(!open);
}}
/>
<MenuItem
title='Deplesi'
onClick={() => {
setChartData('deplesi');
setOpen(!open);
}}
/>
</Menu>
</Dropdown>
)}
</div>
{/* Legend - Dynamic based on series data */}
<div className='flex flex-wrap gap-3 mb-6'>
{(() => {
// Get series data based on current mode and chartData
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData.map((series, index) => {
const isVisible = visibleSeries.has(series.id);
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
return (
<button
key={series.id}
onClick={() => {
const newVisible = new Set(visibleSeries);
if (isVisible) {
newVisible.delete(series.id);
} else {
newVisible.add(series.id);
}
setVisibleSeries(newVisible);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
isVisible
? 'border-neutral-400 bg-neutral-50'
: 'border-neutral-300 hover:bg-neutral-50'
}`}
>
<div
className={`w-6 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : ''
} ${!isVisible ? 'opacity-30' : ''}`}
style={{
backgroundColor: isStandard
? 'transparent'
: getLineColor(series.id, index, analysisMode),
borderColor: isStandard
? getLineColor(series.id, index, analysisMode)
: 'transparent',
}}
></div>
<span
className={`text-sm ${isVisible ? 'text-neutral-900 font-medium' : 'text-neutral-700'}`}
>
{series.label}
</span>
<Icon
icon='heroicons:information-circle'
width={16}
height={16}
className='text-neutral-400'
/>
</button>
);
});
})()}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={(() => {
// Transform data based on analysisMode
if (analysisMode === 'OVERVIEW') {
// For OVERVIEW mode, use the selected chart data
if (isOverviewCharts(data.charts)) {
const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset) return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (value === undefined || name === undefined) return ['', ''];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const unit = series?.unit || '';
return [`${value} ${unit}`, name];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
</Card>
);
};
export default DashboardLineChart;
@@ -1,166 +0,0 @@
import Alert from '@/components/Alert';
import Card from '@/components/Card';
import { formatNumber } from '@/lib/helper';
import { DashboardStatisticsData } from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
interface DashboardStatsProps {
data: DashboardStatisticsData[];
}
// Konfigurasi untuk setiap kartu
const CARD_CONFIG = [
{
key: 'HPP Global',
icon: 'heroicons:banknotes',
alertColor: 'warning' as const,
suffix: ' /Kg',
prefix: 'RP ',
},
{
key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const,
suffix: ' /Kg',
prefix: '',
},
{
key: 'FCR',
icon: 'heroicons:clipboard-document-list',
alertColor: 'info' as const,
suffix: '',
prefix: '',
},
{
key: 'Mortality',
icon: 'heroicons:exclamation-triangle',
alertColor: 'error' as const,
suffix: ' %',
prefix: '',
},
];
const DashboardStats = ({ data }: DashboardStatsProps) => {
// Helper to get trend icon and color
const getTrendDisplay = (percent: number) => {
const isPositive = percent >= 0;
return {
icon: isPositive
? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down',
color: isPositive ? 'text-success' : 'text-error',
value: Math.abs(percent),
};
};
// Helper to format value
const formatValue = (value: number, prefix: string, suffix: string) => {
return (
<>
{prefix}
{formatNumber(value)}
{suffix && (
<span className='text-sm font-normal text-neutral-500'>{suffix}</span>
)}
</>
);
};
return (
<div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-6'>
{CARD_CONFIG.map((config) => {
// Find matching data from API
const cardData = data.find((item) => item.label === config.key);
if (!cardData) {
// Show placeholder card for missing data (FCR & Mortality)
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4'>
<div className='text-neutral-400 font-semibold text-sm'>
From last month
</div>
<div className='text-neutral-400 font-semibold text-sm'>
Filter Required
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert variant='soft' className='rounded-lg p-3 bg-neutral-100'>
<Icon
icon={config.icon}
width={32}
height={32}
className='text-neutral-400'
/>
</Alert>
<div>
<h3 className='text-neutral-400 font-semibold text-sm'>
{config.key}
</h3>
<p className='text-2xl font-semibold text-neutral-400'>
********
</p>
</div>
</div>
</Card>
);
}
const trend = getTrendDisplay(cardData.percent_last_month);
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4'>
<div className='text-neutral-500 font-semibold text-sm'>
From last month
</div>
<div
className={`${trend.color} font-semibold flex flex-row items-center gap-1 text-sm`}
>
<Icon icon={trend.icon} width={16} height={16} />
{trend.value}%
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert
variant='soft'
color={config.alertColor}
className='rounded-lg p-3'
>
<Icon icon={config.icon} width={32} height={32} />
</Alert>
<div>
<h3 className='text-neutral-500 font-semibold text-sm'>
{cardData.label}
</h3>
<p className='text-2xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)}
</p>
</div>
</div>
</Card>
);
})}
</div>
);
};
export default DashboardStats;
@@ -1,117 +0,0 @@
import { OptionType } from '@/components/input/SelectInput';
import * as yup from 'yup';
export type DashboardFilterType = {
startDate: string;
endDate: string;
analysisMode: string;
comparisonType: string | undefined;
location: OptionType | OptionType[];
lokasiIds: number[] | undefined;
flock: OptionType | OptionType[] | undefined;
flockIds: number[] | undefined;
kandang: OptionType | OptionType[] | undefined;
kandangIds: number[] | undefined;
};
// Schema untuk mode OVERVIEW - semua field required
export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType> =
yup.object({
startDate: yup.string().required('Start date is required'),
endDate: yup.string().required('End date is required'),
analysisMode: yup.string().required('Analysis mode is required'),
comparisonType: yup.string().when('analysisMode', {
is: 'COMPARISON',
then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(),
}),
lokasiIds: yup.array().optional(),
flockIds: yup.array().optional(),
kandangIds: yup.array().optional(),
location: yup
.mixed<OptionType | OptionType[]>()
.required('Farm is required')
.test('is-not-empty', 'Farm is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
flock: yup
.mixed<OptionType | OptionType[]>()
.required('Flock is required')
.test('is-not-empty', 'Flock is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
kandang: yup
.mixed<OptionType | OptionType[]>()
.required('Kandang is required')
.test('is-not-empty', 'Kandang is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
});
// Schema untuk mode COMPARISON - conditional validation
export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterType> =
yup.object({
startDate: yup.string().required('Start date is required'),
endDate: yup.string().required('End date is required'),
analysisMode: yup.string().required('Analysis mode is required'),
comparisonType: yup.string().when('analysisMode', {
is: 'COMPARISON',
then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(),
}),
lokasiIds: yup.array().optional(),
flockIds: yup.array().optional(),
kandangIds: yup.array().optional(),
location: yup
.mixed<OptionType | OptionType[]>()
.required('Farm is required')
.test('is-not-empty', 'Farm is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
flock: yup.mixed<OptionType | OptionType[]>().when('comparisonType', {
is: (value: string) => value === 'FLOCK' || value === 'KANDANG',
then: (schema) =>
schema.test('is-required', 'Flock is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
otherwise: (schema) => schema.optional(),
}),
kandang: yup.mixed<OptionType | OptionType[]>().when('comparisonType', {
is: 'KANDANG',
then: (schema) =>
schema.test('is-required', 'Kandang is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
otherwise: (schema) => schema.optional(),
}),
});
// Helper function untuk mendapatkan schema yang sesuai berdasarkan analysis mode
export const getDashboardFilterSchema = (analysisMode?: string) => {
return analysisMode === 'OVERVIEW'
? DashboardFilterOverviewSchema
: DashboardFilterComparisonSchema;
};
// Default schema
export const DashboardFilterSchema = DashboardFilterComparisonSchema;
export type DashboardFilterValues = yup.InferType<typeof DashboardFilterSchema>;
@@ -1,100 +0,0 @@
import { Icon } from '@iconify/react';
import { DashboardMeta } from '@/types/api/dashboard/dashboard';
const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => {
return (
<div className='w-full bg-white rounded-lg shadow-sm border border-gray-200 p-6 relative'>
{/* Header with title skeleton */}
<div className='text-lg font-semibold'>
Performance{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
{/* Chart area with axes skeleton */}
<div className='relative mt-6'>
{/* Main chart container */}
<div className='flex gap-4'>
{/* Y-axis skeleton (left side) */}
<div className='flex flex-col justify-between py-4 space-y-4'>
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className='h-4 w-12 bg-gray-100 rounded animate-pulse'
></div>
))}
</div>
{/* Chart content area */}
<div className='flex-1 relative'>
{/* Empty state centered in chart area */}
<div className='absolute inset-0 flex flex-col items-center justify-center pb-12'>
{!meta?.filters && (
<>
{/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:funnel'
className='text-white'
width={24}
height={24}
/>
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
No Filters Selected
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please choose filters to narrow down your results and make
your search easier.
</p>
</>
)}
{meta?.filters && (
<>
{/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={24}
height={24}
/>
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</>
)}
</div>
{/* Placeholder for chart height */}
<div className='h-64'></div>
{/* X-axis skeleton (bottom) */}
<div className='flex justify-between pt-4 border-t border-gray-100'>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
<div
key={item}
className='h-4 w-8 bg-gray-100 rounded animate-pulse'
></div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardLineChartSkeleton;
@@ -28,7 +28,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
if ( if (
initialValues?.latest_approval && initialValues?.latest_approval &&
initialValues?.latest_approval.step_number >= 5 && initialValues?.latest_approval.step_number >= 4 &&
initialValues.latest_approval.action !== 'REJECTED' initialValues.latest_approval.action !== 'REJECTED'
) { ) {
validTabs.push({ validTabs.push({
@@ -16,7 +16,7 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant'; import { ACCEPTED_FILE_TYPE } from '@/config/constant';
interface ExpenseRealizationContentProps { interface ExpenseRealizationContentProps {
initialValues?: Expense; initialValues?: Expense;
@@ -48,13 +48,6 @@ const ExpenseRealizationContent = ({
const realizationDocumentsChangeHandler = (val: File[]) => { const realizationDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true); formik.setFieldTouched('documents', true);
const invalidFiles = val.filter((file) => file.size > 5 * 1024 * 1024);
if (invalidFiles.length > 0) {
toast.error('Ukuran dokumen maksimal 5 MB!');
return;
}
formik.setFieldValue('documents', val); formik.setFieldValue('documents', val);
}; };
@@ -110,17 +103,10 @@ const ExpenseRealizationContent = ({
initialValues?.realization_docs.length > 0 && ( initialValues?.realization_docs.length > 0 && (
<ul className='list-disc'> <ul className='list-disc'>
{initialValues?.realization_docs.map( {initialValues?.realization_docs.map(
(realizationDocument, realizationDocumentIdx) => { (realizationDocument, realizationDocumentIdx) => (
const path = realizationDocument.path.startsWith(
'/'
)
? realizationDocument.path.slice(1)
: realizationDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={realizationDocumentIdx}> <li key={realizationDocumentIdx}>
<Link <Link
href={documentUrl} href={realizationDocument.path}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-blue-500 underline' className='text-blue-500 underline'
@@ -134,8 +120,7 @@ const ExpenseRealizationContent = ({
/> />
</Link> </Link>
</li> </li>
); )
}
)} )}
</ul> </ul>
)} )}
@@ -226,7 +211,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -288,7 +273,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -27,7 +27,7 @@ import {
UploadRequestDocumentsFormSchema, UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues, UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant'; import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line'; import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
@@ -59,40 +59,34 @@ const ExpenseRequestContent = ({
const isLatestApprovalRejectedOrDone = const isLatestApprovalRejectedOrDone =
isLatestApprovalRejected || isLatestApprovalRejected ||
initialValues?.latest_approval.step_number === 6; initialValues?.latest_approval.step_number === 5;
const isCurrentApprovalOnHeadArea = const isCurrentApprovalOnManager =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 1; initialValues?.latest_approval.step_number === 1;
const isCurrentApprovalOnUnitVicePresident = const isCurrentApprovalOnFinance =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 2; initialValues?.latest_approval.step_number === 2;
const isCurrentApprovalOnFinance =
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 3;
const isCurrentApprovalOnRealization = const isCurrentApprovalOnRealization =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 5; initialValues?.latest_approval.step_number === 4;
const showEditButton = const showEditButton =
initialValues?.latest_approval.step_number !== 6 && initialValues?.latest_approval.step_number !== 5 &&
(initialValues?.latest_approval.step_number === 1 ||
initialValues?.latest_approval.step_number === 2 ||
initialValues?.latest_approval.step_number === 3 ||
initialValues?.latest_approval.step_number === 4);
const showRejectButton =
!isLatestApprovalRejected &&
(initialValues?.latest_approval.step_number === 1 || (initialValues?.latest_approval.step_number === 1 ||
initialValues?.latest_approval.step_number === 2 || initialValues?.latest_approval.step_number === 2 ||
initialValues?.latest_approval.step_number === 3); initialValues?.latest_approval.step_number === 3);
const showRejectButton =
!isLatestApprovalRejected &&
(initialValues?.latest_approval.step_number === 1 ||
initialValues?.latest_approval.step_number === 2);
const isExpenseCanBeRealized = const isExpenseCanBeRealized =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4; initialValues?.latest_approval.step_number === 3;
// Modal hooks // Modal hooks
const deleteModal = useModal(); const deleteModal = useModal();
@@ -146,17 +140,17 @@ const ExpenseRequestContent = ({
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
const deleteResponse = await ExpenseApi.delete(initialValues?.id as number); try {
await ExpenseApi.delete(initialValues?.id as number);
if (isResponseSuccess(deleteResponse)) {
toast.success('Berhasil menghapus data biaya operasional!'); toast.success('Berhasil menghapus data biaya operasional!');
router.push('/expense'); router.push('/expense');
} else { } catch (error) {
toast.error('Gagal menghapus data biaya operasional!'); toast.error('Gagal menghapus data biaya operasional!');
} } finally {
deleteModal.closeModal(); deleteModal.closeModal();
setIsDeleteLoading(false); setIsDeleteLoading(false);
}
}; };
const confirmationModalCompleteClickHandler = async () => { const confirmationModalCompleteClickHandler = async () => {
@@ -180,15 +174,8 @@ const ExpenseRequestContent = ({
let approveResponse: BaseApiResponse<Expense> | undefined = undefined; let approveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isCurrentApprovalOnHeadArea) { if (isCurrentApprovalOnManager) {
approveResponse = await ExpenseApi.approveHeadArea( approveResponse = await ExpenseApi.approveManager(
initialValues.id,
notes
);
}
if (isCurrentApprovalOnUnitVicePresident) {
approveResponse = await ExpenseApi.approveUnitVicePresident(
initialValues.id, initialValues.id,
notes notes
); );
@@ -220,15 +207,8 @@ const ExpenseRequestContent = ({
let rejectResponse: BaseApiResponse<Expense> | undefined = undefined; let rejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isCurrentApprovalOnHeadArea) { if (isCurrentApprovalOnManager) {
rejectResponse = await ExpenseApi.rejectHeadArea(initialValues.id, notes); rejectResponse = await ExpenseApi.rejectManager(initialValues.id, notes);
}
if (isCurrentApprovalOnUnitVicePresident) {
rejectResponse = await ExpenseApi.rejectUnitVicePresident(
initialValues.id,
notes
);
} }
if (isCurrentApprovalOnFinance) { if (isCurrentApprovalOnFinance) {
@@ -251,13 +231,6 @@ const ExpenseRequestContent = ({
const requestDocumentsChangeHandler = (val: File[]) => { const requestDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true); formik.setFieldTouched('documents', true);
const invalidFiles = val.filter((file) => file.size > 5 * 1024 * 1024);
if (invalidFiles.length > 0) {
toast.error('Ukuran dokumen maksimal 5 MB!');
return;
}
formik.setFieldValue('documents', val); formik.setFieldValue('documents', val);
}; };
@@ -282,8 +255,8 @@ const ExpenseRequestContent = ({
{/* TODO: apply RBAC */} {/* TODO: apply RBAC */}
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && ( {isCurrentApprovalOnManager && (
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.manager'>
<Button <Button
variant='outline' variant='outline'
color='info' color='info'
@@ -291,21 +264,7 @@ const ExpenseRequestContent = ({
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon icon='lucide-lab:farm' width={24} height={24} /> <Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Head Area Approve Manager
</Button>
</RequirePermission>
)}
{isCurrentApprovalOnUnitVicePresident && (
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='success'
onClick={approveClickHandler}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Unit Vice President
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )}
@@ -345,8 +304,7 @@ const ExpenseRequestContent = ({
{showRejectButton && ( {showRejectButton && (
<RequirePermission <RequirePermission
permissions={[ permissions={[
'lti.expense.approve.head_area', 'lti.expense.approve.manager',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance', 'lti.expense.approve.finance',
]} ]}
> >
@@ -450,13 +408,9 @@ const ExpenseRequestContent = ({
<th>Kandang</th> <th>Kandang</th>
<th>:</th> <th>:</th>
<td> <td>
{initialValues?.kandangs && {initialValues?.kandangs
initialValues?.kandangs.some((k) => k.name)
? initialValues?.kandangs
.filter((item) => item.name)
.map((item) => item.name) .map((item) => item.name)
.join(', ') .join(', ')}
: '-'}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -494,14 +448,7 @@ const ExpenseRequestContent = ({
<tr> <tr>
<th>Nominal Biaya</th> <th>Nominal Biaya</th>
<th>:</th> <th>:</th>
<td> <td>{formatCurrency(initialValues?.grand_total ?? 0)}</td>
{formatCurrency(
initialValues?.latest_approval.step_number === 5 ||
initialValues?.latest_approval.step_number === 6
? (initialValues?.total_realisasi ?? 0)
: (initialValues?.total_pengajuan ?? 0)
)}
</td>
</tr> </tr>
<tr> <tr>
<th>Status Pencairan</th> <th>Status Pencairan</th>
@@ -535,17 +482,10 @@ const ExpenseRequestContent = ({
initialValues?.documents.length > 0 && ( initialValues?.documents.length > 0 && (
<ul className='list-disc'> <ul className='list-disc'>
{initialValues?.documents.map( {initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => { (requestDocument, requestDocumentIdx) => (
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={requestDocumentIdx}> <li key={requestDocumentIdx}>
<Link <Link
href={documentUrl} href={requestDocument.path}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-blue-500 underline' className='text-blue-500 underline'
@@ -559,8 +499,7 @@ const ExpenseRequestContent = ({
/> />
</Link> </Link>
</li> </li>
); )
}
)} )}
</ul> </ul>
)} )}
@@ -619,7 +558,7 @@ const ExpenseRequestContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -634,9 +573,7 @@ const ExpenseRequestContent = ({
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
{kandangExpense.kandang_id && kandangExpense.name Biaya {kandangExpense.name}
? `Biaya ${kandangExpense.name}`
: `Biaya ${initialValues?.location.name || 'Umum'}`}
</th> </th>
</tr> </tr>
<tr> <tr>
@@ -21,7 +21,7 @@ const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => {
switch (latestApprovalStepNumber) { switch (latestApprovalStepNumber) {
case 1: case 1:
expenseStatusPillBadgeColor = 'gray'; expenseStatusPillBadgeColor = 'yellow';
break; break;
case 2: case 2:
@@ -33,16 +33,12 @@ const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => {
break; break;
case 4: case 4:
expenseStatusPillBadgeColor = 'yellow'; expenseStatusPillBadgeColor = 'red';
break; break;
case 5: case 5:
expenseStatusPillBadgeColor = 'green'; expenseStatusPillBadgeColor = 'green';
break; break;
case 6:
expenseStatusPillBadgeColor = 'green';
break;
} }
if (isLatestApprovalRejected) { if (isLatestApprovalRejected) {
+20 -73
View File
@@ -55,16 +55,15 @@ const RowOptionsMenu = ({
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = const showEditButton =
props.row.original.latest_approval.step_number !== 6 && props.row.original.latest_approval.step_number !== 5 &&
(props.row.original.latest_approval.step_number === 1 || (props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 || props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 || props.row.original.latest_approval.step_number === 3);
props.row.original.latest_approval.step_number === 4);
// TODO: apply RBAC // TODO: apply RBAC
const showRealizationButton = const showRealizationButton =
props.row.original.latest_approval.action !== 'REJECTED' && props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4; props.row.original.latest_approval.step_number === 3;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
@@ -194,7 +193,7 @@ const ExpensesTable = () => {
parseInt(item) parseInt(item)
); );
const isAllSelectedRowLatestApprovalOnHeadArea = useMemo(() => { const isAllSelectedRowLatestApprovalOnManager = useMemo(() => {
return selectedRowIds.every((rowId) => { return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false; if (!isResponseSuccess(expenses)) return false;
@@ -203,28 +202,11 @@ const ExpensesTable = () => {
const isLatestApprovalRejected = const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED'; expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnHeadArea = const isCurrentApprovalOnManager =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 1; expenseItem?.latest_approval.step_number === 1;
return isCurrentApprovalOnHeadArea; return isCurrentApprovalOnManager;
});
}, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnUnitVicePresident = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnUnitVicePresident =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 2;
return isCurrentApprovalOnUnitVicePresident;
}); });
}, [expenses, selectedRowIds]); }, [expenses, selectedRowIds]);
@@ -239,7 +221,7 @@ const ExpensesTable = () => {
const isCurrentApprovalOnFinance = const isCurrentApprovalOnFinance =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 3; expenseItem?.latest_approval.step_number === 2;
return isCurrentApprovalOnFinance; return isCurrentApprovalOnFinance;
}); });
@@ -256,7 +238,7 @@ const ExpensesTable = () => {
const isCurrentApprovalOnRealization = const isCurrentApprovalOnRealization =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 5; expenseItem?.latest_approval.step_number === 4;
return isCurrentApprovalOnRealization; return isCurrentApprovalOnRealization;
}); });
@@ -415,7 +397,7 @@ const ExpensesTable = () => {
) => { ) => {
return ( return (
row.original.latest_approval.action !== 'REJECTED' && row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6 row.original.latest_approval.step_number !== 5
); );
}; };
@@ -438,19 +420,11 @@ const ExpensesTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
const deleteResponse = await ExpenseApi.delete( await ExpenseApi.delete(selectedExpense?.id as number);
selectedExpense?.id as number
);
if (isResponseSuccess(deleteResponse)) {
refreshExpenses(); refreshExpenses();
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Berhasil menghapus biaya operasional!'); toast.success('Berhasil menghapus biaya operasional!');
} else {
deleteModal.closeModal();
toast.error('Gagal menghapus biaya operasional!');
}
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
@@ -459,13 +433,8 @@ const ExpensesTable = () => {
let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined; let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnHeadArea) { if (isAllSelectedRowLatestApprovalOnManager) {
bulkApproveResponse = await ExpenseApi.bulkApproveHeadArea( bulkApproveResponse = await ExpenseApi.bulkApproveManager(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnUnitVicePresident) {
bulkApproveResponse = await ExpenseApi.bulkApproveUnitVicePresident(
selectedRowIds, selectedRowIds,
notes notes
); );
@@ -501,13 +470,8 @@ const ExpensesTable = () => {
let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined; let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnHeadArea) { if (isAllSelectedRowLatestApprovalOnManager) {
bulkRejectResponse = await ExpenseApi.bulkRejectHeadArea( bulkRejectResponse = await ExpenseApi.bulkRejectManager(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnUnitVicePresident) {
bulkRejectResponse = await ExpenseApi.bulkRejectUnitVicePresident(
selectedRowIds, selectedRowIds,
notes notes
); );
@@ -622,31 +586,16 @@ const ExpensesTable = () => {
{selectedRowIds.length > 0 && ( {selectedRowIds.length > 0 && (
<> <>
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.manager'>
<Button <Button
variant='outline' variant='outline'
color='info' color='info'
onClick={bulkApproveClickHandler} onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea} disabled={!isAllSelectedRowLatestApprovalOnManager}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon icon='lucide-lab:farm' width={24} height={24} /> <Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Head Area Approve Manager
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Unit Vice President
</Button> </Button>
</RequirePermission> </RequirePermission>
@@ -665,8 +614,7 @@ const ExpensesTable = () => {
<RequirePermission <RequirePermission
permissions={[ permissions={[
'lti.expense.approve.head_area', 'lti.expense.approve.manager',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance', 'lti.expense.approve.finance',
]} ]}
> >
@@ -675,8 +623,7 @@ const ExpensesTable = () => {
color='error' color='error'
onClick={bulkRejectClickHandler} onClick={bulkRejectClickHandler}
disabled={ disabled={
!isAllSelectedRowLatestApprovalOnHeadArea && !isAllSelectedRowLatestApprovalOnManager &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!isAllSelectedRowLatestApprovalOnFinance !isAllSelectedRowLatestApprovalOnFinance
} }
className='w-full sm:w-fit' className='w-full sm:w-fit'
@@ -9,7 +9,7 @@ interface RealizationStatusBadgeProps {
const RealizationStatusBadge = ({ approval }: RealizationStatusBadgeProps) => { const RealizationStatusBadge = ({ approval }: RealizationStatusBadgeProps) => {
const isLatestApprovalRejected = approval?.action === 'REJECTED'; const isLatestApprovalRejected = approval?.action === 'REJECTED';
const isExpenseRealized = approval?.step_number && approval.step_number >= 5; const isExpenseRealized = approval?.step_number && approval.step_number >= 4;
const realizationStatus = isExpenseRealized const realizationStatus = isExpenseRealized
? 'Sudah Realisasi' ? 'Sudah Realisasi'
@@ -20,10 +20,10 @@ interface ExpenseKandangsTableProps {
locationId?: number; locationId?: number;
type: 'add' | 'edit' | 'detail'; type: 'add' | 'edit' | 'detail';
selectedKandangs: { selectedKandangs: {
id?: number; id: number;
name?: string; name: string;
}[]; }[];
onChange: (kandangs: { id?: number; name?: string }[]) => void; onChange: (kandangs: { id: number; name: string }[]) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
}; };
@@ -67,11 +67,7 @@ const ExpenseKandangsTable = ({
); );
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>( const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
convertRowSelectionArrToObj( convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id))
selectedKandangs
.map((item) => item.id)
.filter((id): id is number => id !== undefined)
)
); );
const kandangsColumns: ColumnDef<Kandang>[] = [ const kandangsColumns: ColumnDef<Kandang>[] = [
@@ -1,7 +1,6 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
type ExpenseRealizationFormSchemaType = { type ExpenseRealizationFormSchemaType = {
category?: { category?: {
@@ -13,7 +12,7 @@ type ExpenseRealizationFormSchemaType = {
label: string; label: string;
}; };
realization_date?: string; realization_date?: string;
kandangs?: { id?: number; name?: string }[]; kandangs?: { id: number; name: string }[];
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
@@ -21,7 +20,7 @@ type ExpenseRealizationFormSchemaType = {
existing_documents?: { name: string; url: string }[]; existing_documents?: { name: string; url: string }[];
documents?: File[]; documents?: File[];
realizations: { realizations: {
kandang_id?: number; kandang_id: number;
cost_items: { cost_items: {
nonstock?: { nonstock?: {
value: number; value: number;
@@ -50,11 +49,12 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
kandangs: Yup.array() kandangs: Yup.array()
.of( .of(
Yup.object({ Yup.object({
id: Yup.number().optional(), id: Yup.number().required('Kandang wajib dipilih!'),
name: Yup.string().optional(), name: Yup.string().required('Kandang wajib dipilih!'),
}) })
) )
.optional(), .min(1, 'Kandang wajib dipilih!')
.required('Kandang wajib dipilih!'),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -73,7 +73,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
realizations: Yup.array() realizations: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
cost_items: Yup.array() cost_items: Yup.array()
.of( .of(
Yup.object({ Yup.object({
@@ -86,12 +86,12 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
notes: Yup.string(), notes: Yup.string(),
}) })
) )
.min(1, 'Harus memiliki setidaknya 1 biaya!') .min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
.required('Biaya wajib diisi!'), .required('Biaya kandang wajib diisi!'),
}) })
) )
.min(1, 'Biaya wajib diisi!') .min(1, 'Biaya kandang wajib diisi!')
.required('Biaya wajib diisi!'), .required('Biaya kandang wajib diisi!'),
}); });
export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema; export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema;
@@ -139,13 +139,10 @@ export const getExpenseRealizationFormInitialValues = (
label: initialValues.supplier.name, label: initialValues.supplier.name,
} }
: undefined, : undefined,
existing_documents: initialValues?.realization_docs?.map((doc) => { existing_documents: initialValues?.realization_docs?.map((doc) => ({
const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
return {
name: doc.path, name: doc.path,
url: `${S3_PUBLIC_BASE_URL}/${path}`, url: doc.path,
}; })),
}),
documents: [], documents: [],
realizations: initialValues?.kandangs realizations: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => { ? initialValues.kandangs.map((kandangExpense) => {
@@ -150,59 +150,26 @@ const ExpenseRealizationForm = ({
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('realizations', []);
// Auto-create realization item for location (without kandang)
formik.setFieldValue('realizations', [
{
cost_items: [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
}; };
const kandangsChangeHandler = ( const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
kandangs: { id?: number; name?: string }[]
) => {
formik.setFieldTouched('kandangs', true); formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs); formik.setFieldValue('kandangs', kandangs);
// If no kandangs selected, create realization item for location const newRealizations = [...(formik.values.realizations ?? [])];
if (kandangs.length === 0) {
formik.setFieldValue('realizations', [
{
cost_items: [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
return;
}
// Start with empty array when kandangs are selected // add new realizations
const newRealizations: typeof formik.values.realizations = [];
// add new realizations for each kandang
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return; const isKandangExistInRealization = newRealizations.find(
const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id (realizationItem) => realizationItem.kandang_id === kandangItem.id
); );
if (isKandangExistInRealization) return;
newRealizations.push({ newRealizations.push({
kandang_id: kandangItem.id, kandang_id: kandangItem.id,
cost_items: existingRealization?.cost_items || [ cost_items: [
{ {
nonstock: undefined, nonstock: undefined,
quantity: undefined, quantity: undefined,
@@ -213,6 +180,22 @@ const ExpenseRealizationForm = ({
}); });
}); });
// prune realizations
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedRealizationsIdx: number[] = [];
newRealizations.forEach((realization, idx) => {
const isRealizationValid = kandangIds.has(realization.kandang_id);
if (!isRealizationValid) {
deletedRealizationsIdx.push(idx);
}
});
deletedRealizationsIdx.forEach((deletedRealizationIdx) => {
newRealizations.splice(deletedRealizationIdx, 1);
});
formik.setFieldValue('realizations', newRealizations); formik.setFieldValue('realizations', newRealizations);
}; };
@@ -223,13 +206,6 @@ const ExpenseRealizationForm = ({
const realizationDocumentsChangeHandler = (val: File[]) => { const realizationDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true); formik.setFieldTouched('documents', true);
const invalidFiles = val.filter((file) => file.size > 5 * 1024 * 1024);
if (invalidFiles.length > 0) {
toast.error('Ukuran dokumen maksimal 5 MB!');
return;
}
formik.setFieldValue('documents', val); formik.setFieldValue('documents', val);
}; };
@@ -362,10 +338,7 @@ const ExpenseRealizationForm = ({
)} )}
<ExpenseRealizationKandangDetailExpense <ExpenseRealizationKandangDetailExpense
type={type}
formik={formik} formik={formik}
supplierId={formik.values.supplier?.value as number}
location={formik.values.location}
className={{ className={{
wrapper: 'col-span-12', wrapper: 'col-span-12',
}} }}
@@ -18,11 +18,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
interface ExpenseRealizationKandangDetailExpenseProps { interface ExpenseRealizationKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRealizationFormValues>; formik: FormikContextType<ExpenseRealizationFormValues>;
supplierId?: number;
location?: {
value: number;
label: string;
};
className?: { className?: {
wrapper?: string; wrapper?: string;
}; };
@@ -30,18 +25,12 @@ interface ExpenseRealizationKandangDetailExpenseProps {
const ExpenseRealizationKandangDetailExpense: React.FC< const ExpenseRealizationKandangDetailExpense: React.FC<
ExpenseRealizationKandangDetailExpenseProps ExpenseRealizationKandangDetailExpenseProps
> = ({ type, formik, supplierId, location, className }) => { > = ({ type, formik, className }) => {
const { const {
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions, isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>( } = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
NonstockApi.basePath,
'id',
'name',
'search',
supplierId ? { supplier_id: String(supplierId) } : undefined
);
const nonstockChangeHandler = ( const nonstockChangeHandler = (
kandangExpenseIdx: number, kandangExpenseIdx: number,
@@ -93,46 +82,28 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
</div> </div>
<div className='w-full flex flex-col gap-6'> <div className='w-full flex flex-col gap-6'>
{!formik.values.supplier?.value && ( {formik.values.realizations.length === 0 && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
Pilih supplier terlebih dahulu! Pilih kandang terlebih dahulu!
</p> </p>
</div> </div>
)} )}
{formik.values.realizations.length === 0 && {formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => {
formik.values.supplier?.value && ( const kandangName = formik.values.kandangs?.find(
<div>
<p className='text-sm text-gray-400 text-center'>
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
</p>
</div>
)}
{formik.values.realizations.length > 0 &&
formik.values.supplier?.value &&
formik.values.realizations.map(
(kandangExpense, kandangExpenseIdx) => {
const kandangName = kandangExpense.kandang_id
? formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id (kandang) => kandang.id === kandangExpense.kandang_id
) );
: null;
return ( return (
(kandangName?.name || !kandangExpense.kandang_id) && ( kandangName?.name && (
<div <div
key={`kandangExpense-${kandangExpenseIdx}`} key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4' className='w-full flex flex-col gap-4'
> >
<div> <div>
<h5 className='mb-2 text-lg font-bold text-center'> <h5 className='mb-2 text-lg font-bold text-center'>
{kandangName?.name Biaya {kandangName?.name}
? `Biaya ${kandangName.name}`
: location?.label
? `Biaya ${location.label}`
: 'Biaya Umum'}
</h5> </h5>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
@@ -244,8 +215,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
</div> </div>
) )
); );
} })}
)}
</div> </div>
</Card> </Card>
); );
@@ -1,36 +1,32 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
type ExpenseFormSchemaType = { type ExpenseFormSchemaType = {
category?: { category?: {
value: 'BOP' | 'NON-BOP'; value: 'BOP' | 'NON-BOP';
label: 'BOP' | 'NON-BOP'; label: 'BOP' | 'NON-BOP';
} | null; };
location?: { location?: {
value: number; value: number;
label: string; label: string;
} | null; };
location_id: number;
transaction_date?: string; transaction_date?: string;
kandangs?: { id?: number; name?: string }[]; kandangs?: { id: number; name: string }[];
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
} | null; };
supplier_id: number;
existing_documents?: { id: number; name: string; url: string }[]; existing_documents?: { id: number; name: string; url: string }[];
deleted_documents?: number[]; deleted_documents?: number[];
documents?: File[]; documents?: File[];
expense_nonstocks: { expense_nonstocks: {
kandang_id?: number; kandang_id: number;
cost_items: { cost_items: {
nonstock?: { nonstock?: {
value: number; value: number;
label: string; label: string;
} | null; };
nonstock_id?: number;
quantity?: number; quantity?: number;
price?: number; price?: number;
notes?: string; notes?: string;
@@ -43,54 +39,36 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
category: Yup.object({ category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
}) }).required('Kategori wajib diisi!'),
.nullable()
.optional(),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).required('Lokasi wajib diisi!'),
.nullable()
.optional(),
location_id: Yup.number()
.required('Lokasi wajib diisi!')
.min(1, 'Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'), transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
kandangs: Yup.array() kandangs: Yup.array()
.of( .of(
Yup.object({ Yup.object({
id: Yup.number().optional(), id: Yup.number().required('Kandang wajib dipilih!'),
name: Yup.string().optional(), name: Yup.string().required('Kandang wajib dipilih!'),
}) })
) )
.optional(), .min(1, 'Kandang wajib dipilih!')
.required('Kandang wajib dipilih!'),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).required('Vendor wajib diisi!'),
.nullable()
.optional(),
supplier_id: Yup.number() existing_documents: Yup.array().of(
.required('Vendor wajib diisi!')
.min(1, 'Vendor wajib diisi!')
.typeError('Vendor wajib diisi!'),
existing_documents: Yup.array()
.of(
Yup.object({ Yup.object({
id: Yup.number().required(), id: Yup.number().required(),
name: Yup.string().required(), name: Yup.string().required(),
url: Yup.string().required(), url: Yup.string().required(),
}) })
) ),
.optional(),
deleted_documents: Yup.array().of(Yup.number().required()).optional(), deleted_documents: Yup.array().of(Yup.number().required()).optional(),
@@ -99,24 +77,16 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
expense_nonstocks: Yup.array() expense_nonstocks: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
cost_items: Yup.array() cost_items: Yup.array()
.of( .of(
Yup.object({ Yup.object({
nonstock: Yup.object({ nonstock: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).required('Nonstock wajib diisi!'),
nonstock_id: Yup.number() quantity: Yup.number().required('Total kuantitas wajib diisi!'),
.required('Nonstock wajib diisi!') price: Yup.number().required('Harga satuan wajib diisi!'),
.min(1, 'Nonstock wajib diisi!')
.typeError('Nonstock wajib diisi!'),
quantity: Yup.number()
.required('Total kuantitas wajib diisi!')
.typeError('Total kuantitas wajib diisi!'),
price: Yup.number()
.required('Harga satuan wajib diisi!')
.typeError('Harga satuan wajib diisi!'),
notes: Yup.string(), notes: Yup.string(),
}) })
) )
@@ -131,16 +101,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema; export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
export const UploadRequestDocumentsFormSchema = Yup.object({ export const UploadRequestDocumentsFormSchema = Yup.object({
documents: Yup.array() documents: Yup.array().of(Yup.mixed<File>().required()).required(),
.of(
Yup.mixed<File>()
.required()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value || !(value instanceof File)) return true;
return value.size <= 5 * 1024 * 1024;
})
)
.required(),
}); });
export type ExpenseRequestFormValues = Yup.InferType< export type ExpenseRequestFormValues = Yup.InferType<
@@ -160,14 +121,13 @@ export const getExpenseFormInitialValues = (
value: initialValues.category, value: initialValues.category,
label: initialValues.category, label: initialValues.category,
} }
: null, : undefined,
location: initialValues?.location location: initialValues?.location
? { ? {
value: initialValues.location.id, value: initialValues.location.id,
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : undefined,
location_id: Number(initialValues?.location.id || 0),
transaction_date: initialValues?.transaction_date transaction_date: initialValues?.transaction_date
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD') ? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
: undefined, : undefined,
@@ -180,16 +140,12 @@ export const getExpenseFormInitialValues = (
value: initialValues.supplier.id, value: initialValues.supplier.id,
label: initialValues.supplier.name, label: initialValues.supplier.name,
} }
: null, : undefined,
supplier_id: initialValues?.supplier?.id ?? 0, existing_documents: initialValues?.documents?.map((doc) => ({
existing_documents: initialValues?.documents?.map((doc) => {
const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
return {
id: doc.id, id: doc.id,
name: doc.path, name: doc.path,
url: `${S3_PUBLIC_BASE_URL}/${path}`, url: doc.path,
}; })),
}),
deleted_documents: [], deleted_documents: [],
documents: [], documents: [],
expense_nonstocks: initialValues?.kandangs expense_nonstocks: initialValues?.kandangs
@@ -201,25 +157,12 @@ export const getExpenseFormInitialValues = (
value: expenseItem.nonstock.id, value: expenseItem.nonstock.id,
label: expenseItem.nonstock.name, label: expenseItem.nonstock.name,
}, },
nonstock_id: expenseItem.nonstock.id,
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.note,
})) }))
: [], : [],
})) }))
: [ : [],
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
],
}; };
}; };
@@ -37,8 +37,6 @@ import { cn, sleep } from '@/lib/helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface ExpenseFormProps { interface ExpenseFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -57,7 +55,6 @@ const ExpenseRequestForm = ({
const rejectModal = useModal(); const rejectModal = useModal();
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState(''); const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]);
const createExpenseHandler = useCallback( const createExpenseHandler = useCallback(
async (payload: CreateExpensePayload) => { async (payload: CreateExpensePayload) => {
@@ -111,24 +108,18 @@ const ExpenseRequestForm = ({
const expensePayload: CreateExpensePayload = { const expensePayload: CreateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP', category: formik.values.category?.value as 'BOP' | 'NON-BOP',
location_id: values.location_id as number,
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => { expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({
const basePayload = { kandang_id: expenseNonstock.kandang_id,
cost_items: expenseNonstock.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
}; })),
return expenseNonstock.kandang_id
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
: basePayload;
}),
}; };
switch (type) { switch (type) {
@@ -139,25 +130,19 @@ const ExpenseRequestForm = ({
case 'edit': case 'edit':
const expenseUpdatePayload: UpdateExpensePayload = { const expenseUpdatePayload: UpdateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP', category: formik.values.category?.value as 'BOP' | 'NON-BOP',
location_id: values.location_id as number,
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
expense_nonstocks: values.expense_nonstocks.map( expense_nonstocks: values.expense_nonstocks.map(
(expenseNonstock) => { (expenseNonstock) => ({
const basePayload = { kandang_id: expenseNonstock.kandang_id,
cost_items: expenseNonstock.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
}; })
return expenseNonstock.kandang_id
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
: basePayload;
}
), ),
}; };
@@ -194,67 +179,30 @@ const ExpenseRequestForm = ({
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('expense_nonstocks', []);
// Auto-create expense item for location (without kandang)
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
}; };
const kandangsChangeHandler = ( const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
kandangs: { id?: number; name?: string }[]
) => {
formik.setFieldTouched('kandangs', true); formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs); formik.setFieldValue('kandangs', kandangs);
// If no kandangs selected, create expense item for location const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
if (kandangs.length === 0) {
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
return;
}
const newExpenseNonstocks: typeof formik.values.expense_nonstocks = [];
// add new expense_nonstocks
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return; const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
const existingExpenseNonstock = formik.values.expense_nonstocks?.find(
(expenseNonstockItem) => (expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id expenseNonstockItem.kandang_id === kandangItem.id
); );
if (isKandangExistInExpenseNonstocks) return;
newExpenseNonstocks.push({ newExpenseNonstocks.push({
kandang_id: kandangItem.id, kandang_id: kandangItem.id,
cost_items: existingExpenseNonstock?.cost_items || [ cost_items: [
{ {
nonstock: null, nonstock: undefined,
nonstock_id: 0,
quantity: undefined, quantity: undefined,
price: undefined, price: undefined,
notes: '', notes: '',
@@ -263,26 +211,32 @@ const ExpenseRequestForm = ({
}); });
}); });
// prune expense_nonstocks
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedExpenseNonstocksIdx: number[] = [];
newExpenseNonstocks.forEach((expenseNonstock, idx) => {
const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
if (!isExpenseNonstockValid) {
deletedExpenseNonstocksIdx.push(idx);
}
});
deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
});
formik.setFieldValue('expense_nonstocks', newExpenseNonstocks); formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
}; };
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('supplier', true); formik.setFieldTouched('supplier', true);
formik.setFieldValue('supplier', val); formik.setFieldValue('supplier', val);
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue('supplier_id', supplierId ?? 0);
}; };
const requestDocumentsChangeHandler = (val: File[]) => { const requestDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true); formik.setFieldTouched('documents', true);
const invalidFiles = val.filter((file) => file.size > 5 * 1024 * 1024);
if (invalidFiles.length > 0) {
toast.error('Ukuran dokumen maksimal 5 MB!');
return;
}
formik.setFieldValue('documents', val); formik.setFieldValue('documents', val);
}; };
@@ -338,22 +292,6 @@ const ExpenseRequestForm = ({
router.push('/expense'); router.push('/expense');
}; };
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
useEffect(() => { useEffect(() => {
formikSetValues(getExpenseFormInitialValues(initialValues)); formikSetValues(getExpenseFormInitialValues(initialValues));
}, [formikSetValues, getExpenseFormInitialValues, initialValues]); }, [formikSetValues, getExpenseFormInitialValues, initialValues]);
@@ -379,27 +317,10 @@ const ExpenseRequestForm = ({
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='grid grid-cols-12 gap-4'> <div className='grid grid-cols-12 gap-4'>
<SelectInput <SelectInput
label='Kategori' label='Kategori'
@@ -533,10 +454,7 @@ const ExpenseRequestForm = ({
)} )}
<ExpenseRequestKandangDetailExpense <ExpenseRequestKandangDetailExpense
type={type}
formik={formik} formik={formik}
supplierId={formik.values.supplier?.value as number}
location={formik.values.location}
className={{ className={{
wrapper: 'col-span-12', wrapper: 'col-span-12',
}} }}
@@ -584,6 +502,17 @@ const ExpenseRequestForm = ({
</div> </div>
)} )}
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error w-full'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -598,7 +527,7 @@ const ExpenseRequestForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -21,11 +21,6 @@ import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ExpenseRequestKandangDetailExpenseProps { interface ExpenseRequestKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRequestFormValues>; formik: FormikContextType<ExpenseRequestFormValues>;
supplierId?: number;
location?: {
value: number;
label: string;
} | null;
className?: { className?: {
wrapper?: string; wrapper?: string;
}; };
@@ -33,18 +28,12 @@ interface ExpenseRequestKandangDetailExpenseProps {
const ExpenseRequestKandangDetailExpense: React.FC< const ExpenseRequestKandangDetailExpense: React.FC<
ExpenseRequestKandangDetailExpenseProps ExpenseRequestKandangDetailExpenseProps
> = ({ type, formik, supplierId, location, className }) => { > = ({ type, formik, className }) => {
const { const {
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions, isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>( } = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
NonstockApi.basePath,
'id',
'name',
'search',
supplierId ? { supplier_id: String(supplierId) } : undefined
);
const nonstockChangeHandler = ( const nonstockChangeHandler = (
kandangExpenseIdx: number, kandangExpenseIdx: number,
@@ -59,20 +48,13 @@ const ExpenseRequestKandangDetailExpense: React.FC<
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val val
); );
const nonstockId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
nonstockId ?? 0
);
}; };
const addExpenseItemHandler = (kandangExpenseIdx: number) => { const addExpenseItemHandler = (kandangExpenseIdx: number) => {
const newExpensesValue = [ const newExpensesValue = [
...formik.values.expense_nonstocks[kandangExpenseIdx].cost_items, ...formik.values.expense_nonstocks[kandangExpenseIdx].cost_items,
{ {
nonstock: null, nonstock: undefined,
nonstock_id: 0,
price: undefined, price: undefined,
quantity: undefined, quantity: undefined,
notes: '', notes: '',
@@ -131,19 +113,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
<div className='w-full flex flex-col gap-6'> <div className='w-full flex flex-col gap-6'>
{!formik.values.supplier?.value && ( {(formik.values.expense_nonstocks.length === 0 ||
!formik.values.supplier?.value) && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
Pilih supplier terlebih dahulu! Pilih kandang terlebih dahulu!
</p>
</div>
)}
{formik.values.expense_nonstocks.length === 0 &&
formik.values.supplier?.value && (
<div>
<p className='text-sm text-gray-400 text-center'>
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
</p> </p>
</div> </div>
)} )}
@@ -152,36 +126,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
formik.values.supplier?.value && formik.values.supplier?.value &&
formik.values.expense_nonstocks.map( formik.values.expense_nonstocks.map(
(kandangExpense, kandangExpenseIdx) => { (kandangExpense, kandangExpenseIdx) => {
const kandangName = kandangExpense.kandang_id const kandangName = formik.values.kandangs?.find(
? formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id (kandang) => kandang.id === kandangExpense.kandang_id
) );
: null;
return ( return (
(kandangName?.name || !kandangExpense.kandang_id) && ( kandangName?.name && (
<div <div
key={`kandangExpense-${kandangExpenseIdx}`} key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4' className='w-full flex flex-col gap-4'
> >
<div> <div>
<h5 className='mb-2 text-lg font-bold text-center'> <h5 className='mb-2 text-lg font-bold text-center'>
Biaya {kandangName?.name || location?.label || 'Umum'} Biaya {kandangName?.name}
</h5> </h5>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
<table className='table'> <table className='table'>
<thead> <thead>
<tr> <tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'> <th>Nonstock</th>
Nonstock <th>Total Kuantitas</th>
</th> <th>Harga Satuan</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Total Kuantitas
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga Satuan
</th>
<th>Catatan</th> <th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
@@ -198,7 +198,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
expense?.latest_approval?.action === 'REJECTED'; expense?.latest_approval?.action === 'REJECTED';
const isExpenseRealized = const isExpenseRealized =
expense?.latest_approval?.step_number && expense?.latest_approval?.step_number &&
expense?.latest_approval.step_number >= 5; expense?.latest_approval.step_number >= 4;
const realizationStatus = isExpenseRealized const realizationStatus = isExpenseRealized
? 'Sudah Realisasi' ? 'Sudah Realisasi'
@@ -219,13 +219,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Lokasi', value: expense?.location.name }, { label: 'Lokasi', value: expense?.location.name },
{ {
label: 'Kandang', label: 'Kandang',
value: value: expense?.kandangs.map((item) => item.name).join(', '),
expense?.kandangs && expense?.kandangs.some((k) => k.name)
? expense?.kandangs
.filter((item) => item.name)
.map((item) => item.name)
.join(', ')
: '-',
}, },
{ label: 'Vendor', value: expense?.supplier.name }, { label: 'Vendor', value: expense?.supplier.name },
{ {
@@ -241,12 +235,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Nama Pengaju', value: expense?.created_user.name }, { label: 'Nama Pengaju', value: expense?.created_user.name },
{ {
label: 'Nominal Biaya', label: 'Nominal Biaya',
value: formatCurrency( value: formatCurrency(expense?.grand_total ?? 0),
expense?.latest_approval.step_number === 5 ||
expense?.latest_approval.step_number === 6
? (expense?.total_realisasi ?? 0)
: (expense?.total_pengajuan ?? 0)
),
}, },
{ {
label: 'Nominal Pengajuan', label: 'Nominal Pengajuan',
@@ -337,7 +326,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRequestTotal = 0; let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.qty * item.price) (item) => (expenseRequestTotal += item.price)
); );
return ( return (
@@ -346,9 +335,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
style={ExpensePDFStyle.kandangExpenseContainer} style={ExpensePDFStyle.kandangExpenseContainer}
> >
<Text style={ExpensePDFStyle.kandangExpenseTitle}> <Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.kandang_id && kandangExpense.name {kandangExpense.name}
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text> </Text>
<View style={ExpensePDFStyle.kandangExpenseTable}> <View style={ExpensePDFStyle.kandangExpenseTable}>
@@ -497,7 +484,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRealizationTotal = 0; let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.qty * item.price) (item) => (expenseRealizationTotal += item.price)
); );
return ( return (
@@ -506,9 +493,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
style={ExpensePDFStyle.kandangExpenseContainer} style={ExpensePDFStyle.kandangExpenseContainer}
> >
<Text style={ExpensePDFStyle.kandangExpenseTitle}> <Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.kandang_id && kandangExpense.name {kandangExpense.name}
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text> </Text>
<View style={ExpensePDFStyle.kandangExpenseTable}> <View style={ExpensePDFStyle.kandangExpenseTable}>
+4 -26
View File
@@ -9,7 +9,6 @@ import Table from '@/components/Table';
import { import {
FINANCE_INITIAL_BALANCE_STATUS, FINANCE_INITIAL_BALANCE_STATUS,
FINANCE_TRANSACTION_STATUS, FINANCE_TRANSACTION_STATUS,
FINANCE_INJECTION_STATUS,
} from '@/config/constant'; } from '@/config/constant';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
@@ -34,7 +33,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Pihak', label: 'Pihak',
value: finance.party.id ? finance.party.name : '-', value: finance.party.name,
}, },
{ {
label: 'Tanggal', label: 'Tanggal',
@@ -52,7 +51,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
const informasiTransfer = [ const informasiTransfer = [
{ {
label: 'No. Referensi', label: 'No. Referensi',
value: finance.reference_number ?? '-', value: finance.reference_number,
}, },
{ {
label: 'Nomor Rekening', label: 'Nomor Rekening',
@@ -70,16 +69,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
label: 'Sisa', label: 'Sisa',
value: formatCurrency(finance.income_amount), value: formatCurrency(finance.income_amount),
}, },
].filter((item) => { ];
// Hide party account number row if transaction type is INJECTION
if (
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
item.label === `Rekening ${formatTitleCase(finance.party.type)}`
) {
return false;
}
return true;
});
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -172,19 +162,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )}
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && ( <RequirePermission permissions='lti.finance.transaction.delete'>
<RequirePermission permissions='lti.finance.injections.update'>
<Button
color='warning'
className='min-w-24'
href={`/finance/detail/edit/injection?financeId=${finance.id}`}
>
<Icon icon='mdi:pencil-outline' />
Edit
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.finance.transactions.delete'>
<Button <Button
color='error' color='error'
className='min-w-24' className='min-w-24'
@@ -49,14 +49,7 @@ const RowOptionsMenu = ({
}) => { }) => {
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<RequirePermission <RequirePermission permissions='lti.finance.transaction.detail'>
permissions={[
'lti.finance.transactions.detail',
'lti.finance.initial_balances.detail',
'lti.finance.injections.detail',
'lti.finance.payments.detail',
]}
>
<Button <Button
href={`/finance/detail?financeId=${props.row.original.id}`} href={`/finance/detail?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
@@ -116,7 +109,7 @@ const RowOptionsMenu = ({
</RequirePermission> </RequirePermission>
)} )}
<RequirePermission permissions='lti.finance.transactions.delete'> <RequirePermission permissions='lti.finance.transaction.delete'>
<Button <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
variant='ghost' variant='ghost'
@@ -1,7 +1,7 @@
'use client'; 'use client';
import Button from '@/components/Button'; import Button from '@/components/Button';
import AlertErrorList from '@/components/helper/form/FormErrors'; import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormHeader } from '@/components/helper/form/FormHeader';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
@@ -21,7 +21,6 @@ import {
} from '@/config/constant'; } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatTitleCase } from '@/lib/helper'; import { formatDate, formatTitleCase } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { import {
@@ -105,9 +104,6 @@ const FormFinanceAdd = ({
}, },
}); });
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Options ===== // ===== Options =====
const { const {
options: partyOptions, options: partyOptions,
@@ -184,7 +180,7 @@ const FormFinanceAdd = ({
title={`${type === 'add' ? 'Tambah' : 'Ubah'} Data Keuangan`} title={`${type === 'add' ? 'Tambah' : 'Ubah'} Data Keuangan`}
backUrl='/finance' backUrl='/finance'
/> />
<form className='flex flex-col gap-4' onSubmit={handleFormSubmit}> <form className='flex flex-col gap-4' onSubmit={formik.handleSubmit}>
<SelectInput <SelectInput
label='Jenis Transaksi' label='Jenis Transaksi'
placeholder='Pilih jenis transaksi' placeholder='Pilih jenis transaksi'
@@ -192,8 +188,6 @@ const FormFinanceAdd = ({
value={formik.values.party_type_option} value={formik.values.party_type_option}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_type_option', value); formik.setFieldValue('party_type_option', value);
formik.setFieldValue('party_id_option', null);
formik.setFieldValue('party_account_number', '');
}} }}
isError={Boolean( isError={Boolean(
formik.touched.party_type_option && formik.touched.party_type_option &&
@@ -388,7 +382,6 @@ const FormFinanceAdd = ({
} }
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -1,10 +1,12 @@
'use client'; 'use client';
import Button from '@/components/Button'; import Button from '@/components/Button';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormHeader } from '@/components/helper/form/FormHeader';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { import {
@@ -15,7 +17,6 @@ import {
FINANCE_INITIAL_BALANCE_TYPE_OPTIONS, FINANCE_INITIAL_BALANCE_TYPE_OPTIONS,
FINANCE_PARTY_TYPE_OPTIONS, FINANCE_PARTY_TYPE_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatTitleCase } from '@/lib/helper'; import { formatTitleCase } from '@/lib/helper';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
@@ -172,9 +173,6 @@ const FormFinanceAddInitialBalance = ({
[router] [router]
); );
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl mx-auto'> <section className='w-full max-w-xl mx-auto'>
@@ -183,7 +181,7 @@ const FormFinanceAddInitialBalance = ({
title={`${type === 'add' ? 'Tambah' : 'Ubah'} Saldo Awal`} title={`${type === 'add' ? 'Tambah' : 'Ubah'} Saldo Awal`}
backUrl='/finance' backUrl='/finance'
/> />
<form className='flex flex-col gap-4' onSubmit={handleFormSubmit}> <form className='flex flex-col gap-4' onSubmit={formik.handleSubmit}>
<SelectInput <SelectInput
label='Jenis Pihak' label='Jenis Pihak'
placeholder='Pilih jenis pihak' placeholder='Pilih jenis pihak'
@@ -191,8 +189,6 @@ const FormFinanceAddInitialBalance = ({
value={formik.values.party_type_option} value={formik.values.party_type_option}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_type_option', value); formik.setFieldValue('party_type_option', value);
formik.setFieldValue('party_id_option', null);
formik.setFieldValue('party_account_number', '');
}} }}
isError={Boolean( isError={Boolean(
formik.touched.party_type_option && formik.touched.party_type_option &&
@@ -354,7 +350,6 @@ const FormFinanceAddInitialBalance = ({
} }
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -1,17 +1,18 @@
'use client'; 'use client';
import Button from '@/components/Button'; import Button from '@/components/Button';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormHeader } from '@/components/helper/form/FormHeader';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { import {
InjectionFormSchema, InjectionFormSchema,
InjectionFormValues, InjectionFormValues,
} from '@/components/pages/finance/add/injection/FormFinanceInjection.schema'; } from '@/components/pages/finance/add/injection/FormFinanceInjection.schema';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
@@ -127,9 +128,6 @@ const FormFinanceInjection = ({
[router] [router]
); );
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl mx-auto'> <section className='w-full max-w-xl mx-auto'>
@@ -138,7 +136,7 @@ const FormFinanceInjection = ({
title={`${type === 'add' ? 'Tambah' : 'Ubah'} Injeksi Dana`} title={`${type === 'add' ? 'Tambah' : 'Ubah'} Injeksi Dana`}
backUrl='/finance' backUrl='/finance'
/> />
<form className='flex flex-col gap-4' onSubmit={handleFormSubmit}> <form className='flex flex-col gap-4' onSubmit={formik.handleSubmit}>
<SelectInput <SelectInput
label='Bank' label='Bank'
placeholder='Pilih bank' placeholder='Pilih bank'
@@ -225,7 +223,6 @@ const FormFinanceInjection = ({
} }
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -15,7 +15,6 @@ import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
const InventoryAdjustmentTable = () => { const InventoryAdjustmentTable = () => {
const { const {
@@ -1,42 +1,26 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const InventoryAdjustmentFormSchema = Yup.object({ export const InventoryAdjustmentFormSchema = Yup.object({
product_category: Yup.mixed<OptionType>() product_category: Yup.object({
.nullable() value: Yup.number().required('ID Kategori Produk wajib diisi!'),
.test( label: Yup.string().required('Nama Kategori Produk wajib diisi!'),
'is-valid-option', }).nullable(),
'Kategori Produk wajib diisi!',
(value) => value !== null && value !== undefined
),
product_category_id: Yup.number().nullable(), product_category_id: Yup.number().nullable(),
product: Yup.mixed<OptionType>() product: Yup.object({
.nullable() value: Yup.number().required('ID Produk wajib diisi!'),
.test( label: Yup.string().required('Nama Produk wajib diisi!'),
'is-valid-option', }).nullable(),
'Produk wajib diisi!',
(value) => value !== null && value !== undefined
),
product_id: Yup.number() product_id: Yup.number().nullable(),
.nullable()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!'),
warehouse: Yup.mixed<OptionType>() warehouse: Yup.object({
.nullable() value: Yup.number().required('ID Gudang wajib diisi!'),
.test( label: Yup.string().required('Nama Gudang wajib diisi!'),
'is-valid-option', }).nullable(),
'Warehouse wajib diisi!',
(value) => value !== null && value !== undefined
),
warehouse_id: Yup.number() warehouse_id: Yup.number().nullable(),
.nullable()
.required('Warehouse wajib diisi!')
.min(1, 'Warehouse wajib diisi!'),
transaction_type: Yup.string() transaction_type: Yup.string()
.oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid') .oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid')
@@ -26,8 +26,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -127,7 +125,6 @@ const InventoryAdjustmentForm = ({
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
search: '', search: '',
limit: '100',
}).toString()}`; }).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehouseUrl, warehouseUrl,
@@ -247,9 +244,6 @@ const InventoryAdjustmentForm = ({
return decimal ? `${formattedInteger}.${decimal}` : formattedInteger; return decimal ? `${formattedInteger}.${decimal}` : formattedInteger;
}; };
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// Render // Render
return ( return (
<> <>
@@ -271,7 +265,7 @@ const InventoryAdjustmentForm = ({
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -395,7 +389,6 @@ const InventoryAdjustmentForm = ({
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'detail' && ( {type !== 'detail' && (
<div className='flex flex-row justify-end gap-2'> <div className='flex flex-row justify-end gap-2'>
@@ -411,7 +404,11 @@ const InventoryAdjustmentForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={
!formik.isValid ||
formik.isSubmitting ||
formik.values.product == undefined
}
className='px-4' className='px-4'
> >
Submit Submit
@@ -1,5 +1,5 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Movement, MovementDocument } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
type MovementFormSchemaType = { type MovementFormSchemaType = {
transfer_reason: string; transfer_reason: string;
@@ -29,7 +29,7 @@ type MovementFormSchemaType = {
deliveries: { deliveries: {
delivery_cost?: number | string; delivery_cost?: number | string;
delivery_cost_per_item?: number | string; delivery_cost_per_item?: number | string;
document?: File | MovementDocument | null; document?: File | string | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name: string;
vehicle_plate: string; vehicle_plate: string;
@@ -61,7 +61,7 @@ export type ProductSchema = {
export type DeliverySchema = { export type DeliverySchema = {
delivery_cost?: number | string; delivery_cost?: number | string;
delivery_cost_per_item?: number | string; delivery_cost_per_item?: number | string;
document?: File | MovementDocument | null; document?: File | string | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name: string;
vehicle_plate: string; vehicle_plate: string;
@@ -85,10 +85,7 @@ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
product_id: Yup.number() product_id: Yup.number().required('Produk wajib diisi!'),
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
product_qty: Yup.number() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
@@ -100,10 +97,7 @@ const DeliveryProductObjectSchema = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
product_id: Yup.number() product_id: Yup.number().required('Produk wajib diisi!'),
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
product_qty: Yup.number() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
@@ -133,14 +127,15 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
(delivery_cost !== undefined && delivery_cost > 0) (delivery_cost !== undefined && delivery_cost > 0)
); );
}), }),
document_path: Yup.string().nullable().optional(), document_path: Yup.string().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>() document: Yup.mixed<File | string>()
.nullable() .nullable()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => { .test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => {
if (!value) return true; if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024; if (typeof value === 'string') return true;
return true; if (value instanceof File) return value.size <= 2 * 1024 * 1024;
return false;
}), }),
driver_name: Yup.string().required('Nama sopir wajib diisi!'), driver_name: Yup.string().required('Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
@@ -148,10 +143,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
supplier_id: Yup.number() supplier_id: Yup.number().required('Supplier wajib diisi!'),
.required('Supplier wajib diisi!')
.min(1, 'Supplier wajib diisi!')
.typeError('Supplier wajib diisi!'),
products: Yup.array() products: Yup.array()
.of(DeliveryProductObjectSchema) .of(DeliveryProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!') .min(1, 'Minimal harus ada 1 produk!')
@@ -170,7 +162,6 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(), }).nullable(),
source_warehouse_id: Yup.number() source_warehouse_id: Yup.number()
.required('Gudang asal wajib diisi!') .required('Gudang asal wajib diisi!')
.min(1, 'Gudang asal wajib diisi!')
.typeError('Gudang asal wajib diisi!'), .typeError('Gudang asal wajib diisi!'),
destination_warehouse: Yup.object({ destination_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -180,7 +171,6 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
}).nullable(), }).nullable(),
destination_warehouse_id: Yup.number() destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!') .required('Gudang tujuan wajib diisi!')
.min(1, 'Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!') .typeError('Gudang tujuan wajib diisi!')
.test( .test(
'different-warehouse', 'different-warehouse',
@@ -237,24 +227,21 @@ export const getMovementFormInitialValues = (
} }
: null, : null,
destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
products: initialValues?.details?.map((detail) => ({ products:
initialValues?.details?.map((detail) => ({
product: { product: {
value: detail.product.id, value: detail.product.id,
label: detail.product.name, label: detail.product.name,
}, },
product_id: detail.product.id, product_id: detail.product.id,
product_qty: detail.quantity, product_qty: detail.quantity,
})) ?? [ })) ?? [],
{ deliveries:
product: null, initialValues?.deliveries?.map((d) => ({
product_id: 0,
product_qty: '',
},
],
deliveries: initialValues?.deliveries?.map((d) => ({
delivery_cost: d.shipping_cost_total ?? undefined, delivery_cost: d.shipping_cost_total ?? undefined,
delivery_cost_per_item: d.shipping_cost_item ?? undefined, delivery_cost_per_item: d.shipping_cost_item ?? undefined,
document: d.document ?? null, document_number: d.document_number ?? '',
document: d.document_path ?? null,
document_path: d.document_path ?? null, document_path: d.document_path ?? null,
driver_name: d.driver_name ?? '', driver_name: d.driver_name ?? '',
vehicle_plate: d.vehicle_plate ?? '', vehicle_plate: d.vehicle_plate ?? '',
@@ -275,24 +262,6 @@ export const getMovementFormInitialValues = (
product_qty: item.quantity, product_qty: item.quantity,
}; };
}) ?? [], }) ?? [],
})) ?? [ })) ?? [],
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
document: null,
document_path: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{
product: null,
product_id: 0,
product_qty: '',
},
],
},
],
}; };
}; };
@@ -35,9 +35,6 @@ import FileInput from '@/components/input/FileInput';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -55,12 +52,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
] = useState(''); ] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createMovementHandler = useCallback( const createMovementHandler = useCallback(
async (payload: CreateMovementPayload) => { async (payload: CreateMovementPayload, documents: File[] = []) => {
const res = await MovementApi.createMovement(payload); const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
const res = await MovementApi.create(
formData as unknown as CreateMovementPayload
);
if (isResponseError(res)) { if (isResponseError(res)) {
setMovementFormErrorMessage(res.message); setMovementFormErrorMessage(res.message);
return; return;
@@ -189,45 +193,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return; return;
} }
const documents: File[] = []; const documents: File[] = [];
const documentNameToIndex = new Map<string, number>();
let sequentialDocumentIndex = 0;
const deliveriesPayload = values.deliveries.map((d) => { const deliveriesPayload = values.deliveries.map((d) => {
let documentIndex = -1; let documentIndex = 0;
if (d.document && d.document instanceof File) { if (d.document && d.document instanceof File) {
const fileName = d.document.name;
if (documentNameToIndex.has(fileName)) {
documentIndex = documentNameToIndex.get(fileName)!;
} else {
documents.push(d.document); documents.push(d.document);
documentIndex = sequentialDocumentIndex; documentIndex = documents.length - 1;
documentNameToIndex.set(fileName, documentIndex);
sequentialDocumentIndex++;
}
} else if (d.document_path) {
const pathFileName =
d.document_path.split('/').pop() || d.document_path;
if (documentNameToIndex.has(pathFileName)) {
documentIndex = documentNameToIndex.get(pathFileName)!;
} else {
documentIndex = sequentialDocumentIndex;
documentNameToIndex.set(pathFileName, documentIndex);
sequentialDocumentIndex++;
}
} else if (d.document && !(d.document instanceof File)) {
const existingDocFileName =
d.document.path.split('/').pop() || d.document.path;
if (documentNameToIndex.has(existingDocFileName)) {
documentIndex = documentNameToIndex.get(existingDocFileName)!;
} else {
documentIndex = sequentialDocumentIndex;
documentNameToIndex.set(existingDocFileName, documentIndex);
sequentialDocumentIndex++;
}
} }
return { return {
@@ -235,6 +206,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
delivery_cost_per_item: delivery_cost_per_item:
parseInt((d.delivery_cost_per_item || '').toString()) || 0, parseInt((d.delivery_cost_per_item || '').toString()) || 0,
document_index: documentIndex, document_index: documentIndex,
document_path: d.document_path,
driver_name: d.driver_name, driver_name: d.driver_name,
vehicle_plate: d.vehicle_plate, vehicle_plate: d.vehicle_plate,
supplier_id: d.supplier_id, supplier_id: d.supplier_id,
@@ -246,7 +218,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}); });
const payload: CreateMovementPayload = { const payload: CreateMovementPayload = {
data: {
transfer_reason: values.transfer_reason, transfer_reason: values.transfer_reason,
transfer_date: values.transfer_date, transfer_date: values.transfer_date,
source_warehouse_id: values.source_warehouse_id, source_warehouse_id: values.source_warehouse_id,
@@ -256,13 +227,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
product_qty: parseInt(p.product_qty.toString()) || 0, product_qty: parseInt(p.product_qty.toString()) || 0,
})), })),
deliveries: deliveriesPayload, deliveries: deliveriesPayload,
},
documents: documents.length > 0 ? documents : undefined,
}; };
switch (type) { switch (type) {
case 'add': case 'add':
await createMovementHandler(payload); await createMovementHandler(payload, documents);
break; break;
} }
}, },
@@ -796,36 +765,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type !== 'edit' && type !== 'edit' &&
type !== 'detail' type !== 'detail'
) { ) {
if (formik.values.products.length === 0) { formik.setFieldValue('products', []);
formik.setFieldValue('products', [ formik.setFieldValue('deliveries', []);
{
product: null,
product_id: 0,
product_qty: '',
},
]);
}
if (formik.values.deliveries.length === 0) {
formik.setFieldValue('deliveries', [
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
document: null,
document_path: null,
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{
product: null,
product_id: 0,
product_qty: '',
},
],
},
]);
}
} }
}, [formik.values.source_warehouse_id]); }, [formik.values.source_warehouse_id]);
@@ -854,22 +795,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.errors.destination_warehouse_id, formik.errors.destination_warehouse_id,
]); ]);
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return;
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit(e);
};
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -889,29 +814,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{movementFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{movementFormErrorMessage}</span>
</div>
)}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* Top card - Movement details */} {/* Top card - Movement details */}
<Card <Card
title='Detail Movement' title='Detail Movement'
@@ -1195,7 +1101,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Produk Produk
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1204,7 +1110,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Qty Qty
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1217,7 +1123,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.products?.map((product, idx) => ( {formik.values.products?.map((product, idx) => (
<tr key={`product-row-${idx}-${product.product_id}`}> <tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td className='align-middle!'> <td className='!align-middle'>
<CheckboxInput <CheckboxInput
name={`product-${idx}`} name={`product-${idx}`}
checked={selectedProducts.includes(idx)} checked={selectedProducts.includes(idx)}
@@ -1409,7 +1315,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Produk Produk
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1418,7 +1324,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Qty Qty
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1427,7 +1333,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Supplier Supplier
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1436,7 +1342,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Plat Nomor Plat Nomor
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1446,7 +1352,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Biaya Pengiriman (Rp.) Biaya Pengiriman (Rp.)
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1455,7 +1361,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Biaya Per Item (Rp.) Biaya Per Item (Rp.)
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1464,7 +1370,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th> <th>
Nama Sopir Nama Sopir
<span <span
className='tooltip tooltip-error tooltip-bottom z-9999' className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required' data-tip='required'
> >
<span className='text-error'>*</span> <span className='text-error'>*</span>
@@ -1477,7 +1383,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{formik.values.deliveries?.map((delivery, idx) => ( {formik.values.deliveries?.map((delivery, idx) => (
<tr key={`delivery-row-${idx}`}> <tr key={`delivery-row-${idx}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td className='align-middle!'> <td className='!align-middle'>
<CheckboxInput <CheckboxInput
name={`delivery-${idx}`} name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)} checked={selectedDeliveries.includes(idx)}
@@ -1631,64 +1537,37 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
{type === 'detail' ? ( {type === 'detail' ? (
<> <>
<div className='flex flex-col items-start gap-2'> <div className='flex flex-col items-start gap-2'>
{delivery.document_path ? (
<Button <Button
color='primary' color='primary'
className='w-full min-w-52 flex items-center justify-center gap-2' className='w-full min-w-52 flex items-center justify-center gap-2'
href={`${S3_PUBLIC_BASE_URL}/${delivery.document_path.startsWith('/') ? delivery.document_path.slice(1) : delivery.document_path}`} disabled={!delivery.document_path}
href={delivery.document_path ?? undefined}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
> >
{delivery.document_path ? (
<>
<Icon <Icon
icon='material-symbols:file-open-outline' icon='material-symbols:file-open-outline'
width={20} width={20}
height={20} height={20}
/> />
Lihat Dokumen Lihat Dokumen
</Button> </>
) : delivery.document &&
delivery.document instanceof File === false ? (
<Button
color='primary'
className='w-full min-w-52 flex items-center justify-center gap-2'
href={`${S3_PUBLIC_BASE_URL}/${delivery.document.path.startsWith('/') ? delivery.document.path.slice(1) : delivery.document.path}`}
target='_blank'
rel='noopener noreferrer'
>
<Icon
icon='material-symbols:file-open-outline'
width={20}
height={20}
/>
<span className='truncate max-w-[200px]'>
{delivery.document.name}
</span>
</Button>
) : ( ) : (
<Button '-'
color='neutral'
className='w-full min-w-52 flex items-center justify-center gap-2 cursor-not-allowed'
disabled
>
<Icon
icon='material-symbols:description'
width={20}
height={20}
/>
Tidak ada dokumen
</Button>
)} )}
</Button>
</div> </div>
</> </>
) : ( ) : (
<FileInput <FileInput
accept='.pdf,.jpg,.jpeg,.png'
name={`deliveries.${idx}.document`} name={`deliveries.${idx}.document`}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (file.size > 5 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 5 MB!'); toast.error('Ukuran dokumen maksimal 2 MB!');
e.target.value = ''; e.target.value = '';
return; return;
} }
@@ -1845,6 +1724,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
disabled={ disabled={
hasInvalidQty || hasInvalidQty ||
hasExceededStock || hasExceededStock ||
!formik.isValid ||
formik.isSubmitting || formik.isSubmitting ||
(formik.values.source_warehouse_id === (formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id && formik.values.destination_warehouse_id &&
@@ -1857,6 +1737,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
)} )}
</div> </div>
{movementFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{movementFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
</> </>
+31 -124
View File
@@ -2,10 +2,7 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import SelectInput, { import SelectInput, { OptionType } from '@/components/input/SelectInput';
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
@@ -31,8 +28,6 @@ import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -69,7 +64,6 @@ const RowsOptionsMenu = ({
</Button> </Button>
</RequirePermission> </RequirePermission>
{props.row.original.latest_approval.step_number != 1 && ( {props.row.original.latest_approval.step_number != 1 && (
<>
<RequirePermission <RequirePermission
permissions={ permissions={
props.row.original.latest_approval.step_number == 3 props.row.original.latest_approval.step_number == 3
@@ -98,10 +92,8 @@ const RowsOptionsMenu = ({
Deliver Deliver
</Button> </Button>
</RequirePermission> </RequirePermission>
</>
)} )}
{props.row.original.latest_approval.step_number != 3 && ( {props.row.original.latest_approval.step_number != 3 && (
<>
<RequirePermission permissions='lti.marketing.sales_order.update'> <RequirePermission permissions='lti.marketing.sales_order.update'>
<Button <Button
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`} href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
@@ -113,7 +105,6 @@ const RowsOptionsMenu = ({
Edit Edit
</Button> </Button>
</RequirePermission> </RequirePermission>
</>
)} )}
<RequirePermission permissions='lti.marketing.sales_order.delete'> <RequirePermission permissions='lti.marketing.sales_order.delete'>
<Button <Button
@@ -133,6 +124,8 @@ const RowsOptionsMenu = ({
const MarketingTable = () => { const MarketingTable = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [approveAction, setApproveAction] = useState<'APPROVED' | 'REJECTED'>( const [approveAction, setApproveAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED' 'APPROVED'
@@ -142,68 +135,22 @@ const MarketingTable = () => {
const { permissionCheck } = useAuth(); const { permissionCheck } = useAuth();
const router = useRouter(); const router = useRouter();
const deleteModal = useModal();
const confirmationModal = useModal();
const productsModal = useModal();
const deliveryModal = useModal();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterToQueryString,
} = useTableFilter({
initial: {
search: '',
product_ids: '',
status: '',
customer_id: '',
page: 1,
limit: 10,
},
paramMap: {
page: 'page',
pageSize: 'limit',
product_ids: 'product_ids',
status: 'status',
customer_id: 'customer_id',
},
});
// ===== FETCH DATA =====
const { const {
data: marketing, data: marketing,
isLoading: isLoadingMarketing, isLoading: isLoadingMarketing,
mutate: refreshMarketing, mutate: refreshMarketing,
} = useSWR( } = useSWR(MarketingApi.basePath, MarketingApi.getAllFetcher);
`${MarketingApi.basePath}${getTableFilterToQueryString()}`,
MarketingApi.getAllFetcher
);
// ===== OPTIONS ===== const deleteModal = useModal();
const { const confirmationModal = useModal();
options: productsOptions, const productsModal = useModal();
isLoadingOptions: isLoadingProductsOptions, const deliveryModal = useModal();
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const {
options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const statusOptions = MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_number,
label: item.step_name,
}));
// ===== HANDLER =====
const searchChangeHandler = useCallback( const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value); setSearch(e.target.value);
updateFilter('page', 1); setPage(1);
updateFilter('search', e.target.value);
}, },
[] []
); );
@@ -211,8 +158,7 @@ const MarketingTable = () => {
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType; const newVal = val as OptionType;
setPageSize(newVal.value as number); setPageSize(newVal.value as number);
updateFilter('page', 1); setPage(1);
updateFilter('limit', newVal.value as number);
}, },
[] []
); );
@@ -317,6 +263,20 @@ const MarketingTable = () => {
); );
}; };
const {
state: tableFilterState,
updateFilter,
toQueryString: getTableFilterToQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const getRowCanSelect = (row: Row<Marketing>): boolean => { const getRowCanSelect = (row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval; const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED'; return approval?.step_number === 1 && approval?.action !== 'REJECTED';
@@ -367,7 +327,7 @@ const MarketingTable = () => {
</RequirePermission> </RequirePermission>
</div> </div>
<TableRowSizeSelector <TableRowSizeSelector
value={tableFilterState.pageSize} value={pageSize}
onChange={pageSizeChangeHandler} onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS} options={ROWS_OPTIONS}
className='flex sm:flex-row flex-col gap-3 items-end justify-end' className='flex sm:flex-row flex-col gap-3 items-end justify-end'
@@ -377,29 +337,7 @@ const MarketingTable = () => {
label='Product' label='Product'
isClearable isClearable
placeholder='Pilih product' placeholder='Pilih product'
options={productsOptions} options={[]}
isLoading={isLoadingProductsOptions}
value={
tableFilterState.product_ids
?.split(',')
.map((id) =>
productsOptions.find(
(option) => option.value === Number(id)
)
)
.filter(
(option): option is { value: number; label: string } =>
option !== undefined
) ?? null
}
onChange={(value: OptionType | OptionType[] | null) =>
updateFilter(
'product_ids',
(value as OptionType[])
?.map((item: OptionType) => item.value.toString())
.join(',') || ''
)
}
isMulti isMulti
/> />
{/* select status */} {/* select status */}
@@ -407,43 +345,14 @@ const MarketingTable = () => {
label='Status' label='Status'
isClearable isClearable
placeholder='Pilih status' placeholder='Pilih status'
options={statusOptions} options={[]}
value={
tableFilterState.status
? statusOptions.find(
(option) =>
option.value === Number(tableFilterState.status)
)
: null
}
onChange={(value: OptionType | OptionType[] | null) =>
updateFilter(
'status',
(value as OptionType)?.value.toString() || ''
)
}
/> />
{/* select customer */} {/* select customer */}
<SelectInput <SelectInput
label='Customer' label='Customer'
isClearable isClearable
placeholder='Pilih customer' placeholder='Pilih customer'
options={customersOptions} options={[]}
isLoading={isLoadingCustomersOptions}
value={
tableFilterState.customer_id
? customersOptions.find(
(option) =>
option.value === Number(tableFilterState.customer_id)
)
: null
}
onChange={(value: OptionType | OptionType[] | null) =>
updateFilter(
'customer_id',
(value as OptionType)?.value.toString() || ''
)
}
/> />
</TableRowSizeSelector> </TableRowSizeSelector>
</div> </div>
@@ -609,8 +518,8 @@ const MarketingTable = () => {
}, },
}, },
]} ]}
pageSize={tableFilterState.pageSize} pageSize={pageSize}
page={tableFilterState.page} page={page}
onPageChange={setPage} onPageChange={setPage}
className={{ className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full!',
@@ -682,7 +591,7 @@ const MarketingTable = () => {
<Modal <Modal
ref={productsModal.ref} ref={productsModal.ref}
className={{ className={{
modalBox: 'xs:max-w-2/5 z-100', modalBox: 'max-w-2/5 z-100',
}} }}
closeOnBackdrop closeOnBackdrop
> >
@@ -724,7 +633,6 @@ const MarketingTable = () => {
}, },
]} ]}
className={{ className={{
containerClassName: 'p-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
@@ -735,7 +643,6 @@ const MarketingTable = () => {
'px-6 py-3 last:flex last:flex-row last:justify-end', 'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
isLoading={isLoadingMarketing}
/> />
</Modal> </Modal>
</> </>
@@ -77,6 +77,10 @@ const MarketingDetail = ({
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const deliveryClickHandler = () => {
deliveryModal.openModal();
};
const deleteClickHandler = () => { const deleteClickHandler = () => {
deleteModal.openModal(); deleteModal.openModal();
}; };
@@ -124,10 +128,7 @@ const MarketingDetail = ({
return ( return (
<> <>
<div className='flex flex-col w-full gap-4'> <div className='flex flex-col w-full gap-4'>
<FormHeader <FormHeader title='Detail Sales Order' backUrl='/marketing' />
title={`Detail ${Number(initialValues?.latest_approval?.step_number) > 2 ? 'Delivery Order' : 'Sales Order'}`}
backUrl='/marketing'
/>
{!isLoadingApproval && approvals && ( {!isLoadingApproval && approvals && (
<ApprovalSteps approvals={approvals} /> <ApprovalSteps approvals={approvals} />
)} )}
@@ -164,7 +165,6 @@ const MarketingDetail = ({
</> </>
)} )}
{initialValues?.latest_approval?.step_number != 1 && ( {initialValues?.latest_approval?.step_number != 1 && (
<>
<RequirePermission <RequirePermission
permissions={ permissions={
initialValues?.latest_approval?.step_number == 3 initialValues?.latest_approval?.step_number == 3
@@ -187,7 +187,6 @@ const MarketingDetail = ({
Delivery Order Delivery Order
</Button> </Button>
</RequirePermission> </RequirePermission>
</>
)} )}
</div> </div>
@@ -205,23 +204,8 @@ const MarketingDetail = ({
No. Sales Order No. Sales Order
</td> </td>
<td>:</td> <td>:</td>
<td width='50%' className='font-mono'> <td width='50%'>{initialValues?.so_number}</td>
{initialValues?.so_number}
</td>
</tr> </tr>
{Number(initialValues?.latest_approval?.step_number) > 2 && (
<tr>
<td width='45%' className='font-semibold'>
No. Delivery Order
</td>
<td>:</td>
<td width='50%' className='font-mono'>
{initialValues?.delivery_order
?.map((item) => item.do_number)
.join(', ')}
</td>
</tr>
)}
<tr> <tr>
<td className='font-semibold'>Nama Pelanggan</td> <td className='font-semibold'>Nama Pelanggan</td>
<td>:</td> <td>:</td>
@@ -248,27 +232,12 @@ const MarketingDetail = ({
<td>{initialValues?.notes ?? '-'}</td> <td>{initialValues?.notes ?? '-'}</td>
</tr> </tr>
<tr> <tr>
<td className='font-semibold'>Dokumen Penjualan</td> <td className='font-semibold'>Dokumen</td>
<td>:</td> <td>:</td>
<td> <td>
<SalesOrderExport data={initialValues} /> <SalesOrderExport data={initialValues} />
</td> </td>
</tr> </tr>
{Number(initialValues?.latest_approval?.step_number) > 2 && (
<tr>
<td className='font-semibold'>Dokumen Pengiriman</td>
<td>:</td>
<td className='flex flex-wrap gap-2'>
{initialValues?.delivery_order?.map((item, index) => (
<DeliveryOrderExport
key={index}
data={initialValues}
deliveryOrder={item}
/>
))}
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -458,18 +427,16 @@ const MarketingDetail = ({
)} )}
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
{initialValues?.latest_approval?.step_number != 3 && ( {initialValues?.latest_approval?.step_number != 3 && (
<>
<RequirePermission permissions='lti.marketing.sales_order.update'> <RequirePermission permissions='lti.marketing.sales_order.update'>
<Button <Button
color='warning' color='warning'
type='button' type='button'
href={`/marketing/detail/${initialValues?.latest_approval?.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`} href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
> >
<Icon icon='mdi:pencil' width={24} height={24} /> <Icon icon='mdi:pencil' width={24} height={24} />
Edit Edit
</Button> </Button>
</RequirePermission> </RequirePermission>
</>
)} )}
<RequirePermission permissions='lti.marketing.sales_order.delete'> <RequirePermission permissions='lti.marketing.sales_order.delete'>
<Button color='error' onClick={deleteClickHandler}> <Button color='error' onClick={deleteClickHandler}>
@@ -48,8 +48,6 @@ import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema'; import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema'; import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -560,14 +558,11 @@ const MarketingForm = ({
); );
}, [memoSalesOrder]); }, [memoSalesOrder]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4'
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
> >
<FormHeader <FormHeader
@@ -640,6 +635,12 @@ const MarketingForm = ({
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
> >
{/* <div className='text-blue-500'>
{JSON.stringify(formik.values)}
</div>
<div className='text-red-500'>
{JSON.stringify(formik.errors)}
</div> */}
<MemoizedDeliveryOrderProductTable <MemoizedDeliveryOrderProductTable
formType={formType} formType={formType}
data={deliveryOrderValues} data={deliveryOrderValues}
@@ -671,8 +672,6 @@ const MarketingForm = ({
</div> </div>
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{/* Form Actions */} {/* Form Actions */}
<div className='flex flex-row items-start justify-center gap-2 mt-4'> <div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}> <Button type='reset' color='warning' disabled={formik.isSubmitting}>
@@ -680,7 +679,7 @@ const MarketingForm = ({
</Button> </Button>
<Button <Button
type='submit' type='submit'
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
> >
Submit Submit
@@ -15,9 +15,6 @@ import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm'; import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
@@ -42,7 +39,6 @@ const DeliveryOrderProductForm = ({
null null
); );
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const salesOrder = salesOrders.find( const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
@@ -167,14 +163,15 @@ const DeliveryOrderProductForm = ({
} }
}, [initialValues]); }, [initialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={handleFormSubmit} onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formikErrorMessage && ( {formikErrorMessage && (
@@ -211,7 +208,7 @@ const DeliveryOrderProductForm = ({
...formik.values, ...formik.values,
marketing_product_id: undefined, marketing_product_id: undefined,
marketing_product: null, marketing_product: null,
qty: '', qty: formik.values.qty || '',
unit_price: '', unit_price: '',
total_price: '', total_price: '',
avg_weight: '', avg_weight: '',
@@ -225,7 +222,7 @@ const DeliveryOrderProductForm = ({
...formik.values, ...formik.values,
marketing_product_id: selected.value as number, marketing_product_id: selected.value as number,
marketing_product: SalesProductToFieldValues(so), marketing_product: SalesProductToFieldValues(so),
qty: so.qty, qty: formik.values.qty || so.qty,
unit_price: so.unit_price, unit_price: so.unit_price,
total_price: so.total_price, total_price: so.total_price,
avg_weight: so.avg_weight, avg_weight: so.avg_weight,
@@ -301,18 +298,8 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)} isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
bottomLabel={
formik.values.marketing_product_id
? 'Stok dijual: ' +
salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id
)?.qty
: ''
}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required required
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
@@ -374,8 +361,6 @@ const DeliveryOrderProductForm = ({
/> />
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'> <Button type='reset' color='warning'>
Reset Reset
@@ -383,7 +368,7 @@ const DeliveryOrderProductForm = ({
<Button <Button
type='submit' type='submit'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -25,19 +25,15 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
id: Yup.number(), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'), vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({ kandang: Yup.object({
value: Yup.number() value: Yup.number().min(1).required(),
.min(1, 'Kandang wajib diisi!') label: Yup.string().required(),
.required('Kandang wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'),
}).nullable(), }).nullable(),
kandang_id: Yup.number() kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!') .min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'), .required('Kandang wajib diisi!'),
product_warehouse: Yup.object({ product_warehouse: Yup.object({
value: Yup.number() value: Yup.number().min(1).required(),
.min(1, 'Produk wajib diisi!') label: Yup.string().required(),
.required('Produk wajib diisi!'),
label: Yup.string().required('Produk wajib diisi!'),
}).nullable(), }).nullable(),
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.min(1, 'Produk wajib diisi!') .min(1, 'Produk wajib diisi!')
@@ -11,21 +11,15 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data'; import { KandangApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { import { formatVechicleNumber } from '@/lib/helper';
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput'; import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -40,7 +34,6 @@ const SalesOrderProductForm = ({
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
// ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
@@ -65,7 +58,6 @@ const SalesOrderProductForm = ({
isInitialValid: false, isInitialValid: false,
}); });
// ===== Options =====
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
@@ -94,13 +86,12 @@ const SalesOrderProductForm = ({
); );
}, [warehouseSourceOptions, exisitingValues]); }, [warehouseSourceOptions, exisitingValues]);
// ===== Handler =====
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType); formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value); formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null); formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('qty', ''); formik.setFieldValue('qty', null);
}; };
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -115,7 +106,7 @@ const SalesOrderProductForm = ({
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
formik.setFieldValue('qty', ''); formik.setFieldValue('qty', null);
} }
}; };
@@ -171,14 +162,15 @@ const SalesOrderProductForm = ({
} }
}; };
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={handleFormSubmit} onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formErrorMessage && ( {formErrorMessage && (
@@ -188,6 +180,9 @@ const SalesOrderProductForm = ({
</Alert> </Alert>
</div> </div>
)} )}
{/* <small className='block text-rose-500'>
{JSON.stringify(formik.errors)}
</small> */}
<div className='grid sm:grid-cols-2 gap-4 z-200'> <div className='grid sm:grid-cols-2 gap-4 z-200'>
<PatternInput <PatternInput
name='vehicle_number' name='vehicle_number'
@@ -256,24 +251,7 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)} isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
bottomLabel={
isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id
? `Stok tersedia: ${formatNumber(
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0
)} ${
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.product?.uom?.name ?? ''
}`
: ''
}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<NumberInput <NumberInput
required required
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
@@ -339,9 +317,6 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Penjualan' placeholder='Masukan Total Penjualan'
/> />
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning' onClick={handleResetForm}> <Button type='reset' color='warning' onClick={handleResetForm}>
Reset Reset
@@ -349,7 +324,7 @@ const SalesOrderProductForm = ({
<Button <Button
type='submit' type='submit'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -32,6 +32,38 @@ const DeliveryOrderProductTable = ({
const columns = useMemo(() => { const columns = useMemo(() => {
const cols = [ const cols = [
// {
// id: 'select',
// header: ({
// table,
// }: {
// table: TanStack.Table<DeliveryOrderProductFormValues>;
// }) => (
// <div className='w-full flex flex-row justify-center'>
// <CheckboxInput
// name='allRow'
// checked={table.getIsAllRowsSelected()}
// indeterminate={table.getIsSomeRowsSelected()}
// onChange={table.getToggleAllRowsSelectedHandler()}
// />
// </div>
// ),
// cell: ({
// row,
// }: {
// row: TanStack.Row<DeliveryOrderProductFormValues>;
// }) => (
// <div>
// <CheckboxInput
// name='row'
// checked={row.getIsSelected()}
// disabled={!row.getCanSelect()}
// indeterminate={row.getIsSomeSelected()}
// onChange={row.getToggleSelectedHandler()}
// />
// </div>
// ),
// },
{ {
accessorFn: (row: DeliveryOrderProductFormValues) => row.do_number, accessorFn: (row: DeliveryOrderProductFormValues) => row.do_number,
header: 'No. Pengiriman', header: 'No. Pengiriman',
@@ -156,6 +188,18 @@ const DeliveryOrderProductTable = ({
</Button> </Button>
)} )}
{!props.row.original.qty && '-'} {!props.row.original.qty && '-'}
{/* {formType == 'add_deliver' && (
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
)} */}
</> </>
</div> </div>
), ),
@@ -204,6 +248,22 @@ const DeliveryOrderProductTable = ({
<Icon icon='mdi:plus' width={16} height={16} /> <Icon icon='mdi:plus' width={16} height={16} />
Tambah Pengiriman Tambah Pengiriman
</Button> </Button>
{/* {selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Pengiriman
</Button>
)} */}
</div> </div>
</> </>
); );
@@ -25,8 +25,6 @@ import {
} from '@/types/api/master-data/area'; } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface AreaFormProps { interface AreaFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -120,9 +118,6 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -144,7 +139,7 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -204,8 +199,6 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -220,7 +213,7 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -25,8 +25,6 @@ import {
} from '@/types/api/master-data/bank'; } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data'; import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface BankFormProps { interface BankFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -126,9 +124,6 @@ const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -150,7 +145,7 @@ const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -252,8 +247,6 @@ const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -268,7 +261,7 @@ const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -28,8 +28,6 @@ import useSWR from 'swr';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
import { TYPE_OPTIONS } from '@/config/constant'; import { TYPE_OPTIONS } from '@/config/constant';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface CustomerFormProps { interface CustomerFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -193,9 +191,6 @@ const CustomerForm = ({
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// Render // Render
return ( return (
<> <>
@@ -218,7 +213,7 @@ const CustomerForm = ({
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -363,8 +358,6 @@ const CustomerForm = ({
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{formType !== 'detail' && ( {formType !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -379,7 +372,7 @@ const CustomerForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -26,8 +26,6 @@ import {
} from '@/types/api/master-data/fcr'; } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data'; import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface FcrFormProps { interface FcrFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -160,9 +158,6 @@ const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-5xl'> <section className='w-full max-w-5xl'>
@@ -184,7 +179,7 @@ const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -299,8 +294,6 @@ const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
)} )}
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
@@ -356,7 +349,7 @@ const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -17,8 +17,6 @@ import TextInput from '@/components/input/TextInput';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface FlockCustomProps { interface FlockCustomProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -88,9 +86,6 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
formikSetValues(formikInitialValue); formikSetValues(formikInitialValue);
}, [formikSetValues, formikInitialValue]); }, [formikSetValues, formikInitialValue]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// Render // Render
return ( return (
<> <>
@@ -112,7 +107,7 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -173,8 +168,6 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{formType !== 'detail' && ( {formType !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -189,7 +182,7 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -29,8 +29,6 @@ import { LocationApi, KandangApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface KandangFormProps { interface KandangFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -200,9 +198,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -224,7 +219,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -329,8 +324,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -345,7 +338,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -27,8 +27,6 @@ import {
} from '@/types/api/master-data/location'; } from '@/types/api/master-data/location';
import { AreaApi, LocationApi } from '@/services/api/master-data'; import { AreaApi, LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface LocationFormProps { interface LocationFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -162,9 +160,6 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -186,7 +181,7 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -273,8 +268,6 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -289,7 +282,7 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -29,8 +29,6 @@ import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { flags } from '@/types/api/api-general'; import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant'; import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface NonstockFormProps { interface NonstockFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -215,9 +213,6 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -239,7 +234,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -342,8 +337,6 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -358,7 +351,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -11,7 +11,6 @@ import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProductCategoryFormSchema, ProductCategoryFormSchema,
@@ -26,7 +25,6 @@ import {
} from '@/types/api/master-data/product-category'; } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface ProductCategoryFormProps { interface ProductCategoryFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -131,9 +129,6 @@ const ProductCategoryForm = ({
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-2xl'> <section className='w-full max-w-2xl'>
@@ -155,23 +150,10 @@ const ProductCategoryForm = ({
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<TextInput <TextInput
required required
@@ -254,7 +236,7 @@ const ProductCategoryForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -262,6 +244,17 @@ const ProductCategoryForm = ({
</div> </div>
)} )}
</div> </div>
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
@@ -29,38 +29,36 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({ uom: Yup.object({
value: Yup.number() value: Yup.number().min(1).required(),
.min(1, 'Satuan wajib dipilih!') label: Yup.string().required(),
.required('Satuan wajib dipilih!'), })
label: Yup.string().required('Satuan wajib dipilih!'), .nullable()
}).nullable(), .required('Satuan wajib diisi!'),
uom_id: Yup.number() uom_id: Yup.number()
.min(1, 'Satuan wajib dipilih!') .required('Satuan wajib diisi!')
.required('Satuan wajib dipilih!') .typeError('Satuan wajib diisi!'),
.typeError('Satuan wajib dipilih!'),
product_category: Yup.object({ product_category: Yup.object({
value: Yup.number() value: Yup.number().min(1).required(),
.min(1, 'Kategori produk wajib dipilih!') label: Yup.string().required(),
.required('Kategori produk wajib dipilih!'), })
label: Yup.string().required('Kategori produk wajib dipilih!'), .nullable()
}).nullable(), .required('Kategori produk wajib diisi!'),
product_category_id: Yup.number() product_category_id: Yup.number()
.min(1, 'Kategori produk wajib dipilih!') .required('Kategori produk wajib diisi!')
.required('Kategori produk wajib dipilih!') .typeError('Kategori produk wajib diisi!'),
.typeError('Kategori produk wajib dipilih!'),
product_price: Yup.number() product_price: Yup.number()
.required('Harga produk wajib diisi!') .required('Harga produk wajib diisi!')
.typeError('Harga produk wajib diisi!') .typeError('Harga produk wajib diisi!')
.min(1, 'Harga produk tidak boleh kurang dari 1!'), .min(0, 'Harga produk tidak boleh kurang dari 0!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!') .typeError('Harga jual wajib diisi!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'), .min(0, 'Harga jual tidak boleh kurang dari 0!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .required('Pajak wajib diisi!')
@@ -71,7 +69,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa wajib diisi!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'), .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
supplier_ids: Yup.array() supplier_ids: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(Yup.number().required().typeError('Supplier tidak valid!'))
@@ -17,8 +17,6 @@ import SelectInput, {
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
ProductFormSchema, ProductFormSchema,
@@ -39,7 +37,6 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface ProductFormProps { interface ProductFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -204,9 +201,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-2xl'> <section className='w-full max-w-2xl'>
@@ -226,24 +220,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</h1> </h1>
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{productFormErrorMessage && ( <div className='flex flex-col gap-4'>
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='grid grid-cols-1 gap-4'>
<TextInput <TextInput
required required
label='Nama' label='Nama'
@@ -256,7 +237,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.name} errorMessage={formik.errors.name}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<div className='grid sm:grid-cols-2 gap-4'>
<TextInput <TextInput
required required
label='Merek' label='Merek'
@@ -281,8 +261,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.sku} errorMessage={formik.errors.sku}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
label='Satuan' label='Satuan'
@@ -292,10 +270,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
options={uomOptions} options={uomOptions}
onInputChange={setUomSelectInputValue} onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms} isLoading={isLoadingUoms}
isError={ isError={formik.touched.uom_id && Boolean(formik.errors.uom_id)}
(formik.touched.uom || formik.touched.uom_id) &&
Boolean(formik.errors.uom_id)
}
errorMessage={formik.errors.uom_id as string} errorMessage={formik.errors.uom_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
@@ -310,16 +285,13 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onInputChange={setCategorySelectInputValue} onInputChange={setCategorySelectInputValue}
isLoading={isLoadingCategories} isLoading={isLoadingCategories}
isError={ isError={
(formik.touched.product_category || formik.touched.product_category_id &&
formik.touched.product_category_id) &&
Boolean(formik.errors.product_category_id) Boolean(formik.errors.product_category_id)
} }
errorMessage={formik.errors.product_category_id as string} errorMessage={formik.errors.product_category_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required required
label='Harga Produk' label='Harga Produk'
@@ -360,8 +332,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.selling_price as string} errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required required
label='Pajak (%)' label='Pajak (%)'
@@ -399,8 +369,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.expiry_period as string} errorMessage={formik.errors.expiry_period as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
label='Supplier' label='Supplier'
@@ -443,7 +411,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable isClearable
/> />
</div> </div>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
@@ -496,7 +463,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -504,6 +471,16 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div> </div>
)} )}
</div> </div>
{productFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
{type !== 'add' && ( {type !== 'add' && (
@@ -18,7 +18,6 @@ const LayingRepeaterFormSchema = Yup.object({
), ),
target_egg_weight: Yup.number().required('Berat telur wajib diisi!'), target_egg_weight: Yup.number().required('Berat telur wajib diisi!'),
target_egg_mass: Yup.number().required('Massa telur wajib diisi!'), target_egg_mass: Yup.number().required('Massa telur wajib diisi!'),
standard_fcr: Yup.number().required('FCR wajib diisi!'),
}).required(), }).required(),
}); });
@@ -36,7 +35,6 @@ const GrowingRepeaterFormSchema = Yup.object({
target_hen_house_production: Yup.number().optional(), target_hen_house_production: Yup.number().optional(),
target_egg_weight: Yup.number().optional(), target_egg_weight: Yup.number().optional(),
target_egg_mass: Yup.number().optional(), target_egg_mass: Yup.number().optional(),
standard_fcr: Yup.number().optional(),
}).optional(), }).optional(),
}); });
@@ -9,7 +9,6 @@ import {
ProductionStandardRepeaterFormSchemaValues, ProductionStandardRepeaterFormSchemaValues,
ProductionStandardFormValues, ProductionStandardFormValues,
createProductionStandardRepeaterFormSchema, createProductionStandardRepeaterFormSchema,
ProductionStandardFormSchema,
} from '@/components/pages/master-data/production-standard/form/ProductionStandardForm.schema'; } from '@/components/pages/master-data/production-standard/form/ProductionStandardForm.schema';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant'; import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant';
@@ -31,9 +30,6 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import Alert from '@/components/Alert';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
type TableRowsType = { type TableRowsType = {
customRow: boolean; customRow: boolean;
@@ -46,7 +42,6 @@ type ProductionDetailsErrors = {
target_hen_house_production?: string; target_hen_house_production?: string;
target_egg_weight?: string; target_egg_weight?: string;
target_egg_mass?: string; target_egg_mass?: string;
standard_fcr?: string;
}; };
type ProductionDetailsTouched = { type ProductionDetailsTouched = {
@@ -54,7 +49,6 @@ type ProductionDetailsTouched = {
target_hen_house_production?: boolean; target_hen_house_production?: boolean;
target_egg_weight?: boolean; target_egg_weight?: boolean;
target_egg_mass?: boolean; target_egg_mass?: boolean;
standard_fcr?: boolean;
}; };
const getProductionDetailsError = ( const getProductionDetailsError = (
@@ -98,9 +92,6 @@ const convertPayloadToNumberTypes = (payload: ProductionStandardFormValues) => {
target_egg_mass: Number( target_egg_mass: Number(
detail.production_standard_details.target_egg_mass detail.production_standard_details.target_egg_mass
), ),
standard_fcr: Number(
detail.production_standard_details.standard_fcr
),
} }
: undefined, : undefined,
production_standard_uniformity_details: { production_standard_uniformity_details: {
@@ -141,9 +132,6 @@ const convertStandardValueToFormValues = (
target_egg_mass: Number( target_egg_mass: Number(
detail.egg_production_standard_detail.target_egg_mass detail.egg_production_standard_detail.target_egg_mass
), ),
standard_fcr: Number(
detail.egg_production_standard_detail.standard_fcr
),
} }
: undefined, : undefined,
production_standard_uniformity_details: { production_standard_uniformity_details: {
@@ -210,7 +198,6 @@ const ProductionStandardForm = ({
initialValues: formikInitialValues as ProductionStandardFormValues, initialValues: formikInitialValues as ProductionStandardFormValues,
// Only enable reinitialize for edit/detail mode, not add mode // Only enable reinitialize for edit/detail mode, not add mode
enableReinitialize: formType !== 'add', enableReinitialize: formType !== 'add',
validationSchema: ProductionStandardFormSchema,
onSubmit: (values) => { onSubmit: (values) => {
switch (formType) { switch (formType) {
case 'add': case 'add':
@@ -239,7 +226,6 @@ const ProductionStandardForm = ({
target_hen_house_production: '' as unknown as number, target_hen_house_production: '' as unknown as number,
target_egg_weight: '' as unknown as number, target_egg_weight: '' as unknown as number,
target_egg_mass: '' as unknown as number, target_egg_mass: '' as unknown as number,
standard_fcr: '' as unknown as number,
}, },
production_standard_uniformity_details: { production_standard_uniformity_details: {
target_mean_bw: '' as unknown as number, target_mean_bw: '' as unknown as number,
@@ -378,12 +364,6 @@ const ProductionStandardForm = ({
row.production_standard_details?.target_egg_mass, row.production_standard_details?.target_egg_mass,
enableSorting: false, enableSorting: false,
}, },
{
header: 'FCR',
accessorFn: (row) =>
row.production_standard_details?.standard_fcr,
enableSorting: false,
},
] ]
: []; : [];
@@ -696,7 +676,6 @@ const ProductionStandardForm = ({
target_hen_house_production: 0, target_hen_house_production: 0,
target_egg_weight: 0, target_egg_weight: 0,
target_egg_mass: 0, target_egg_mass: 0,
standard_fcr: 0,
}, },
})); }));
} }
@@ -727,8 +706,7 @@ const ProductionStandardForm = ({
router.push('/master-data/production-standard'); router.push('/master-data/production-standard');
}; };
// ===== Formik Error List ===== // ===== Function =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
@@ -831,7 +809,7 @@ const ProductionStandardForm = ({
className={cn( className={cn(
'grid gap-4 items-start', 'grid gap-4 items-start',
formik.values.project_category === 'LAYING' formik.values.project_category === 'LAYING'
? 'grid-cols-10' ? 'grid-cols-9'
: 'grid-cols-5' : 'grid-cols-5'
)} )}
> >
@@ -990,41 +968,6 @@ const ProductionStandardForm = ({
) )
} }
/> />
<NumberInput
name='production_standard_details.standard_fcr'
label='FCR'
placeholder='1'
value={
repeaterFormik.values
.production_standard_details?.standard_fcr
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
'standard_fcr'
)}
isError={
Boolean(
getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
'standard_fcr'
)
) &&
getProductionDetailsTouched(
repeaterFormik.touched
.production_standard_details,
'standard_fcr'
)
}
/>
</> </>
)} )}
<NumberInput <NumberInput
@@ -1215,26 +1158,9 @@ const ProductionStandardForm = ({
return null; return null;
}} }}
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{productionStandardFormErrorMessage && (
<Alert color='error' className='w-full'>
<div className='flex items-center gap-2 stretch'>
<Icon icon='mdi:alert' />
<span>{productionStandardFormErrorMessage}</span>
</div>
<Icon
icon='mdi:close'
onClick={() => setProductionStandardFormErrorMessage('')}
className='ms-auto'
/>
</Alert>
)}
<form <form
className='flex justify-between mt-6 gap-2 flex-wrap' className='flex justify-between mt-6 gap-2 flex-wrap'
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
> >
{formType === 'detail' && ( {formType === 'detail' && (
<div className='gap-2 flex items-center'> <div className='gap-2 flex items-center'>
@@ -25,8 +25,6 @@ import TextArea from '@/components/input/TextArea';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface SupplierCustomProps { interface SupplierCustomProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -201,9 +199,6 @@ const SupplierForm = ({
formik.setFieldValue('category', val); formik.setFieldValue('category', val);
}; };
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// Render // Render
return ( return (
<> <>
@@ -226,7 +221,7 @@ const SupplierForm = ({
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -449,8 +444,6 @@ const SupplierForm = ({
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{formType !== 'detail' && ( {formType !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -465,7 +458,7 @@ const SupplierForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -25,8 +25,6 @@ import {
} from '@/types/api/master-data/uom'; } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data'; import { UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface UomFormProps { interface UomFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -120,9 +118,6 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -144,7 +139,7 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -204,8 +199,6 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -220,7 +213,7 @@ const UomForm = ({ type = 'add', initialValues }: UomFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -33,8 +33,6 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant'; import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
interface WarehouseFormProps { interface WarehouseFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -325,9 +323,6 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-xl'>
@@ -349,7 +344,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
</header> </header>
<form <form
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -426,8 +421,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
value={formik.values.kandang ?? undefined} value={formik.values.kandang ?? undefined}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
options={kandangOptions} options={kandangOptions}
onInputChange={setKandangSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingKandangs} isLoading={isLoadingLocations}
isError={ isError={
formik.touched.kandangId && Boolean(formik.errors.kandangId) formik.touched.kandangId && Boolean(formik.errors.kandangId)
} }
@@ -479,8 +474,6 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -495,7 +488,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -18,7 +18,6 @@ import { Icon } from '@iconify/react';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line'; import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { BaseApproval } from '@/types/api/api-general';
const ChickinFormKandang = ({ const ChickinFormKandang = ({
formType = 'add', formType = 'add',
initialValues, initialValues,
@@ -34,16 +33,11 @@ const ChickinFormKandang = ({
approvals, approvals,
isLoading: approvalsLoading, isLoading: approvalsLoading,
refresh: refreshApprovals, refresh: refreshApprovals,
rawDataApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.chickin_approval, latestApproval: initialValues?.approval,
approvalLines: CHICKINS_APPROVAL_LINE, approvalLines: CHICKINS_APPROVAL_LINE,
moduleName: 'CHICKINS', moduleName: 'CHICKINS',
moduleId: initialValues?.id.toString() ?? '', moduleId: initialValues?.id.toString() ?? '',
params: {
limit: 'limit',
group_step_number: false,
},
}); });
const afterSubmitFormChickin = () => { const afterSubmitFormChickin = () => {
@@ -186,7 +180,6 @@ const ChickinFormKandang = ({
</div> </div>
{openChickin && ( {openChickin && (
<ChickinLogsView <ChickinLogsView
rawDataApprovals={rawDataApprovals as BaseApproval[]}
initialValues={initialValues} initialValues={initialValues}
afterSubmit={afterSubmitFormChickin} afterSubmit={afterSubmitFormChickin}
/> />
@@ -8,7 +8,6 @@ import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin'; import { ChickinApi } from '@/services/api/production/chickin';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useState } from 'react'; import { useState } from 'react';
@@ -17,11 +16,9 @@ import toast from 'react-hot-toast';
const ChickinLogsView = ({ const ChickinLogsView = ({
initialValues, initialValues,
afterSubmit, afterSubmit,
rawDataApprovals,
}: { }: {
initialValues: ProjectFlockKandang; initialValues: ProjectFlockKandang;
afterSubmit?: () => void; afterSubmit?: () => void;
rawDataApprovals: BaseApproval[];
}) => { }) => {
const confirmModal = useModal(); const confirmModal = useModal();
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
@@ -63,15 +60,8 @@ const ChickinLogsView = ({
</div> </div>
) : ( ) : (
(initialValues?.chickins || []).map((chickin, index) => { (initialValues?.chickins || []).map((chickin, index) => {
const latestApproval = rawDataApprovals[0]; const isApproved = chickin.usage_qty !== 0;
const isApproved = const isPending = chickin.pending_usage_qty !== 0;
index == (initialValues?.chickins || []).length - 1
? latestApproval?.step_number === 2
: true;
const isPending =
index == (initialValues?.chickins || []).length - 1
? latestApproval?.step_number === 1
: false;
const quantity = isApproved const quantity = isApproved
? chickin.usage_qty ? chickin.usage_qty
: isPending : isPending
@@ -91,7 +81,7 @@ const ChickinLogsView = ({
{/* Header with Status Badge */} {/* Header with Status Badge */}
<div className='flex flex-row justify-between items-center'> <div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Chick In #{index + 1} - {latestApproval?.step_number} Chick In #{index + 1}
</div> </div>
<PillBadge <PillBadge
content={ content={
@@ -156,8 +146,7 @@ const ChickinLogsView = ({
}) })
)} )}
{initialValues.chickin_approval && {initialValues?.approval?.step_number <= 2 && (
initialValues?.chickin_approval?.step_number < 2 && (
<RequirePermission permissions='lti.production.chickins.approve'> <RequirePermission permissions='lti.production.chickins.approve'>
<Button <Button
color='success' color='success'
@@ -19,7 +19,7 @@ import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, SortingState } from '@tanstack/react-table';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -27,6 +27,84 @@ import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<ProjectFlock, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type == 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<div className='flex flex-col gap-1'>
<RequirePermission permissions='lti.production.project_flocks.detail'>
<Button
href={`/production/project-flock/detail?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
{props.row.original.approval.step_name === 'Aktif' && (
<RequirePermission permissions='lti.production.chickins.create'>
<Button
href={`/production/project-flock/chickin/add?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='success'
className='justify-start text-sm'
>
<Icon icon='mdi:home-import-outline' width={16} height={16} />
Chickin
</Button>
</RequirePermission>
)}
{props.row.original.approval.step_name === 'Pengajuan' && (
<RequirePermission permissions='lti.production.project_flocks.update'>
<Button
href={`/production/project-flock/detail/edit?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.production.project_flocks.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit justify-start text-sm'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
/>
Delete
</Button>
</RequirePermission>
</div>
</div>
);
};
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { const {
state: tableFilterState, state: tableFilterState,
@@ -71,6 +149,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
const [periodInputValue, setPeriodInputValue] = useState<number | null>(null); const [periodInputValue, setPeriodInputValue] = useState<number | null>(null);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedProjectFlock, setSelectedProjectFlock] =
useState<ProjectFlock>();
const deleteModal = useModal(); const deleteModal = useModal();
const confirmModal = useModal(); const confirmModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>( const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
@@ -141,21 +221,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
: []; : [];
// ====== HANDLER ====== // ====== HANDLER ======
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
const response = await ProjectFlockApi.delete( await ProjectFlockApi.delete(selectedSingleRow?.id as number);
selectedSingleRow?.id as number
);
if (isResponseSuccess(response)) {
toast.success(response?.message as string);
}
if (isResponseError(response)) {
toast.error(response?.message as string);
}
refreshProjectFlocks(); refreshProjectFlocks();
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Project Flock!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
setRowSelection({}); setRowSelection({});
}; };
@@ -208,146 +285,12 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const canApprove = useMemo(() => { const canApprove = useMemo(() => {
if (!selectedSingleRow || isApproveLoading) return false; if (!selectedSingleRow || isApproveLoading) return false;
const isPengajuan = selectedSingleRow.approval?.step_number == 1; const isPengajuan = selectedSingleRow.approval.step_number == 1;
const isNotRejected = selectedSingleRow.approval?.action != 'REJECTED'; const isNotRejected = selectedSingleRow.approval.action != 'REJECTED';
return isPengajuan && isNotRejected; return isPengajuan && isNotRejected;
}, [selectedSingleRow, isApproveLoading]); }, [selectedSingleRow, isApproveLoading]);
// ====== COLUMNS ======
const columns = useMemo<ColumnDef<ProjectFlock>[]>(
() => [
{
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows;
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
selectableRows.length != 0;
const someSelected =
selectableRows.some((row) => row.getIsSelected()) && !allSelected;
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
};
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
/>
</div>
);
},
cell: ({ row }) => {
return (
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
},
},
{
accessorKey: 'flock_name',
header: 'Flock',
},
{
accessorKey: 'area.name',
header: 'Area',
},
{
accessorKey: 'location.name',
header: 'Lokasi',
},
{
accessorKey: 'fcr.name',
header: 'FCR',
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
return (
<Badge
variant='soft'
className={{
badge: 'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'success'
: 'error'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'success'
: 'error'
}
/>
{approval?.step_name}
</Badge>
);
},
},
{
header: 'Kandang',
cell: (props) => {
const kandang = props.row.original.kandangs;
if (kandang) {
const kandangNames = kandang.map((k: Kandang) => k.name);
return (
<div>
{kandangNames.length > 0
? kandangNames.join(', ')
: 'Tidak ada'}
</div>
);
} else {
return '-';
}
},
},
{
accessorKey: 'period',
header: 'Periode',
},
{
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
formatDate(props.row.original.created_at, 'MMM DD, YYYY'),
},
],
[]
);
return ( return (
<> <>
<div className='min-h-screen w-full p-4'> <div className='min-h-screen w-full p-4'>
@@ -358,10 +301,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<Button <Button
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
onClick={() => { href='/production/project-flock/add'
setRowSelection({});
router.push('/production/project-flock/add');
}}
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Tambah
@@ -370,7 +310,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<div className='ms-auto w-full sm:w-auto'> <div className='ms-auto w-full sm:w-auto'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Cari Project Flock' placeholder='Cari Area'
value={tableFilterState.search} value={tableFilterState.search}
onChange={searchChangeHandler} onChange={searchChangeHandler}
className={{ className={{
@@ -432,18 +372,160 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
type='number' type='number'
label='Periode' label='Periode'
placeholder='Masukan periode' placeholder='Masukan periode'
value={periodInputValue?.toString() ?? ''} value={periodInputValue ?? ''}
onChange={(e) => { onChange={(e) => {
setPeriodInputValue(parseInt(e.target.value)); setPeriodInputValue(parseInt(e.target.value));
updateFilter('periodFilter', e.target.value); updateFilter('periodFilter', e.target.value);
}} }}
/> />
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div> </div>
</div> </div>
<Table<ProjectFlock> <Table<ProjectFlock>
data={isResponseSuccess(projectFlocks) ? projectFlocks?.data : []} data={isResponseSuccess(projectFlocks) ? projectFlocks?.data : []}
columns={columns} columns={[
{
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows;
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
selectableRows.length != 0;
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
};
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
/>
</div>
);
},
cell: ({ row }) => {
return (
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
},
},
{
accessorKey: 'flock_name',
header: 'Flock',
},
{
accessorKey: 'area.name',
header: 'Area',
},
{
accessorKey: 'location.name',
header: 'Lokasi',
},
{
accessorKey: 'fcr.name',
header: 'FCR',
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
/>
{approval.step_name}
</Badge>
);
},
},
{
header: 'Kandang',
cell: (props) => {
const kandang = props.row.original.kandangs;
if (kandang) {
const kandangNames = kandang.map((k: Kandang) => k.name);
return (
<div>
{kandangNames.length > 0
? kandangNames.join(', ')
: 'Tidak ada'}
</div>
);
} else {
return '-';
}
},
},
{
accessorKey: 'period',
header: 'Periode',
},
{
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
formatDate(props.row.original.created_at, 'MMM DD, YYYY'),
},
]}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={ page={
isResponseSuccess(projectFlocks) ? projectFlocks?.meta?.page : 0 isResponseSuccess(projectFlocks) ? projectFlocks?.meta?.page : 0
@@ -453,12 +535,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
? projectFlocks?.meta?.total_results ? projectFlocks?.meta?.total_results
: 0 : 0
} }
onPageChange={(page) => { onPageChange={setPage}
setPage(page);
}}
onPageSizeChange={(pageSize) => {
setPageSize(pageSize);
}}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={setSorting}
@@ -466,9 +543,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
className={{ className={{
containerClassName: cn({ containerClassName: cn({
'mb-40': 'mb-20':
isResponseSuccess(projectFlocks) && isResponseSuccess(projectFlocks) &&
projectFlocks?.data?.length > 0, projectFlocks?.data?.length === 0,
}), }),
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!',
@@ -209,6 +209,20 @@ const ProjectFlockDetail = ({
</Badge> </Badge>
</div> </div>
{/* <div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div> */}
{/* BARIS 1 */} {/* BARIS 1 */}
<div <div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2 className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
@@ -238,18 +252,6 @@ const ProjectFlockDetail = ({
</div> </div>
<div className='col-span-2'>{projectFlock?.fcr?.name}</div> <div className='col-span-2'>{projectFlock?.fcr?.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Standard
</div>
<div className='col-span-2'>
{projectFlock?.production_standard?.name ?? '-'}
</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */} {/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'> <div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '} <Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
@@ -64,9 +64,9 @@ export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSche
.min(1, 'Harga minimal 1!') .min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'), .required('Harga wajib diisi!'),
total_price: Yup.number() total_price: Yup.number()
.typeError('Total Harga harus berupa angka!') .typeError('Harga harus berupa angka!')
.min(1, 'Total Harga minimal 1!') .min(1, 'Harga minimal 1!')
.required('Total Harga wajib diisi!'), .required('Harga wajib diisi!'),
}); });
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> = export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
@@ -6,7 +6,6 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
AreaApi, AreaApi,
FcrApi, FcrApi,
@@ -39,6 +38,11 @@ import { BaseApiResponse } from '@/types/api/api-general';
import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant'; import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
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 NumberInput from '@/components/input/NumberInput';
import Card from '@/components/Card'; import Card from '@/components/Card';
import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable'; import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable';
@@ -46,7 +50,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface ProjectFlockFormProps { interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -68,7 +71,6 @@ const ProjectFlockForm = ({
useState(''); useState('');
const [selectedArea, setSelectedArea] = useState(''); const [selectedArea, setSelectedArea] = useState('');
const [selectedLocation, setSelectedLocation] = useState(''); const [selectedLocation, setSelectedLocation] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [disabledLocation, setDisabledLocation] = useState( const [disabledLocation, setDisabledLocation] = useState(
initialValues?.location?.id ? false : true initialValues?.location?.id ? false : true
); );
@@ -88,8 +90,18 @@ const ProjectFlockForm = ({
const setIsValid = useUiStore((s) => s.setIsValid); const setIsValid = useUiStore((s) => s.setIsValid);
const deleteModal = useModal(); const deleteModal = useModal();
const confirmModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isApprovedDisabled, setIsApprovedDisabled] = useState(
initialValues?.approval?.step_name == 'Pengajuan' ? false : true
);
const [isRejectedDisabled, setIsRejectedDisabled] =
useState(!isApprovedDisabled);
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
!isApprovedDisabled ? 'APPROVED' : 'REJECTED'
);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>( const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
() => () =>
@@ -128,15 +140,11 @@ const ProjectFlockForm = ({
const { const {
options: optionsProductionStandards, options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', { } = useSelect(ProductionStandardApi.basePath, 'id', 'name');
search: '',
project_category: selectedCategory,
});
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '', search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation, location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit',
}).toString()}`; }).toString()}`;
const { const {
data: kandang, data: kandang,
@@ -155,6 +163,17 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingNonstocks, isLoadingOptions: isLoadingNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name'); } = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const {
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: initialValues?.approval,
approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCKS',
moduleId: initialValues?.id.toString() ?? '',
});
useEffect(() => { useEffect(() => {
if (isResponseSuccess(kandang)) { if (isResponseSuccess(kandang)) {
if (selectedLocation) { if (selectedLocation) {
@@ -244,19 +263,9 @@ const ProjectFlockForm = ({
}; };
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
// Reset production standard when category is changed formik.setFieldValue('category', (val as OptionType)?.value);
formik.setFieldValue('production_standard_id', '');
formik.setFieldValue('production_standard', '');
formik.setFieldValue('category_option', val); formik.setFieldValue('category_option', val);
formik.setFieldValue('category', val ? (val as OptionType)?.value : ''); if (val == null) {
setSelectedCategory((val as OptionType)?.value as string);
if (Boolean(val)) {
formik.setFieldTouched('category', false);
formik.setFieldError('category', '');
} else {
formik.setFieldTouched('category', true); formik.setFieldTouched('category', true);
} }
}; };
@@ -395,6 +404,8 @@ const ProjectFlockForm = ({
validationSchema: validationSchema:
formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema,
validateOnBlur: true, validateOnBlur: true,
// validateOnChange: true,
// validateOnMount: true,
onSubmit: async (values) => { onSubmit: async (values) => {
setProjectFlockFormErrorMessage(''); setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = { const payload: CreateProjectFlockPayload = {
@@ -511,6 +522,19 @@ const ProjectFlockForm = ({
return unsub; 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 // Actions handler
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -564,6 +588,29 @@ const ProjectFlockForm = ({
} }
}; };
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'REJECTED' | 'APPROVED'
) => {
if (initialValues?.id === undefined) return;
setIsApproveLoading(true);
const approvalRes =
approvalAction == 'APPROVED'
? await ProjectFlockApi.approve(initialValues?.id, notes)
: await ProjectFlockApi.reject(initialValues?.id, notes);
if (isResponseSuccess(approvalRes)) {
refreshProjectFlocks?.();
toast.success(approvalRes.message as string);
}
if (isResponseError(approvalRes)) {
toast.error(approvalRes?.message as string);
}
refreshApprovals();
confirmModal.closeModal();
setIsApproveLoading(false);
};
const handleBudgetChange = ( const handleBudgetChange = (
index: number, index: number,
fieldName: 'qty' | 'price' | 'total_price', fieldName: 'qty' | 'price' | 'total_price',
@@ -641,9 +688,6 @@ const ProjectFlockForm = ({
return !isNonstockAlreadyInBudgets; return !isNonstockAlreadyInBudgets;
}); });
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
@@ -700,10 +744,50 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
)} )}
{approvals && !approvalsLoading && formType == 'detail' && (
<ApprovalSteps approvals={approvals} />
)}
{formType == 'detail' && (
<div className='w-full flex flex-col sm:flex-row gap-2 py-4'>
<RequirePermission permissions='lti.production.project_flocks.approve'>
<Button
variant='outline'
color='success'
onClick={() => {
if (initialValues?.id) {
setApprovalAction('APPROVED');
confirmModal.openModal();
}
}}
disabled={!initialValues?.id || isApprovedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
</RequirePermission>
<RequirePermission permissions='lti.production.project_flocks.approve'>
<Button
variant='outline'
color='error'
onClick={() => {
if (initialValues?.id) {
setApprovalAction('REJECTED');
confirmModal.openModal();
}
}}
disabled={!initialValues?.id || isRejectedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</RequirePermission>
</div>
)}
<form <form
className='w-auto h-auto' className='w-auto h-auto'
onSubmit={handleFormSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
> >
{/* Form Informasi Umum */} {/* Form Informasi Umum */}
@@ -788,19 +872,6 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
<SelectInput
required
label='Kategori'
value={formik.values.category_option as OptionType}
onChange={categoryChangeHandler}
options={FLOCK_CATEGORY_OPTIONS}
isError={
formik.touched.category && Boolean(formik.errors.category)
}
errorMessage={formik.errors.category as string}
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput <SelectInput
required required
label='Standar Produksi' label='Standar Produksi'
@@ -811,10 +882,23 @@ const ProjectFlockForm = ({
options={optionsProductionStandards} options={optionsProductionStandards}
isLoading={isLoadingProductionStandards} isLoading={isLoadingProductionStandards}
isError={ isError={
formik.touched.production_standard_id && formik.touched.production_standard &&
Boolean(formik.errors.production_standard_id) Boolean(formik.errors.production_standard)
} }
errorMessage={formik.errors.production_standard_id as string} errorMessage={formik.errors.production_standard as string}
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Kategori'
value={formik.values.category_option as OptionType}
onChange={categoryChangeHandler}
options={FLOCK_CATEGORY_OPTIONS}
isError={
formik.touched.category && Boolean(formik.errors.category)
}
errorMessage={formik.errors.category as string}
isClearable isClearable
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
@@ -1069,9 +1153,15 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'> <div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'>
{/* <div className='w-120'>
<div className='text-primary text-sm'>
{JSON.stringify(formik.values)}
</div>
<div className='text-error text-sm'>
{JSON.stringify(formik.errors)}
</div>
</div> */}
{formType !== 'detail' && ( {formType !== 'detail' && (
<RequirePermission <RequirePermission
permissions={ permissions={
@@ -1084,7 +1174,7 @@ const ProjectFlockForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
className='px-4 w-full' className='px-4 w-full'
> >
<Icon icon='mdi:plus' width={24} height={24} /> <Icon icon='mdi:plus' width={24} height={24} />
@@ -1110,6 +1200,27 @@ const ProjectFlockForm = ({
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
<ConfirmationModalWithNotes
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject'
} Project Flock berikut? (${initialValues?.flock_name} - ${
initialValues?.area?.name
})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: (notes) => {
confirmApprovalHandler(notes, approvalAction);
},
}}
/>
</> </>
); );
}; };
@@ -872,7 +872,7 @@ const RecordingTable = () => {
'mb-20': 'mb-20':
isResponseSuccess(recordings) && recordings?.data?.length === 0, isResponseSuccess(recordings) && recordings?.data?.length === 0,
}), }),
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full overflow-visible!',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName: headerColumnClassName:

Some files were not shown because too many files have changed in this diff Show More