From 8c7640eb9c0ac9f42e8fc8e78a6fa86b98623d62 Mon Sep 17 00:00:00 2001 From: randy-ar Date: Tue, 9 Dec 2025 18:14:46 +0700 Subject: [PATCH] feat(FE-333): adding feature overhead closing report --- src/components/helper/RequireAuth.tsx | 199 +++++++++++++++--- .../pages/closing/ClosingDetail.tsx | 3 +- .../closing/ClosingOverheadTabContent.tsx | 19 ++ .../pages/closing/ClosingOverheadTable.tsx | 159 ++++++++++++++ src/dummy/closing.dummy.ts | 152 +++++++++++++ src/services/api/closing.ts | 37 +++- src/types/api/closing.d.ts | 27 +++ 7 files changed, 558 insertions(+), 38 deletions(-) create mode 100644 src/components/pages/closing/ClosingOverheadTabContent.tsx create mode 100644 src/components/pages/closing/ClosingOverheadTable.tsx diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 119d74cb..dbd4b6bc 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -6,9 +6,147 @@ import useSWRImmutable from 'swr/immutable'; import { useAuth } from '@/services/hooks/useAuth'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; -import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general'; -import { AxiosError } from 'axios'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { GetMeResponse } from '@/types/api/api-general'; + +// TODO: delete this later, DONT HARDCODE USER DATA +const DUMMY_USER = { + id: 1, + email: 'admin@mbugroup.id', + npk: '0001', + name: 'Super Admin', + image: null, + created_at: '2025-09-30T03:24:20.899229Z', + updated_at: '2025-09-30T03:24:20.899229Z', + roles: [ + { + id: 1, + key: 'mbu.super_admin', + name: 'MBU Administrator', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + permissions: [ + { + id: 1, + name: 'mbu:purchase:read', + action: 'read', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + }, + { + id: 2, + name: 'mbu:purchase:create', + action: 'create', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + }, + { + id: 3, + name: 'mbu:purchase:approve', + action: 'approve', + client: { + id: 1, + name: 'PT Mitra Berlian Unggas', + alias: 'MBU', + }, + }, + ], + }, + { + id: 2, + key: 'lti.super_admin', + name: 'LTI Administrator', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + permissions: [ + { + id: 4, + name: 'lti:purchase:read', + action: 'read', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + }, + { + id: 5, + name: 'lti:purchase:create', + action: 'create', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + }, + { + id: 6, + name: 'lti:purchase:approve', + action: 'approve', + client: { + id: 2, + name: 'PT Lumbung Telur Indonesia', + alias: 'LTI', + }, + }, + ], + }, + { + id: 3, + key: 'manbu.super_admin', + name: 'MANBU Administrator', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + permissions: [ + { + id: 7, + name: 'manbu:purchase:read', + action: 'read', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + }, + { + id: 8, + name: 'manbu:purchase:create', + action: 'create', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + }, + { + id: 9, + name: 'manbu:purchase:approve', + action: 'approve', + client: { + id: 3, + name: 'PT Mandiri Berlian Unggas', + alias: 'MANBU', + }, + }, + ], + }, + ], +}; interface RequireAuthProps { children?: ReactNode; @@ -18,20 +156,17 @@ const RequireAuth = ({ children }: RequireAuthProps) => { const router = useRouter(); const { setUser, setIsLoadingUser } = useAuth(); - const { - data: userResponse, - isLoading: isLoadingUserResponse, - error: userErrorResponse, - } = useSWRImmutable< - GetMeResponse & { ok?: boolean }, - AxiosError, - SWRHttpKey - >('/sso/userinfo', httpClientFetcher, { - shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshInterval: 0, - }); + const { data: userResponse, isLoading: isLoadingUserResponse } = + useSWRImmutable( + '/auth/sso/userinfo', + httpClientFetcher, + { + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshInterval: 0, + } + ); useEffect(() => { setIsLoadingUser(isLoadingUserResponse); @@ -40,25 +175,23 @@ const RequireAuth = ({ children }: RequireAuthProps) => { useEffect(() => { if (isResponseSuccess(userResponse)) { setUser(userResponse.data); - } else if ( - isResponseError(userErrorResponse?.response?.data) && - typeof window !== 'undefined' - ) { - router.replace( - `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}` - ); + } else { + // router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); + // TODO: remove this later, DONT HARDCODE USER DATA + setUser(DUMMY_USER); } - }, [userResponse, userErrorResponse, setIsLoadingUser, setUser]); + }, [userResponse, setIsLoadingUser, setUser]); - if (isLoadingUserResponse && !userResponse && !userErrorResponse) { - return ( -
- -
- ); - } + // TODO: uncomment this later + // if (isLoadingUserResponse && !userResponse) { + // return ( + //
+ // + //
+ // ); + // } - return <>{isResponseSuccess(userResponse) && children}; + return <>{children}; }; export default RequireAuth; diff --git a/src/components/pages/closing/ClosingDetail.tsx b/src/components/pages/closing/ClosingDetail.tsx index 86f39d18..eff26135 100644 --- a/src/components/pages/closing/ClosingDetail.tsx +++ b/src/components/pages/closing/ClosingDetail.tsx @@ -10,6 +10,7 @@ import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGe import { ClosingGeneralInformation } from '@/types/api/closing'; import ClosingSapronakTabContent from './ClosingSapronakTabContent'; import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent'; +import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent'; interface ClosingDetailProps { id: number; @@ -39,7 +40,7 @@ const ClosingDetail: React.FC = ({ id, initialValue }) => { { id: 'overhead', label: 'Overhead', - content: 'Overhead', + content: , }, { id: 'hppEkspedisi', diff --git a/src/components/pages/closing/ClosingOverheadTabContent.tsx b/src/components/pages/closing/ClosingOverheadTabContent.tsx new file mode 100644 index 00000000..458cff0f --- /dev/null +++ b/src/components/pages/closing/ClosingOverheadTabContent.tsx @@ -0,0 +1,19 @@ +import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable'; + +interface ClosingOverheadTabContentProps { + projectFlockId: number; +} + +const ClosingOverheadTabContent = ({ + projectFlockId, +}: ClosingOverheadTabContentProps) => { + return ( +
+ {projectFlockId && ( + + )} +
+ ); +}; + +export default ClosingOverheadTabContent; diff --git a/src/components/pages/closing/ClosingOverheadTable.tsx b/src/components/pages/closing/ClosingOverheadTable.tsx new file mode 100644 index 00000000..ca5c2671 --- /dev/null +++ b/src/components/pages/closing/ClosingOverheadTable.tsx @@ -0,0 +1,159 @@ +import Card from '@/components/Card'; +import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { ClosingApi } from '@/services/api/closing'; +import { Overhead, OverheadTotal } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import useSWR from 'swr'; + +interface ClosingOverheadTableProps { + type?: 'detail'; + projectFlockId: number; +} + +const ClosingOverheadTable = ({ + type, + projectFlockId, +}: ClosingOverheadTableProps) => { + const { data: overhead, isLoading: isLoadingOverhead } = useSWR( + `${ClosingApi.basePath}/${projectFlockId}/overhead`, + () => ClosingApi.getOverhead(projectFlockId) + ); + + // Helper function to create columns with footer support + const createColumns = (total?: OverheadTotal): ColumnDef[] => [ + // Group untuk kolom tanpa footer + { + header: 'Nama Item', + accessorFn: (props) => props.item_name, + footer: 'Total Pengeluaran Overhead', + }, + { + header: 'Satuan', + accessorFn: (props) => props.uom_name, + }, + { + header: 'Budget Pengajuan', + footer: '', + columns: [ + { + id: 'budget_quantity', + header: 'Jumlah', + accessorFn: (props) => + props.budget_quantity ? formatNumber(props.budget_quantity) : '-', + footer: total ? () => formatNumber(total.budget_quantity) : '', + }, + { + id: 'budget_unit_price', + header: 'Harga Satuan', + accessorFn: (props) => + props.budget_unit_price + ? formatCurrency(props.budget_unit_price) + : '-', + footer: '', + }, + { + id: 'budget_total_amount', + header: 'Total', + accessorFn: (props) => + props.budget_total_amount + ? formatCurrency(props.budget_total_amount) + : '-', + footer: total ? () => formatCurrency(total.budget_total_amount) : '', + }, + ], + }, + { + header: 'Realisai', + footer: '', + columns: [ + { + id: 'actual_date', + header: 'Tanggal', + accessorFn: (props) => + props.actual_date + ? formatDate(props.actual_date, 'DD MMM, YYYY') + : '-', + footer: '', + }, + { + id: 'actual_quantity', + header: 'Jumlah', + accessorFn: (props) => + props.actual_quantity ? formatNumber(props.actual_quantity) : '-', + footer: total ? () => formatNumber(total.actual_quantity) : '', + }, + { + id: 'actual_unit_price', + header: 'Harga Satuan', + accessorFn: (props) => + props.actual_unit_price + ? formatCurrency(props.actual_unit_price) + : '-', + footer: '', + }, + { + id: 'actual_total_amount', + header: 'Total', + accessorFn: (props) => + props.actual_total_amount + ? formatCurrency(props.actual_total_amount) + : '-', + footer: total ? () => formatCurrency(total.actual_total_amount) : '', + }, + ], + }, + { + id: 'cost_per_bird', + header: 'Rp/Ekor', + accessorFn: (props) => + props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-', + footer: total ? () => formatCurrency(total.cost_per_bird) : '', + }, + ]; + + const columns = useMemo( + () => + isResponseSuccess(overhead) + ? createColumns(overhead.data?.total) + : createColumns(), + [overhead] + ); + + return ( + <> + + + data={ + isResponseSuccess(overhead) ? (overhead.data?.overheads ?? []) : [] + } + columns={columns} + className={{ + containerClassName: 'my-4', + headerColumnClassName: cn( + TABLE_DEFAULT_STYLING.headerColumnClassName, + 'whitespace-nowrap' + ), + }} + renderFooter={ + isResponseSuccess(overhead) + ? overhead.data?.overheads.length > 0 + : false + } + /> + + + ); +}; + +export default ClosingOverheadTable; diff --git a/src/dummy/closing.dummy.ts b/src/dummy/closing.dummy.ts index 8ebb0164..3a20cdaf 100644 --- a/src/dummy/closing.dummy.ts +++ b/src/dummy/closing.dummy.ts @@ -82,6 +82,7 @@ import { ClosingGeneralInformation, ClosingIncomingSapronak, ClosingOutgoingSapronak, + ClosingOverhead, ClosingSapronakCalculation, } from '@/types/api/closing'; import { CreatedUser, BaseApiResponse } from '@/types/api/api-general'; @@ -846,6 +847,141 @@ export const dummySapronakCalculation: ClosingSapronakCalculation = { }, }; +// ====================== +// 💰 Overhead Dummy Data +// ====================== +export const dummyOverhead: ClosingOverhead = { + overheads: [ + { + item_name: 'Expedisi DOC', + uom_name: 'Ekor', + budget_quantity: 500, + budget_unit_price: 8000, + budget_total_amount: 4000000, + actual_date: '', + actual_quantity: 0, + actual_unit_price: 0, + actual_total_amount: 0, + cost_per_bird: 0, + }, + { + item_name: 'Solar', + uom_name: 'Liter', + budget_quantity: 0, + budget_unit_price: 0, + budget_total_amount: 0, + actual_date: today, + actual_quantity: 20, + actual_unit_price: 10000, + actual_total_amount: 200000, + cost_per_bird: 200, + }, + { + item_name: 'Gaji Karyawan Kandang', + uom_name: 'Orang', + budget_quantity: 3, + budget_unit_price: 3000000, + budget_total_amount: 9000000, + actual_date: today, + actual_quantity: 3, + actual_unit_price: 3200000, + actual_total_amount: 9600000, + cost_per_bird: 640, + }, + { + item_name: 'Listrik Kandang', + uom_name: 'Bulan', + budget_quantity: 1, + budget_unit_price: 2500000, + budget_total_amount: 2500000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 2800000, + actual_total_amount: 2800000, + cost_per_bird: 187, + }, + { + item_name: 'Air Bersih', + uom_name: 'Bulan', + budget_quantity: 1, + budget_unit_price: 500000, + budget_total_amount: 500000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 450000, + actual_total_amount: 450000, + cost_per_bird: 30, + }, + { + item_name: 'Perbaikan Kandang', + uom_name: 'Paket', + budget_quantity: 1, + budget_unit_price: 3000000, + budget_total_amount: 3000000, + actual_date: yesterday, + actual_quantity: 1, + actual_unit_price: 3500000, + actual_total_amount: 3500000, + cost_per_bird: 233, + }, + { + item_name: 'Service Peralatan', + uom_name: 'Kali', + budget_quantity: 2, + budget_unit_price: 500000, + budget_total_amount: 1000000, + actual_date: lastWeek, + actual_quantity: 2, + actual_unit_price: 550000, + actual_total_amount: 1100000, + cost_per_bird: 73, + }, + { + item_name: 'ATK & Supplies', + uom_name: 'Paket', + budget_quantity: 1, + budget_unit_price: 500000, + budget_total_amount: 500000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 450000, + actual_total_amount: 450000, + cost_per_bird: 30, + }, + { + item_name: 'Biaya Komunikasi', + uom_name: 'Bulan', + budget_quantity: 1, + budget_unit_price: 300000, + budget_total_amount: 300000, + actual_date: today, + actual_quantity: 1, + actual_unit_price: 320000, + actual_total_amount: 320000, + cost_per_bird: 21, + }, + { + item_name: 'BBM Kendaraan Operasional', + uom_name: 'Liter', + budget_quantity: 200, + budget_unit_price: 10000, + budget_total_amount: 2000000, + actual_date: today, + actual_quantity: 220, + actual_unit_price: 10500, + actual_total_amount: 2310000, + cost_per_bird: 154, + }, + ], + total: { + budget_quantity: 710, + budget_total_amount: 23300000, + actual_quantity: 250, + actual_total_amount: 24530000, + cost_per_bird: 1568, + }, +}; + // ====================== // 🔧 Dummy API Response Functions // ====================== @@ -982,3 +1118,19 @@ export const dummyGetPerhitunganSapronak = async ( data: dummySapronakCalculation, }; }; + +/** + * Dummy implementation for getOverhead + * Returns overhead data + */ +export const dummyGetOverhead = async ( + id: number +): Promise | undefined> => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { + code: 200, + status: 'success', + message: 'Data overhead berhasil diambil', + data: dummyOverhead, + }; +}; diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index 9514f6a3..6efd4a36 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -6,6 +6,7 @@ import { ClosingGeneralInformation, ClosingIncomingSapronak, ClosingOutgoingSapronak, + ClosingOverhead, ClosingSapronakCalculation, } from '@/types/api/closing'; import { BaseApiResponse } from '@/types/api/api-general'; @@ -16,6 +17,7 @@ import { dummyGetAllOutgoingSapronakFetcher, dummyGetGeneralInfo, dummyGetPerhitunganSapronak, + dummyGetOverhead, } from '@/dummy/closing.dummy'; import { httpClient, httpClientFetcher } from '@/services/http/client'; @@ -73,12 +75,12 @@ export class ClosingApiService extends BaseApiService { endpoint: string ): Promise> { // TODO: Remove this block when backend is ready - // return await dummyGetAllOutgoingSapronakFetcher(); + return await dummyGetAllOutgoingSapronakFetcher(); // Uncomment this when backend is ready - return await httpClientFetcher>( - endpoint - ); + // return await httpClientFetcher>( + // endpoint + // ); } async getGeneralInfo( @@ -147,6 +149,33 @@ export class ClosingApiService extends BaseApiService { return undefined; } } + + async getOverhead( + id: number + ): Promise | undefined> { + // TODO: Remove this block when backend is ready + // try { + // return await dummyGetOverhead(id); + // } catch (error) { + // if (axios.isAxiosError>(error)) { + // return error.response?.data; + // } + // return undefined; + // } + + // Uncomment this when backend is ready + try { + const path = `${this.basePath}/${id}/overhead`; + return await httpClient>(path, { + method: 'GET', + }); + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } } export const ClosingApi = new ClosingApiService('/closings'); diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts index 0c4c84df..85217002 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -89,3 +89,30 @@ export type ClosingSapronakCalculation = { ovk: ClosingSapronakCalculationItem; pakan: ClosingSapronakCalculationItem; }; + +// ====== OVERHEAD ====== +export type ClosingOverhead = { + overheads: Overhead[]; + total: OverheadTotal; +}; + +export type Overhead = { + item_name: string; + uom_name: string; + budget_quantity: number; + budget_unit_price: number; + budget_total_amount: number; + actual_date: string; + actual_quantity: number; + actual_unit_price: number; + actual_total_amount: number; + cost_per_bird: number; +}; + +export type OverheadTotal = { + budget_quantity: number; + budget_total_amount: number; + actual_quantity: number; + actual_total_amount: number; + cost_per_bird: number; +};