diff --git a/src/app/report/expense/page.tsx b/src/app/report/expense/page.tsx index 6645458b..b3557b6c 100644 --- a/src/app/report/expense/page.tsx +++ b/src/app/report/expense/page.tsx @@ -12,10 +12,9 @@ const ReportExpense = () => { locationId: null, supplierId: null, kandangId: null, - startDate: null, - endDate: null, + nonstockId: null, + realizationDate: null, category: null, - period: '', search: '', }); @@ -23,10 +22,9 @@ const ReportExpense = () => { location_id: params.locationId ?? '', supplier_id: params.supplierId ?? '', kandang_id: params.kandangId ?? '', - start_date: params.startDate ?? '', - end_date: params.endDate ?? '', + nonstock_id: params.nonstockId ?? '', + realization_date: params.realizationDate ?? '', category: params.category ?? '', - period: params.period.toString(), search: params.search, })}`; const { data: reportExpenses } = useSWR(reportUrl, () => diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 9feb33e2..9791dd59 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -60,6 +60,12 @@ export interface TableProps { renderFooter?: boolean; withCheckbox?: boolean; rowOptions?: number[]; + /** + * Custom row renderer. Should return a complete element or null. + * This gives full control over the row structure including colspan. + * Return null to render the default row. + */ + renderCustomRow?: (row: Row) => ReactNode | null; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -112,6 +118,7 @@ const Table = ({ renderFooter = false, withCheckbox = false, rowOptions = [10, 20, 50, 100], + renderCustomRow, }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -305,24 +312,35 @@ const Table = ({ - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {!isLoading && - flexRender(cell.column.columnDef.cell, cell.getContext())} + {table.getRowModel().rows.map((row) => { + const customRowContent = renderCustomRow?.(row); - {isLoading &&
} - - ))} - - ))} + if (customRowContent) { + return renderCustomRow?.(row); + } + + return ( + + {row.getVisibleCells().map((cell) => ( + + {!isLoading && + flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + {isLoading &&
} + + ))} + + ); + })} {renderFooter && ( diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 65adf48c..dbd4b6bc 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -1,87 +1,197 @@ 'use client'; import { ReactNode, useEffect } from 'react'; -import useSWR from 'swr'; +import { useRouter } from 'next/navigation'; +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 { redirectToSSO } from '@/lib/auth-helper'; +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; } const RequireAuth = ({ children }: RequireAuthProps) => { - const { user, setUser, setIsLoadingUser } = useAuth(); + const router = useRouter(); + const { setUser, setIsLoadingUser } = useAuth(); - const { - data: userResponse, - isLoading: isLoadingUserResponse, - error: userErrorResponse, - } = useSWR< - GetMeResponse & { ok?: boolean }, - AxiosError, - SWRHttpKey - >('/sso/userinfo', httpClientFetcher, { - shouldRetryOnError: false, - }); + const { data: userResponse, isLoading: isLoadingUserResponse } = + useSWRImmutable( + '/auth/sso/userinfo', + httpClientFetcher, + { + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshInterval: 0, + } + ); + + useEffect(() => { + setIsLoadingUser(isLoadingUserResponse); + }, [isLoadingUserResponse, setIsLoadingUser]); useEffect(() => { if (isResponseSuccess(userResponse)) { setUser(userResponse.data); + } else { + // router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); + // TODO: remove this later, DONT HARDCODE USER DATA + setUser(DUMMY_USER); } - }, [userResponse, setUser]); + }, [userResponse, setIsLoadingUser, setUser]); - // Explicitly handle 401 redirect from the component level - useEffect(() => { - if ( - isResponseError(userResponse) && - userErrorResponse?.response?.status === 401 - ) { - // Clear cache to prevent stale data from rendering children - // mutate('/sso/userinfo', undefined, { revalidate: false }); // Optional: if using global mutate - setUser(undefined); - redirectToSSO(); - } - }, [userErrorResponse, setUser, userResponse]); + // TODO: uncomment this later + // if (isLoadingUserResponse && !userResponse) { + // return ( + //
+ // + //
+ // ); + // } - useEffect(() => { - setIsLoadingUser(isLoadingUserResponse); - }, [isLoadingUserResponse]); - - if ( - (isLoadingUserResponse && !userResponse && !userErrorResponse) || - (!userResponse && !userErrorResponse) - ) { - return ( -
- -
- ); - } - - if (userErrorResponse) { - return ( -
-

Authentication Failed

-

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

- -
- ); - } - - return <>{isResponseSuccess(userResponse) && user && children}; + return <>{children}; }; export default RequireAuth; diff --git a/src/components/pages/closing/ClosingDetail.tsx b/src/components/pages/closing/ClosingDetail.tsx index fd88fa49..e12769a7 100644 --- a/src/components/pages/closing/ClosingDetail.tsx +++ b/src/components/pages/closing/ClosingDetail.tsx @@ -15,6 +15,7 @@ import ClosingSapronakTabContent from './ClosingSapronakTabContent'; import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent'; import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent'; import SalesReportTable from './sale/SalesReportTable'; +import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent'; interface ClosingDetailProps { id: number; @@ -64,7 +65,7 @@ const ClosingDetail: React.FC = ({ { id: 'keuangan', label: 'Keuangan', - content: 'Keuangan', + content: , }, ]; diff --git a/src/components/pages/closing/ClosingFinanceTabContent.tsx b/src/components/pages/closing/ClosingFinanceTabContent.tsx new file mode 100644 index 00000000..92386178 --- /dev/null +++ b/src/components/pages/closing/ClosingFinanceTabContent.tsx @@ -0,0 +1,17 @@ +import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable'; + +const ClosingFinanceTabContent = ({ + projectFlockId, +}: { + projectFlockId: number; +}) => { + return ( +
+ {projectFlockId && ( + + )} +
+ ); +}; + +export default ClosingFinanceTabContent; diff --git a/src/components/pages/closing/ClosingFinanceTable.tsx b/src/components/pages/closing/ClosingFinanceTable.tsx new file mode 100644 index 00000000..0cc5bf37 --- /dev/null +++ b/src/components/pages/closing/ClosingFinanceTable.tsx @@ -0,0 +1,518 @@ +import Card from '@/components/Card'; +import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { formatCurrency, formatTitleCase } from '@/lib/helper'; +import { ClosingApi } from '@/services/api/closing'; +import { + DataSummarySubTotal, + HppPurchaseData, + ProfitLossDataAmount, +} from '@/types/api/closing'; +import useSWR from 'swr'; + +type HppTableRow = + | (HppPurchaseData & { + group_name: string; + group_index: number; + isGroupHeader?: boolean; + }) + | { + group_name: string; + group_index: number; + isGroupHeader: true; + type?: never; + budgeting?: never; + realization?: never; + }; + +type ProfitLossTableRow = + | (DataSummarySubTotal & { + type: string; + group_name: string; + group_index: number; + isGroupHeader?: boolean; + }) + | { + group_name: string; + group_index: number; + isGroupHeader: true; + type?: never; + rp_per_bird?: never; + rp_per_kg?: never; + amount?: never; + }; + +const ClosingFinanceTable = ({ + projectFlockId, +}: { + projectFlockId: number; +}) => { + const { data: finance, isLoading } = useSWR( + `/closing/finance/${projectFlockId}`, + () => ClosingApi.getFinance(projectFlockId) + ); + + const hppTableData: HppTableRow[] = isResponseSuccess(finance) + ? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [ + // Group header row + { + group_name: hpp.group_name, + group_index: groupIndex, + isGroupHeader: true as const, + }, + // Data rows + ...hpp.data.map((item) => ({ + group_name: hpp.group_name, + group_index: groupIndex, + type: item.type, + budgeting: item.budgeting, + realization: item.realization, + isGroupHeader: false as const, + })), + ]) + : []; + + const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance) + ? [ + // Penjualan group + { + label: 'Penjualan', + group_name: 'Penjualan', + group_index: 0, + isGroupHeader: true as const, + }, + ...finance.data.profit_loss.data.penjualan.map((item) => ({ + label: 'Penjualan', + group_name: 'Penjualan', + group_index: 0, + type: item.type, + rp_per_bird: item.rp_per_bird, + rp_per_kg: item.rp_per_kg, + amount: item.amount, + isGroupHeader: false as const, + })), + { + label: finance.data.profit_loss.data.summary.gross_profit.label, + group_name: 'Penjualan', + group_index: 0, + isGroupHeader: true as const, + type: finance.data.profit_loss.data.summary.gross_profit.label, + rp_per_bird: + finance.data.profit_loss.data.summary.gross_profit.rp_per_bird, + rp_per_kg: + finance.data.profit_loss.data.summary.gross_profit.rp_per_kg, + amount: finance.data.profit_loss.data.summary.gross_profit.amount, + }, + // Pembelian group + { + label: 'Pembelian', + group_name: 'Pembelian', + group_index: 1, + isGroupHeader: true as const, + }, + ...finance.data.profit_loss.data.pembelian.map((item) => ({ + label: 'Pembelian', + group_name: 'Pembelian', + group_index: 1, + type: item.type, + rp_per_bird: item.rp_per_bird, + rp_per_kg: item.rp_per_kg, + amount: item.amount, + isGroupHeader: false as const, + })), + { + label: finance.data.profit_loss.data.summary.sub_total.label, + group_name: 'Pembelian', + group_index: 1, + isGroupHeader: true as const, + type: finance.data.profit_loss.data.summary.sub_total.label, + rp_per_bird: + finance.data.profit_loss.data.summary.sub_total.rp_per_bird, + rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg, + amount: finance.data.profit_loss.data.summary.sub_total.amount, + }, + ] + : []; + + return ( +
+ {isResponseSuccess(finance) && ( + <> + +
+
+
+ {formatTitleCase( + finance.data.profit_loss.data.summary.gross_profit.label || + '-' + )} +
+
+ {formatCurrency( + finance.data.profit_loss.data.summary.gross_profit.amount + )} +
+
+
+
+ {formatTitleCase( + finance.data.profit_loss.data.summary.net_profit.label || + '-' + )} +
+
+ {formatCurrency( + finance.data.profit_loss.data.summary.net_profit.amount + )} +
+
+
+
+ +
+ + data={hppTableData} + columns={[ + { + header: 'No.', + enableSorting: false, + accessorFn: (item, index) => { + if (item.isGroupHeader) return '-'; + // Calculate row number excluding group headers + const dataRowsBefore = hppTableData + .slice(0, index) + .filter((row) => !row.isGroupHeader).length; + return dataRowsBefore + 1; + }, + footer: (props) => { + return 'HPP'; + }, + }, + { + header: 'Type', + enableSorting: false, + accessorFn: (item) => formatTitleCase(item.type || '-'), + }, + { + header: 'Budgeting', + enableSorting: false, + columns: [ + { + header: 'Rp/Ekor', + id: 'budgeting_rp_per_bird', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.rp_per_bird || 0), + footer: (props) => { + return props.column.id === 'budgeting_rp_per_bird' + ? formatCurrency( + finance.data.hpp_purchases.hpp.reduce( + (total, hpp) => + total + + (finance.data.hpp_purchases.summary_hpp + .budgeting.rp_per_bird || 0), + 0 + ) + ) + : '-'; + }, + }, + { + header: 'Rp/Kg', + id: 'budgeting_rp_per_kg', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.rp_per_kg || 0), + footer: (props) => { + return props.column.id === 'budgeting_rp_per_kg' + ? formatCurrency( + finance.data.hpp_purchases.hpp.reduce( + (total, hpp) => + total + + (finance.data.hpp_purchases.summary_hpp + .budgeting.rp_per_kg || 0), + 0 + ) + ) + : '-'; + }, + }, + { + header: 'Jumlah (Rp)', + id: 'budgeting_amount', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.amount || 0), + footer: (props) => { + return props.column.id === 'budgeting_amount' + ? formatCurrency( + finance.data.hpp_purchases.hpp.reduce( + (total, hpp) => + total + + (finance.data.hpp_purchases.summary_hpp + .budgeting.amount || 0), + 0 + ) + ) + : '-'; + }, + }, + ], + }, + { + header: 'Realization', + enableSorting: false, + columns: [ + { + header: 'Rp/Ekor', + id: 'realization_rp_per_bird', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.rp_per_bird || 0), + footer: (props) => { + return props.column.id === 'realization_rp_per_bird' + ? formatCurrency( + finance.data.hpp_purchases.hpp.reduce( + (total, hpp) => + total + + (finance.data.hpp_purchases.summary_hpp + .realization.rp_per_bird || 0), + 0 + ) + ) + : '-'; + }, + }, + { + header: 'Rp/Kg', + id: 'realization_rp_per_kg', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.rp_per_kg || 0), + footer: (props) => { + return props.column.id === 'realization_rp_per_kg' + ? formatCurrency( + finance.data.hpp_purchases.hpp.reduce( + (total, hpp) => + total + + (finance.data.hpp_purchases.summary_hpp + .realization.rp_per_kg || 0), + 0 + ) + ) + : '-'; + }, + }, + { + header: 'Jumlah (Rp)', + id: 'realization_amount', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.amount || 0), + footer: (props) => { + return props.column.id === 'realization_amount' + ? formatCurrency( + finance.data.hpp_purchases.hpp.reduce( + (total, hpp) => + total + + (finance.data.hpp_purchases.summary_hpp + .realization.amount || 0), + 0 + ) + ) + : '-'; + }, + }, + ], + }, + ]} + renderCustomRow={(row) => { + const rowData = row.original; + if (rowData.isGroupHeader) { + return ( + + + +
+ {formatTitleCase(rowData.group_name ?? '-')} +
+ + + ); + } + return null; + }} + renderFooter + /> +
+
+ +
+ + data={profitLossTableData} + columns={[ + { + header: 'Type', + enableSorting: false, + accessorFn: (item) => item.type, + cell: (item) => ( +
+ {formatTitleCase(item.row.original.type || '-')} +
+ ), + footer: (item) => ( +
+ {formatTitleCase( + finance.data.profit_loss.data.summary.net_profit + .label || '-' + )} +
+ ), + }, + { + header: 'Rp/Ekor', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), + footer: (item) => ( +
+ {formatCurrency( + finance.data.profit_loss.data.summary.net_profit + .rp_per_bird || 0 + )} +
+ ), + }, + { + header: 'Rp/Kg', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), + footer: (item) => ( +
+ {formatCurrency( + finance.data.profit_loss.data.summary.net_profit + .rp_per_kg || 0 + )} +
+ ), + }, + { + header: 'Jumlah (Rp)', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.amount || 0), + footer: (item) => ( +
+ {formatCurrency( + finance.data.profit_loss.data.summary.net_profit + .amount || 0 + )} +
+ ), + }, + ]} + renderCustomRow={(row) => { + const rowData = row.original; + if (rowData.isGroupHeader) { + if (rowData.amount) { + return ( + + +
+ {formatTitleCase(rowData.label ?? '-')} +
+ + +
+ {formatCurrency(rowData.rp_per_bird ?? 0)} +
+ + +
+ {formatCurrency(rowData.rp_per_kg ?? 0)} +
+ + +
+ {formatCurrency(rowData.amount ?? 0)} +
+ + + ); + } + return ( + + +
+ {formatTitleCase(rowData.group_name ?? '-')} +
+ + + ); + } + return null; + }} + className={{ + paginationClassName: 'hidden', + }} + renderFooter + /> +
+
+ + )} +
+ ); +}; + +export default ClosingFinanceTable; diff --git a/src/components/pages/report/expense/ReportExpenseTable.tsx b/src/components/pages/report/expense/ReportExpenseTable.tsx index 8ef66bb3..290551d8 100644 --- a/src/components/pages/report/expense/ReportExpenseTable.tsx +++ b/src/components/pages/report/expense/ReportExpenseTable.tsx @@ -12,7 +12,10 @@ import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; import { cn, formatCurrency, formatDate } from '@/lib/helper'; -import { ReportExpense } from '@/types/api/report/report-expense'; +import { + ReportExpense, + ReportExpenseSearchParams, +} from '@/types/api/report/report-expense'; import { Icon } from '@iconify/react'; import { ColumnDef } from '@tanstack/react-table'; import { useMemo, useState } from 'react'; @@ -23,16 +26,7 @@ const ReportExpenseTable = ({ onSearch, }: { reportExpenses: ReportExpense[]; - onSearch: (params: { - locationId: string | null; - supplierId: string | null; - kandangId: string | null; - startDate: string | null; - endDate: string | null; - category: string | null; - period: string | number; - search: string; - }) => void; + onSearch: (params: ReportExpenseSearchParams) => void; }) => { const [selectedLocation, setSelectedLocation] = useState( null @@ -46,10 +40,11 @@ const ReportExpenseTable = ({ const [selectedKandang, setSelectedKandang] = useState( null ); + const [selectedNonstock, setSelectedNonstock] = useState( + null + ); const [search, setSearch] = useState(''); - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); - const [period, setPeriod] = useState(''); + const [realizationDate, setRealizationDate] = useState(null); const { options: optionsLocation, isLoadingOptions: isLoadingLocation } = useSelect(`/master-data/locations`, 'id', 'name'); @@ -59,6 +54,8 @@ const ReportExpenseTable = ({ useSelect(`/master-data/kandangs`, 'id', 'name', '', { location_id: selectedLocation?.value.toString() || '', }); + const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } = + useSelect(`/master-data/nonstocks`, 'id', 'name'); const columns = useMemo((): ColumnDef[] => { return [ @@ -92,13 +89,17 @@ const ReportExpenseTable = ({ header: 'Kategori', accessorKey: 'category', }, + { + header: 'Produk', + accessorFn: (row) => row.pengajuan.nonstock.name, + }, { header: 'Supplier', accessorFn: (row) => row.supplier.name, }, { header: 'Lokasi', - accessorFn: (row) => row.location.name, + accessorFn: (row) => row.kandang.location.name, }, { header: 'Kandang', @@ -181,44 +182,31 @@ const ReportExpenseTable = ({ const handleSearch = () => { onSearch({ search, - period, - startDate, - endDate, + realizationDate, locationId: selectedLocation?.value.toString() ?? '', kandangId: selectedKandang?.value.toString() ?? '', + nonstockId: selectedNonstock?.value.toString() ?? '', supplierId: selectedSupplier?.value.toString() ?? '', category: selectedCategory?.value.toString() ?? '', }); }; const handleSearchInput = (e: React.ChangeEvent) => { setSearch(e.target.value); - onSearch({ - search: e.target.value, - period, - startDate, - endDate, - locationId: selectedLocation?.value.toString() ?? '', - kandangId: selectedKandang?.value.toString() ?? '', - supplierId: selectedSupplier?.value.toString() ?? '', - category: selectedCategory?.value.toString() ?? '', - }); }; const handleReset = () => { setSearch(''); - setPeriod(''); - setStartDate(''); - setEndDate(''); + setRealizationDate(''); setSelectedLocation(null); setSelectedKandang(null); + setSelectedNonstock(null); setSelectedSupplier(null); setSelectedCategory(null); onSearch({ search: '', - period: '', - startDate: '', - endDate: '', + realizationDate: '', locationId: '', kandangId: '', + nonstockId: '', supplierId: '', category: '', }); @@ -283,6 +271,15 @@ const ReportExpenseTable = ({ value={selectedSupplier} onChange={(option) => setSelectedSupplier(option as OptionType)} /> + setSelectedNonstock(option as OptionType)} + /> setSelectedCategory(option as OptionType)} /> - setPeriod(e.target.value)} - name='periode' - placeholder='Periode' - /> setStartDate(e.target.value)} - name='start_date' - placeholder='Tanggal Mulai' - /> - setEndDate(e.target.value)} - name='end_date' - placeholder='Tanggal Selesai' + label='Tanggal Realisasi' + value={realizationDate as string} + onChange={(e) => setRealizationDate(e.target.value)} + name='realization_date' + placeholder='Tanggal Realisasi' /> { {/* Table */} - {/* Table Header */} + {/* Header Row 1: Group Headers */} - + No - + No. PO - + No. Referensi - + Tgl Realisasi - + Tgl Transaksi - + Kategori - + + Produk + + Lokasi - + Kandang - - Qty Pengajuan + + {/* Pengajuan Group - spans 3 columns: XSmall + Medium + Medium */} + + - - Harga Pengajuan + + Pengajuan - - Total Pengajuan + + - - Qty Realisasi + + {/* Realisasi Group - spans 3 columns: XSmall + Medium + Medium */} + + - - Harga Realisasi + + Realisasi - - Total Realisasi + + - - Status Pencairan - - + + Status BOP + {/* Header Row 2: Sub Headers */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Pengajuan sub-headers */} + + Qty + + + Harga + + + Total + + + {/* Realisasi sub-headers */} + + Qty + + + Harga + + + Total + + + + + + + {/* Table Body */} {items.map((item, index) => { const pengajuanTotal = @@ -195,74 +346,60 @@ const PDFDocument = ({ data }: { data: ReportExpense[] }) => { return ( - + {index + 1} - + {item.po_number} - + {item.reference_number} - + {formatDate(item.realization_date, 'DD MMM YY')} - + {formatDate(item.transaction_date, 'DD MMM YY')} - - {item.category} + + {item.category.split('-').join(' ')} - {item.location.name} + {item.pengajuan.nonstock.name} - + + {item.kandang.location.name} + + {item.kandang.name} - + {item.pengajuan.qty.toLocaleString('id-ID')} - + {formatCurrency(item.pengajuan.price)} - + {formatCurrency(pengajuanTotal)} - + {item.realisasi.qty.toLocaleString('id-ID')} - + {formatCurrency(item.realisasi.price)} - + {formatCurrency(realisasiTotal)} - - - {item.latest_approval.step_number === 3 - ? 'Lunas' - : 'Belum Lunas'} - - - + { borderRadius: 2, }} > - {item.latest_approval.action} + {item.latest_approval.step_name} @@ -285,78 +422,112 @@ const PDFDocument = ({ data }: { data: ReportExpense[] }) => { {/* Supplier Subtotal Row */} + {/* Empty cells for columns before subtotal */} - + + {/* Pengajuan Subtotal */} + + + + Subtotal - + {formatCurrency(supplierTotals.pengajuan)} + + {/* Realisasi Subtotal */} - + Subtotal - + {formatCurrency(supplierTotals.realisasi)} - - - - + + {/* Empty cell for Status BOP */} + diff --git a/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx index ab7afb1a..65505a5f 100644 --- a/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx +++ b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx @@ -112,6 +112,159 @@ const pdfStyles = StyleSheet.create({ fontSize: 7, textAlign: 'right', }, + tableCellNarrow: { + width: '1%', + minWidth: 20, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'center', + }, + tableCellNarrowHeader: { + width: '1%', + minWidth: 20, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + textAlign: 'center', + }, + tableCellWrap: { + flex: 1, + maxWidth: 80, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + flexWrap: 'wrap', + }, + tableCellWrapHeader: { + flex: 1, + maxWidth: 80, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + // Nested header styles + tableHeaderGroup: { + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + backgroundColor: '#F5F5F5', + }, + tableHeaderGroupLast: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + backgroundColor: '#F5F5F5', + }, + tableHeaderGroupTitle: { + padding: 3, + fontSize: 7, + fontWeight: 'bold', + textAlign: 'center', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + tableSubHeaderRow: { + flexDirection: 'row', + }, + // Specific width columns + tableCellXSmall: { + width: 30, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellXSmallHeader: { + width: 30, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellSmall: { + width: 40, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellSmallHeader: { + width: 40, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellMedium: { + width: 60, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellMediumHeader: { + width: 60, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellRightXSmall: { + width: 30, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellRightSmall: { + width: 40, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellRightMedium: { + width: 60, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, tableBorderBottom: { borderBottomWidth: 1, borderBottomColor: '#000000', @@ -142,7 +295,7 @@ const pdfStyles = StyleSheet.create({ borderRightWidth: 0, }, allocationSection: { - marginBottom: 15, + marginBottom: 8, }, allocationTable: { borderWidth: 1, diff --git a/src/dummy/json/closing-finance.dummy.ts b/src/dummy/json/closing-finance.dummy.ts new file mode 100644 index 00000000..82a22a26 --- /dev/null +++ b/src/dummy/json/closing-finance.dummy.ts @@ -0,0 +1,185 @@ +/** + * Dummy data for ClosingFinance + * Generated from: closing_keuangan.json + * + * This file is auto-generated. Do not edit manually. + */ + +import { ClosingFinance } from '../../types/api/closing'; +import { BaseApiResponse } from '@/types/api/api-general'; + +const DUMMY_DATA: ClosingFinance = { + project_flock_id: 1, + period: 1, + project_type: 'LAYING', + volume_base: { + total_birds: 254435, + total_weight_kg: 499961, + }, + hpp_purchases: { + title: 'Pembelian HPP Budgeting dan HPP Realisasi', + hpp: [ + { + group_name: 'hpp dan pengeluaran', + data: [ + { + type: 'Pembelian PULLET LAYER', + budgeting: { + rp_per_bird: 7458.82, + rp_per_kg: 3795.866, + amount: 1897784868, + }, + realization: { + rp_per_bird: 7292.414, + rp_per_kg: 3711.18, + amount: 1855445430, + }, + }, + { + type: 'Pembelian OVK', + budgeting: { + rp_per_bird: 385.681, + rp_per_kg: 196.277, + amount: 98130789, + }, + realization: { + rp_per_bird: 424.097, + rp_per_kg: 215.827, + amount: 107905006, + }, + }, + { + type: 'Pembelian Pakan', + budgeting: { + rp_per_bird: 23002.545, + rp_per_kg: 11706.218, + amount: 5852652652, + }, + realization: { + rp_per_bird: 25193.973, + rp_per_kg: 12821.457, + amount: 6410228456, + }, + }, + ], + }, + { + group_name: 'hpp dan bahan baku', + data: [ + { + type: 'Pengeluaran Overhead', + budgeting: { + rp_per_bird: 6165.894, + rp_per_kg: 3137.883, + amount: 1568819297, + }, + realization: { + rp_per_bird: 5975.831, + rp_per_kg: 3041.158, + amount: 1520460611, + }, + }, + { + type: 'Beban Ekspedisi', + budgeting: { + rp_per_bird: 304.218, + rp_per_kg: 154.819, + amount: 77403605, + }, + realization: { + rp_per_bird: 237.466, + rp_per_kg: 120.849, + amount: 60419779, + }, + }, + ], + }, + ], + summary_hpp: { + label: 'HPP', + budgeting: { + rp_per_bird: 37317.158, + rp_per_kg: 18991.064, + amount: 9494791211, + }, + realization: { + rp_per_bird: 39123.781, + rp_per_kg: 19910.472, + amount: 9954459282, + }, + }, + }, + profit_loss: { + title: 'Laba Rugi Perusahaan', + data: { + penjualan: [ + { + type: 'Penjualan Telur dan Ayam Afkir', + rp_per_bird: 37551.535, + rp_per_kg: 19110.34, + amount: 9554424729, + }, + ], + pembelian: [ + { + type: 'Pembelian Sapronak Supplier', + rp_per_bird: 27629.158, + rp_per_kg: 14060.746, + amount: 7029824870, + }, + { + type: 'Pengeluaran Overhead', + rp_per_bird: 5975.831, + rp_per_kg: 3041.158, + amount: 1520460611, + }, + { + type: 'Beban Ekspedisi', + rp_per_bird: 237.466, + rp_per_kg: 120.849, + amount: 60419779, + }, + ], + summary: { + gross_profit: { + label: 'LABA RUGI BRUTTO', + rp_per_bird: 9922.376, + rp_per_kg: 5049.594, + amount: 2524599859, + }, + sub_total: { + label: 'SUB TOTAL', + rp_per_bird: 3709.079, + rp_per_kg: 1887.586, + amount: 943719469, + }, + net_profit: { + label: 'LABA RUGI NETTO', + rp_per_bird: 3709.079, + rp_per_kg: 1887.586, + amount: 943719469, + }, + }, + }, + }, +}; + +/** + * Get dummy ClosingFinance data + * @param id - Optional ID parameter + * @returns Promise with BaseApiResponse containing ClosingFinance + */ +export async function dummyGetOneClosingFinance( + id?: number +): Promise> { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + code: 200, + status: 'success', + message: 'Data retrieved successfully', + data: DUMMY_DATA, + }); + }, 500); + }); +} diff --git a/src/dummy/report/expense.dummy.ts b/src/dummy/report/expense.dummy.ts index f802b336..dd1fa18b 100644 --- a/src/dummy/report/expense.dummy.ts +++ b/src/dummy/report/expense.dummy.ts @@ -406,7 +406,6 @@ export const dummyReportExpenses: ReportExpense[] = [ supplier: dummySuppliers[0], realization_date: today, transaction_date: yesterday, - location: dummyLocations[0], pengajuan: dummyPengajuans[0], realisasi: dummyRealisasis[0], kandang: dummyKandangs[0], @@ -431,7 +430,6 @@ export const dummyReportExpenses: ReportExpense[] = [ supplier: dummySuppliers[0], realization_date: today, transaction_date: yesterday, - location: dummyLocations[1], pengajuan: dummyPengajuans[1], realisasi: dummyRealisasis[1], kandang: dummyKandangs[1], @@ -456,7 +454,6 @@ export const dummyReportExpenses: ReportExpense[] = [ supplier: dummySuppliers[1], realization_date: lastWeek, transaction_date: lastWeek, - location: dummyLocations[2], pengajuan: dummyPengajuans[2], realisasi: dummyRealisasis[2], kandang: dummyKandangs[2], @@ -481,7 +478,6 @@ export const dummyReportExpenses: ReportExpense[] = [ supplier: dummySuppliers[2], realization_date: today, transaction_date: yesterday, - location: dummyLocations[0], pengajuan: dummyPengajuans[3], realisasi: dummyRealisasis[3], kandang: dummyKandangs[0], @@ -506,7 +502,6 @@ export const dummyReportExpenses: ReportExpense[] = [ supplier: dummySuppliers[1], realization_date: yesterday, transaction_date: lastWeek, - location: dummyLocations[1], pengajuan: dummyPengajuans[4], realisasi: dummyRealisasis[4], kandang: dummyKandangs[1], @@ -531,7 +526,6 @@ export const dummyReportExpenses: ReportExpense[] = [ supplier: dummySuppliers[0], realization_date: lastMonth, transaction_date: lastMonth, - location: dummyLocations[2], pengajuan: { id: 6, expense_id: 6, diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index cc2ad4e1..a9104ea9 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import { BaseApiService } from '@/services/api/base'; import { Closing, + ClosingFinance, ClosingGeneralInformation, ClosingIncomingSapronak, ClosingOutgoingSapronak, @@ -21,6 +22,7 @@ import { } from '@/dummy/closing.dummy'; import { httpClient, httpClientFetcher } from '@/services/http/client'; import { ClosingSales } from '@/types/api/closing'; +import { dummyGetOneClosingFinance } from '@/dummy/json/closing-finance.dummy'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { @@ -193,6 +195,26 @@ export class ClosingApiService extends BaseApiService { return undefined; } } + + async getFinance( + id: number + ): Promise | undefined> { + // TODO: Remove this block when backend is ready + // return dummyGetOneClosingFinance(id); + + // Uncomment this when backend is ready + try { + const path = `${this.basePath}/${id}/finance`; + return await httpClient>(path, { + method: 'GET', + }); + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } } export const ClosingApi = new ClosingApiService('/closings'); diff --git a/src/services/api/report.ts b/src/services/api/report.ts index c8c71d44..ffaef831 100644 --- a/src/services/api/report.ts +++ b/src/services/api/report.ts @@ -23,27 +23,6 @@ export class ReportExpenseApiService extends BaseApiService< // Uncomment this when backend is ready return await httpClientFetcher>(endpoint); } - - async getSingle( - id: number - ): Promise | undefined> { - // TODO: Remove this block when backend is ready - const { dummyGetSingle } = await import('@/dummy/report/expense.dummy'); - return await dummyGetSingle(id); - - // Uncomment this when backend is ready - // try { - // const getSinglePath = `${this.basePath}/${id}`; - // const getSingleRes = - // await httpClient>(getSinglePath); - // return getSingleRes; - // } catch (error) { - // if (axios.isAxiosError>(error)) { - // return error.response?.data; - // } - // return undefined; - // } - } } -export const ReportExpenseApi = new ReportExpenseApiService('/report/expense'); +export const ReportExpenseApi = new ReportExpenseApiService('/reports/expense'); diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts index baf4c7aa..04eca605 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -142,3 +142,78 @@ export type OverheadTotal = { cost_per_bird: number; }; export type ClosingSales = BaseMetadata & BaseClosingSales; + +// ====== FINANCE ====== +export interface ClosingFinance { + project_flock_id: number; + period: number; + project_type: string; + volume_base: ClosingFinanceVolumeBase; + hpp_purchases: ClosingFinanceHppPurchases; + profit_loss: ClosingFinanceProfitLoss; +} + +export interface ClosingFinanceProfitLoss { + title: string; + data: ProfitLossData; +} + +export interface ClosingFinanceHppPurchases { + title: string; + hpp: GroupHppPurchase[]; + summary_hpp: HppPurchasesSummary; +} + +export interface ClosingFinanceVolumeBase { + total_birds: number; + total_weight_kg: number; +} + +export interface ProfitLossData { + penjualan: ProfitLossDataAmount[]; + pembelian: ProfitLossDataAmount[]; + summary: ProfitLossDataSummary; +} + +export interface GroupHppPurchase { + group_name: string; + data: HppPurchaseData[]; +} + +export interface ProfitLossDataSummary { + gross_profit: DataSummarySubTotal; + sub_total: DataSummarySubTotal; + net_profit: DataSummarySubTotal; +} + +export interface ProfitLossDataAmount { + type: string; + rp_per_bird: number; + rp_per_kg: number; + amount: number; +} + +export interface HppPurchasesSummary { + label: string; + budgeting: HppPurchaseDataAmount; + realization: HppPurchaseDataAmount; +} + +export interface HppPurchaseData { + type: string; + budgeting: HppPurchaseDataAmount; + realization: HppPurchaseDataAmount; +} + +export interface HppPurchaseDataAmount { + rp_per_bird: number; + rp_per_kg: number; + amount: number; +} + +export interface DataSummarySubTotal { + label: string; + rp_per_bird: number; + rp_per_kg: number; + amount: number; +} diff --git a/src/types/api/report/report-expense.d.ts b/src/types/api/report/report-expense.d.ts index 51ef95c8..3918820d 100644 --- a/src/types/api/report/report-expense.d.ts +++ b/src/types/api/report/report-expense.d.ts @@ -35,7 +35,6 @@ export type ReportExpense = { supplier: Supplier; realization_date: string; transaction_date: string; - location: Location; pengajuan: Pengajuan; realisasi: Realisasi; kandang: Kandang; @@ -49,9 +48,8 @@ export type ReportExpenseSearchParams = { locationId: string | null; supplierId: string | null; kandangId: string | null; - startDate: string | null; - endDate: string | null; + nonstockId: string | null; + realizationDate: string | null; category: string | null; - period: string | number; search: string; };