feat(FE-333): adding feature overhead closing report

This commit is contained in:
randy-ar
2025-12-09 18:14:46 +07:00
parent 489815ecaf
commit 8c7640eb9c
7 changed files with 558 additions and 38 deletions
+166 -33
View File
@@ -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<BaseApiResponse>,
SWRHttpKey
>('/sso/userinfo', httpClientFetcher, {
shouldRetryOnError: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 0,
});
const { data: userResponse, isLoading: isLoadingUserResponse } =
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
'/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 (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
// TODO: uncomment this later
// if (isLoadingUserResponse && !userResponse) {
// return (
// <div className='w-full flex flex-row justify-center items-center p-4'>
// <span className='loading loading-spinner loading-xl' />
// </div>
// );
// }
return <>{isResponseSuccess(userResponse) && children}</>;
return <>{children}</>;
};
export default RequireAuth;
@@ -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<ClosingDetailProps> = ({ id, initialValue }) => {
{
id: 'overhead',
label: 'Overhead',
content: 'Overhead',
content: <ClosingOverheadTabContent projectFlockId={id} />,
},
{
id: 'hppEkspedisi',
@@ -0,0 +1,19 @@
import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable';
interface ClosingOverheadTabContentProps {
projectFlockId: number;
}
const ClosingOverheadTabContent = ({
projectFlockId,
}: ClosingOverheadTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingOverheadTable projectFlockId={projectFlockId} />
)}
</div>
);
};
export default ClosingOverheadTabContent;
@@ -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<Overhead>[] => [
// 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 (
<>
<Card
title='Pengeluaran Overhead'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<Overhead>
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
}
/>
</Card>
</>
);
};
export default ClosingOverheadTable;
+152
View File
@@ -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<BaseApiResponse<ClosingOverhead> | undefined> => {
await new Promise((resolve) => setTimeout(resolve, 400));
return {
code: 200,
status: 'success',
message: 'Data overhead berhasil diambil',
data: dummyOverhead,
};
};
+33 -4
View File
@@ -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<Closing, null, null> {
endpoint: string
): Promise<BaseApiResponse<ClosingOutgoingSapronak[]>> {
// TODO: Remove this block when backend is ready
// return await dummyGetAllOutgoingSapronakFetcher();
return await dummyGetAllOutgoingSapronakFetcher();
// Uncomment this when backend is ready
return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>(
endpoint
);
// return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>(
// endpoint
// );
}
async getGeneralInfo(
@@ -147,6 +149,33 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
return undefined;
}
}
async getOverhead(
id: number
): Promise<BaseApiResponse<ClosingOverhead> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetOverhead(id);
// } catch (error) {
// if (axios.isAxiosError<BaseApiResponse<ClosingOverhead>>(error)) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const path = `${this.basePath}/${id}/overhead`;
return await httpClient<BaseApiResponse<ClosingOverhead>>(path, {
method: 'GET',
});
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingOverhead>>(error)) {
return error.response?.data;
}
return undefined;
}
}
}
export const ClosingApi = new ClosingApiService('/closings');
+27
View File
@@ -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;
};