diff --git a/src/app/report/expense/detail/layout.tsx b/src/app/report/expense/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/report/expense/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/report/expense/detail/page.tsx b/src/app/report/expense/detail/page.tsx new file mode 100644 index 00000000..f7ae906e --- /dev/null +++ b/src/app/report/expense/detail/page.tsx @@ -0,0 +1,5 @@ +const ReportExpenseDetail = () => { + return
ReportExpenseDetail
; +}; + +export default ReportExpenseDetail; diff --git a/src/app/report/expense/page.tsx b/src/app/report/expense/page.tsx new file mode 100644 index 00000000..6645458b --- /dev/null +++ b/src/app/report/expense/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState } from 'react'; +import useSWR from 'swr'; +import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable'; +import { ReportExpenseApi } from '@/services/api/report'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ReportExpenseSearchParams } from '@/types/api/report/report-expense'; + +const ReportExpense = () => { + const [params, setParams] = useState({ + locationId: null, + supplierId: null, + kandangId: null, + startDate: null, + endDate: null, + category: null, + period: '', + search: '', + }); + + const reportUrl = `${ReportExpenseApi.basePath}?${new URLSearchParams({ + location_id: params.locationId ?? '', + supplier_id: params.supplierId ?? '', + kandang_id: params.kandangId ?? '', + start_date: params.startDate ?? '', + end_date: params.endDate ?? '', + category: params.category ?? '', + period: params.period.toString(), + search: params.search, + })}`; + const { data: reportExpenses } = useSWR(reportUrl, () => + ReportExpenseApi.getAllFetcher(reportUrl) + ); + + const onSearch = (searchParams: ReportExpenseSearchParams) => { + setParams(searchParams); + }; + + return ( +
+ +
+ ); +}; + +export default ReportExpense; diff --git a/src/components/input/DebouncedTextInput.tsx b/src/components/input/DebouncedTextInput.tsx index 4b62aaf7..d52ab72e 100644 --- a/src/components/input/DebouncedTextInput.tsx +++ b/src/components/input/DebouncedTextInput.tsx @@ -24,6 +24,11 @@ const DebouncedTextInput = (props: DebouncedTextInputProps) => { setInternalChangeEvent(e); }; + // Sync internal value with external value prop changes (e.g., from reset) + useEffect(() => { + setInternalValue(props.value); + }, [props.value]); + useEffect(() => { if (debouncedChangeEvent) { onChange?.(debouncedChangeEvent); diff --git a/src/components/pages/report/expense/ReportExpenseTable.tsx b/src/components/pages/report/expense/ReportExpenseTable.tsx new file mode 100644 index 00000000..8ef66bb3 --- /dev/null +++ b/src/components/pages/report/expense/ReportExpenseTable.tsx @@ -0,0 +1,346 @@ +import Badge from '@/components/Badge'; +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import DateInput from '@/components/input/DateInput'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +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 { Icon } from '@iconify/react'; +import { ColumnDef } from '@tanstack/react-table'; +import { useMemo, useState } from 'react'; +import ReportExpenseExport from '@/components/pages/report/expense/pdf/ReportExpenseExport'; + +const ReportExpenseTable = ({ + reportExpenses, + 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; +}) => { + const [selectedLocation, setSelectedLocation] = useState( + null + ); + const [selectedSupplier, setSelectedSupplier] = useState( + null + ); + const [selectedCategory, setSelectedCategory] = useState( + null + ); + const [selectedKandang, setSelectedKandang] = useState( + null + ); + const [search, setSearch] = useState(''); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [period, setPeriod] = useState(''); + + const { options: optionsLocation, isLoadingOptions: isLoadingLocation } = + useSelect(`/master-data/locations`, 'id', 'name'); + const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } = + useSelect(`/master-data/suppliers`, 'id', 'name'); + const { options: optionsKandang, isLoadingOptions: isLoadingKandang } = + useSelect(`/master-data/kandangs`, 'id', 'name', '', { + location_id: selectedLocation?.value.toString() || '', + }); + + const columns = useMemo((): ColumnDef[] => { + return [ + { + header: 'No', + accessorFn: (_, index) => index + 1, + }, + { + header: 'No. PO', + accessorKey: 'po_number', + }, + { + header: 'No. Referensi', + accessorKey: 'reference_number', + }, + { + header: 'Tanggal Realisasi', + accessorKey: 'realization_date', + cell: ({ row }) => { + return formatDate(row.original.realization_date, 'DD MMM, YYYY'); + }, + }, + { + header: 'Tanggal Transaksi', + accessorKey: 'transaction_date', + cell: ({ row }) => { + return formatDate(row.original.transaction_date, 'DD MMM, YYYY'); + }, + }, + { + header: 'Kategori', + accessorKey: 'category', + }, + { + header: 'Supplier', + accessorFn: (row) => row.supplier.name, + }, + { + header: 'Lokasi', + accessorFn: (row) => row.location.name, + }, + { + header: 'Kandang', + accessorFn: (row) => row.kandang.name, + }, + { + header: 'Pengajuan', + columns: [ + { + header: 'Qty', + id: 'qty_pengajuan', + accessorFn: (row) => row.pengajuan.qty, + cell: ({ row }) => + row.original.pengajuan.qty.toLocaleString('id-ID'), + }, + { + header: 'Harga', + id: 'harga_pengajuan', + accessorFn: (row) => row.pengajuan.price, + cell: ({ row }) => formatCurrency(row.original.pengajuan.price), + }, + { + header: 'Total', + id: 'total_pengajuan', + accessorFn: (row) => row.pengajuan.qty * row.pengajuan.price, + cell: ({ row }) => { + const total = + row.original.pengajuan.qty * row.original.pengajuan.price; + return formatCurrency(total); + }, + }, + ], + }, + { + header: 'Realisasi', + columns: [ + { + header: 'Qty', + id: 'qty_realisasi', + accessorFn: (row) => row.realisasi.qty, + cell: ({ row }) => + row.original.realisasi.qty.toLocaleString('id-ID'), + }, + { + header: 'Harga', + id: 'harga_realisasi', + accessorFn: (row) => row.realisasi.price, + cell: ({ row }) => formatCurrency(row.original.realisasi.price), + }, + { + header: 'Total', + id: 'total_realisasi', + accessorFn: (row) => row.realisasi.qty * row.realisasi.price, + cell: ({ row }) => { + const total = + row.original.realisasi.qty * row.original.realisasi.price; + return formatCurrency(total); + }, + }, + ], + }, + { + header: 'Status Pencairan', + cell: (props) => ( + + ), + }, + { + header: 'Status BOP', + cell: (props) => ( + + ), + }, + ]; + }, []); + + // Handle Search + const handleSearch = () => { + onSearch({ + search, + period, + startDate, + endDate, + locationId: selectedLocation?.value.toString() ?? '', + kandangId: selectedKandang?.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(''); + setSelectedLocation(null); + setSelectedKandang(null); + setSelectedSupplier(null); + setSelectedCategory(null); + onSearch({ + search: '', + period: '', + startDate: '', + endDate: '', + locationId: '', + kandangId: '', + supplierId: '', + category: '', + }); + }; + + return ( +
+ +
+ +
+
+ + +
+
+ } + > +
+ { + setSelectedLocation(option as OptionType); + setSelectedKandang(null); + }} + /> + setSelectedKandang(option as OptionType)} + /> + setSelectedSupplier(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' + /> + } + /> +
+ + + columns={columns} + data={reportExpenses} + className={{ + headerRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'), + bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'), + }} + /> + + ); +}; + +export default ReportExpenseTable; diff --git a/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx b/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx new file mode 100644 index 00000000..b04b10bb --- /dev/null +++ b/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx @@ -0,0 +1,420 @@ +import Button from '@/components/Button'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import { Icon } from '@iconify/react'; +import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; +import { useMemo, useState } from 'react'; +import { formatCurrency, formatDate } from '@/lib/helper'; +import pdfStyles from '@/components/pages/report/expense/pdf/styles/ReportExpenseStyles'; +import toast from 'react-hot-toast'; + +interface ReportExpenseExportProps { + data: ReportExpense[]; + className?: string; +} + +const ReportExpenseExport = ({ data }: ReportExpenseExportProps) => { + const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); + + const handleDownloadPDF = async () => { + if (!data || data.length === 0) { + toast.error('No report expense data available'); + return; + } + setIsGeneratingPDF(true); + try { + const blob = await pdf().toBlob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `Laporan-BOP-${formatDate(new Date(), 'DD-MMM-YYYY')}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + toast.error('Failed to generate PDF. Please try again.'); + return error; + } finally { + setIsGeneratingPDF(false); + } + }; + + return ( + + ); +}; + +export default ReportExpenseExport; + +const PDFDocument = ({ data }: { data: ReportExpense[] }) => { + // Group data by supplier + const groupedBySupplier = useMemo(() => { + const groups: Record = {}; + data.forEach((item) => { + const supplierName = item.supplier.name; + if (!groups[supplierName]) { + groups[supplierName] = []; + } + groups[supplierName].push(item); + }); + return groups; + }, [data]); + + // Calculate grand totals + const grandTotals = useMemo(() => { + return data.reduce( + (acc, item) => { + const pengajuanTotal = item.pengajuan.qty * item.pengajuan.price; + const realisasiTotal = item.realisasi.qty * item.realisasi.price; + return { + pengajuan: acc.pengajuan + pengajuanTotal, + realisasi: acc.realisasi + realisasiTotal, + }; + }, + { pengajuan: 0, realisasi: 0 } + ); + }, [data]); + + return ( + + + {/* Header Section */} + + + PT LUMBUNG TELUR INDONESIA + + SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. + Cipedes, Kec. Sukajadi, Kota Bandung 40162 + + + + + {/* Report Title */} + + LAPORAN BIAYA OPERASIONAL + + Tanggal Cetak: {formatDate(new Date(), 'DD MMM YYYY')} + Total Data: {data.length} transaksi + + + + {/* Grouped Tables by Supplier */} + {Object.entries(groupedBySupplier).map( + ([supplierName, items], groupIndex) => { + const supplierTotals = items.reduce( + (acc, item) => { + const pengajuanTotal = + item.pengajuan.qty * item.pengajuan.price; + const realisasiTotal = + item.realisasi.qty * item.realisasi.price; + return { + pengajuan: acc.pengajuan + pengajuanTotal, + realisasi: acc.realisasi + realisasiTotal, + }; + }, + { pengajuan: 0, realisasi: 0 } + ); + + return ( + + {/* Supplier Header */} + {supplierName} + + {/* Table */} + + {/* Table Header */} + + + No + + + No. PO + + + No. Referensi + + + Tgl Realisasi + + + Tgl Transaksi + + + Kategori + + + Lokasi + + + Kandang + + + Qty Pengajuan + + + Harga Pengajuan + + + Total Pengajuan + + + Qty Realisasi + + + Harga Realisasi + + + Total Realisasi + + + Status Pencairan + + + Status BOP + + + + {/* Table Body */} + {items.map((item, index) => { + const pengajuanTotal = + item.pengajuan.qty * item.pengajuan.price; + const realisasiTotal = + item.realisasi.qty * item.realisasi.price; + + 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.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'} + + + + + {item.latest_approval.action} + + + + ); + })} + + {/* Supplier Subtotal Row */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Subtotal + + + + {formatCurrency(supplierTotals.pengajuan)} + + + + + + + Subtotal + + + + {formatCurrency(supplierTotals.realisasi)} + + + + + + + + + + + + ); + } + )} + + {/* Grand Total Section */} + + + + + + GRAND TOTAL PENGAJUAN + + + + + {formatCurrency(grandTotals.pengajuan)} + + + + + + + GRAND TOTAL REALISASI + + + + + {formatCurrency(grandTotals.realisasi)} + + + + + + + {/* Footer */} + + + PT LUMBUNG TELUR INDONESIA + + + + + ); +}; diff --git a/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx new file mode 100644 index 00000000..ab7afb1a --- /dev/null +++ b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx @@ -0,0 +1,212 @@ +import { StyleSheet } from '@react-pdf/renderer'; + +const pdfStyles = StyleSheet.create({ + page: { + fontSize: 18, + fontFamily: 'Helvetica', + padding: 20, + backgroundColor: '#FFFFFF', + }, + header: { + marginBottom: 20, + }, + logo: { + width: 120, + height: 30, + marginBottom: 8, + }, + companyInfo: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 4, + color: '#1f74bf', + }, + address: { + fontSize: 7, + color: '#666666', + maxWidth: 400, + marginBottom: 10, + }, + divider: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + marginBottom: 15, + }, + titleSection: { + flexDirection: 'row', + marginBottom: 20, + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + title: { + fontSize: 18, + fontWeight: 'bold', + flex: 3, + color: '#1f74bf', + }, + poInfo: { + flex: 1, + fontSize: 7, + textAlign: 'right', + }, + sectionTitle: { + fontSize: 14, + fontWeight: 'bold', + marginBottom: 8, + color: '#1f74bf', + }, + table: { + borderWidth: 1, + borderColor: '#000000', + marginBottom: 15, + }, + tableRow: { + flexDirection: 'row', + }, + tableHeader: { + backgroundColor: '#F5F5F5', + }, + tableCell: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellLast: { + flex: 1, + padding: 3, + fontSize: 7, + }, + tableCellHeader: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellHeaderLast: { + flex: 1, + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellRight: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellRightLast: { + flex: 1, + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableBorderBottom: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + grandTotalRow: { + flexDirection: 'row', + borderTopWidth: 1, + borderTopColor: '#000000', + borderTopStyle: 'solid', + }, + grandTotalLabel: { + flex: 3, + padding: 3, + fontSize: 7, + fontWeight: 'bold', + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + }, + grandTotalValue: { + flex: 1, + padding: 3, + fontSize: 7, + fontWeight: 'bold', + textAlign: 'right', + borderRightWidth: 0, + }, + allocationSection: { + marginBottom: 15, + }, + allocationTable: { + borderWidth: 1, + borderColor: '#000000', + }, + innerTable: { + marginTop: 5, + borderWidth: 1, + borderColor: '#000000', + }, + innerRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + innerCell: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + innerCellLast: { + flex: 1, + padding: 3, + fontSize: 7, + }, + innerCellRight: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + innerCellRightLast: { + flex: 1, + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + footer: { + marginTop: 30, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + footerCompany: { + fontSize: 18, + fontWeight: 'bold', + textAlign: 'right', + flex: 1, + color: '#1f74bf', + }, + specialInstructionTable: { + width: '60%', + maxWidth: 300, + borderWidth: 1, + borderColor: '#000000', + flex: 1, + }, +}); + +export default pdfStyles; diff --git a/src/config/constant.ts b/src/config/constant.ts index 96fc8401..844b0d62 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -6,6 +6,17 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ link: '/dashboard', icon: 'heroicons-outline:chart-bar-square', }, + { + text: 'Laporan', + link: '/report', + icon: 'heroicons-outline:clipboard', + submenu: [ + { + text: 'Biaya Operasional', + link: '/report/expense', + }, + ], + }, { text: 'Produksi', link: '/production', diff --git a/src/dummy/report/expense.dummy.ts b/src/dummy/report/expense.dummy.ts new file mode 100644 index 00000000..f802b336 --- /dev/null +++ b/src/dummy/report/expense.dummy.ts @@ -0,0 +1,627 @@ +/** + * Dummy Data untuk Report Expense API + * + * File ini berisi dummy data untuk testing Report Expense API sebelum backend siap. + * + * Struktur data mengikuti tipe yang didefinisikan di @/types/api/report/report-expense.d.ts + * + * @example + * // Menggunakan getAllFetcher dengan SWR: + * import useSWR from 'swr'; + * import { ReportExpenseApi } from '@/services/api/report'; + * + * const { data, error, isLoading } = useSWR( + * ReportExpenseApi.basePath, + * ReportExpenseApi.getAllFetcher + * ); + * + * if (data?.status === 'success') { + * console.log(data.data); // Array of ReportExpense objects + * } + * + * @see {@link /home/sweetpotet/Documents/projects/lti-web-client/src/types/api/report/report-expense.d.ts} + */ + +import { format } from 'date-fns'; +import { + Pengajuan, + Realisasi, + ReportExpense, +} from '@/types/api/report/report-expense'; +import { BaseApiResponse, CreatedUser } from '@/types/api/api-general'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Location } from '@/types/api/master-data/location'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { Kandang } from '@/types/api/master-data/kandang'; + +// Waktu saat ini untuk created_at/updated_at +const now = format(new Date(), 'yyyy-MM-dd HH:mm:ss'); +const today = format(new Date(), 'yyyy-MM-dd'); +const yesterday = format( + new Date(new Date().setDate(new Date().getDate() - 1)), + 'yyyy-MM-dd' +); +const lastWeek = format( + new Date(new Date().setDate(new Date().getDate() - 7)), + 'yyyy-MM-dd' +); +const lastMonth = format( + new Date(new Date().setMonth(new Date().getMonth() - 1)), + 'yyyy-MM-dd' +); + +// ====================== +// 👤 Created User +// ====================== +const createdUser: CreatedUser = { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin Utama', +}; + +// ====================== +// 🏢 Supplier Dummy Data +// ====================== +const dummySuppliers: Supplier[] = [ + { + id: 1, + name: 'PT. Mitra Pakan Sejahtera', + alias: 'MPS', + pic: 'Budi Santoso', + type: 'Pakan', + category: 'Supplier Utama', + hatchery: '-', + phone: '022-1234567', + email: 'info@mitrapakan.com', + address: 'Jl. Raya Industri No. 123, Bandung', + npwp: '01.234.567.8-901.000', + account_number: '1234567890', + due_date: 30, + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 2, + name: 'CV. Sumber Ternak Jaya', + alias: 'STJ', + pic: 'Siti Rahayu', + type: 'DOC', + category: 'Supplier Utama', + hatchery: 'Hatchery Jaya', + phone: '021-9876543', + email: 'contact@sumberternak.com', + address: 'Jl. Peternakan No. 45, Jakarta', + npwp: '02.345.678.9-012.000', + account_number: '0987654321', + due_date: 45, + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 3, + name: 'PT. Agro Veteriner Indonesia', + alias: 'AVI', + pic: 'Dr. Ahmad Fauzi', + type: 'OVK', + category: 'Supplier Utama', + hatchery: '-', + phone: '031-5555666', + email: 'sales@agroveteriner.co.id', + address: 'Jl. Kesehatan Hewan No. 78, Surabaya', + npwp: '03.456.789.0-123.000', + account_number: '5678901234', + due_date: 60, + created_user: createdUser, + created_at: now, + updated_at: now, + }, +]; + +// ====================== +// 📍 Location Dummy Data +// ====================== +const dummyLocations: Location[] = [ + { + id: 1, + name: 'Farm Sukajadi', + address: 'Jl. Sukajadi No. 100, Bandung', + area: { + id: 1, + name: 'Bandung Barat', + }, + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 2, + name: 'Farm Cihampelas', + address: 'Jl. Cihampelas No. 200, Bandung', + area: { + id: 1, + name: 'Bandung Barat', + }, + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 3, + name: 'Farm Pasteur', + address: 'Jl. Pasteur No. 300, Bandung', + area: { + id: 2, + name: 'Bandung Timur', + }, + created_user: createdUser, + created_at: now, + updated_at: now, + }, +]; + +// ====================== +// 📦 Nonstock Dummy Data +// ====================== +const dummyNonstocks: Nonstock[] = [ + { + id: 1, + name: 'Listrik', + uom_id: 1, + uom: { id: 1, name: 'kWh' }, + suppliers: [], + flags: [], + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 2, + name: 'Air', + uom_id: 2, + uom: { id: 2, name: 'm³' }, + suppliers: [], + flags: [], + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 3, + name: 'Bahan Bakar', + uom_id: 3, + uom: { id: 3, name: 'Liter' }, + suppliers: [], + flags: [], + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 4, + name: 'Pemeliharaan Kandang', + uom_id: 4, + uom: { id: 4, name: 'Unit' }, + suppliers: [], + flags: [], + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 5, + name: 'Transportasi', + uom_id: 5, + uom: { id: 5, name: 'Trip' }, + suppliers: [], + flags: [], + created_user: createdUser, + created_at: now, + updated_at: now, + }, +]; + +// ====================== +// 🏠 Kandang Dummy Data +// ====================== +const dummyKandangs: Kandang[] = [ + { + id: 1, + name: 'Kandang A1', + status: 'Aktif', + location: dummyLocations[0], + capacity: 5000, + pic: { + id_user: 1, + id: 1, + name: 'Budi Kandang', + email: 'budi@example.com', + }, + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 2, + name: 'Kandang B1', + status: 'Aktif', + location: dummyLocations[1], + capacity: 4000, + pic: { + id_user: 2, + id: 2, + name: 'Siti Kandang', + email: 'siti@example.com', + }, + created_user: createdUser, + created_at: now, + updated_at: now, + }, + { + id: 3, + name: 'Kandang C1', + status: 'Aktif', + location: dummyLocations[2], + capacity: 6000, + pic: { + id_user: 3, + id: 3, + name: 'Ahmad Kandang', + email: 'ahmad@example.com', + }, + created_user: createdUser, + created_at: now, + updated_at: now, + }, +]; + +// ====================== +// 📋 Pengajuan Dummy Data +// ====================== +const dummyPengajuans: Pengajuan[] = [ + { + id: 1, + expense_id: 1, + project_flock_kandang_id: 1, + kandang_id: 1, + nonstock_id: 1, + qty: 1000, + price: 1500, + notes: 'Pengajuan biaya listrik bulan ini', + nonstock: dummyNonstocks[0], + created_at: now, + }, + { + id: 2, + expense_id: 2, + project_flock_kandang_id: 2, + kandang_id: 2, + nonstock_id: 2, + qty: 500, + price: 5000, + notes: 'Pengajuan biaya air bulan ini', + nonstock: dummyNonstocks[1], + created_at: now, + }, + { + id: 3, + expense_id: 3, + project_flock_kandang_id: 3, + kandang_id: 3, + nonstock_id: 3, + qty: 200, + price: 15000, + notes: 'Pengajuan biaya bahan bakar', + nonstock: dummyNonstocks[2], + created_at: now, + }, + { + id: 4, + expense_id: 4, + project_flock_kandang_id: 1, + kandang_id: 1, + nonstock_id: 4, + qty: 1, + price: 5000000, + notes: 'Pengajuan biaya pemeliharaan kandang', + nonstock: dummyNonstocks[3], + created_at: now, + }, + { + id: 5, + expense_id: 5, + project_flock_kandang_id: 2, + kandang_id: 2, + nonstock_id: 5, + qty: 10, + price: 500000, + notes: 'Pengajuan biaya transportasi', + nonstock: dummyNonstocks[4], + created_at: now, + }, +]; + +// ====================== +// 💰 Realisasi Dummy Data +// ====================== +const dummyRealisasis: Realisasi[] = [ + { + id: 1, + expense_nonstock_id: 1, + qty: 950, + price: 1500, + notes: 'Realisasi biaya listrik aktual', + nonstock: dummyNonstocks[0], + created_at: now, + }, + { + id: 2, + expense_nonstock_id: 2, + qty: 480, + price: 5000, + notes: 'Realisasi biaya air aktual', + nonstock: dummyNonstocks[1], + created_at: now, + }, + { + id: 3, + expense_nonstock_id: 3, + qty: 195, + price: 15000, + notes: 'Realisasi biaya bahan bakar aktual', + nonstock: dummyNonstocks[2], + created_at: now, + }, + { + id: 4, + expense_nonstock_id: 4, + qty: 1, + price: 4800000, + notes: 'Realisasi biaya pemeliharaan kandang', + nonstock: dummyNonstocks[3], + created_at: now, + }, + { + id: 5, + expense_nonstock_id: 5, + qty: 9, + price: 500000, + notes: 'Realisasi biaya transportasi', + nonstock: dummyNonstocks[4], + created_at: now, + }, +]; + +// ====================== +// 📊 Report Expense Dummy Data +// ====================== +export const dummyReportExpenses: ReportExpense[] = [ + { + id: 1, + reference_number: 'EXP-2025-001', + po_number: 'PO-2025-001', + category: 'Utilitas', + supplier: dummySuppliers[0], + realization_date: today, + transaction_date: yesterday, + location: dummyLocations[0], + pengajuan: dummyPengajuans[0], + realisasi: dummyRealisasis[0], + kandang: dummyKandangs[0], + created_at: now, + updated_at: now, + created_user: createdUser, + latest_approval: { + id: 1, + step_number: 1, + step_name: 'Manager Approval', + action: 'PENDING', + notes: '', + action_by: createdUser, + action_at: now, + }, + }, + { + id: 2, + reference_number: 'EXP-2025-002', + po_number: 'PO-2025-002', + category: 'Utilitas', + supplier: dummySuppliers[0], + realization_date: today, + transaction_date: yesterday, + location: dummyLocations[1], + pengajuan: dummyPengajuans[1], + realisasi: dummyRealisasis[1], + kandang: dummyKandangs[1], + created_at: now, + updated_at: now, + created_user: createdUser, + latest_approval: { + id: 2, + step_number: 2, + step_name: 'Finance Approval', + action: 'APPROVED', + notes: 'Disetujui oleh finance', + action_by: createdUser, + action_at: now, + }, + }, + { + id: 3, + reference_number: 'EXP-2025-003', + po_number: 'PO-2025-003', + category: 'Operasional', + supplier: dummySuppliers[1], + realization_date: lastWeek, + transaction_date: lastWeek, + location: dummyLocations[2], + pengajuan: dummyPengajuans[2], + realisasi: dummyRealisasis[2], + kandang: dummyKandangs[2], + created_at: lastWeek, + updated_at: lastWeek, + created_user: createdUser, + latest_approval: { + id: 3, + step_number: 3, + step_name: 'Director Approval', + action: 'APPROVED', + notes: 'Disetujui oleh direktur', + action_by: createdUser, + action_at: lastWeek, + }, + }, + { + id: 4, + reference_number: 'EXP-2025-004', + po_number: 'PO-2025-004', + category: 'Maintenance', + supplier: dummySuppliers[2], + realization_date: today, + transaction_date: yesterday, + location: dummyLocations[0], + pengajuan: dummyPengajuans[3], + realisasi: dummyRealisasis[3], + kandang: dummyKandangs[0], + created_at: now, + updated_at: now, + created_user: createdUser, + latest_approval: { + id: 4, + step_number: 1, + step_name: 'Manager Approval', + action: 'REJECTED', + notes: 'Biaya terlalu tinggi, perlu revisi', + action_by: createdUser, + action_at: now, + }, + }, + { + id: 5, + reference_number: 'EXP-2025-005', + po_number: 'PO-2025-005', + category: 'Operasional', + supplier: dummySuppliers[1], + realization_date: yesterday, + transaction_date: lastWeek, + location: dummyLocations[1], + pengajuan: dummyPengajuans[4], + realisasi: dummyRealisasis[4], + kandang: dummyKandangs[1], + created_at: lastWeek, + updated_at: yesterday, + created_user: createdUser, + latest_approval: { + id: 5, + step_number: 2, + step_name: 'Finance Approval', + action: 'PENDING', + notes: '', + action_by: createdUser, + action_at: yesterday, + }, + }, + { + id: 6, + reference_number: 'EXP-2025-006', + po_number: 'PO-2025-006', + category: 'Utilitas', + supplier: dummySuppliers[0], + realization_date: lastMonth, + transaction_date: lastMonth, + location: dummyLocations[2], + pengajuan: { + id: 6, + expense_id: 6, + project_flock_kandang_id: 3, + kandang_id: 3, + nonstock_id: 1, + qty: 1200, + price: 1500, + notes: 'Pengajuan biaya listrik bulan lalu', + nonstock: dummyNonstocks[0], + created_at: lastMonth, + }, + realisasi: { + id: 6, + expense_nonstock_id: 6, + qty: 1150, + price: 1500, + notes: 'Realisasi biaya listrik bulan lalu', + nonstock: dummyNonstocks[0], + created_at: lastMonth, + }, + kandang: dummyKandangs[2], + created_at: lastMonth, + updated_at: lastMonth, + created_user: createdUser, + latest_approval: { + id: 6, + step_number: 3, + step_name: 'Director Approval', + action: 'APPROVED', + notes: 'Selesai diproses', + action_by: createdUser, + action_at: lastMonth, + }, + }, +]; + +// ====================== +// 🔧 Fetcher Functions +// ====================== + +/** + * Dummy fetcher untuk mendapatkan semua data report expense + * @returns Promise dengan BaseApiResponse berisi array ReportExpense + */ +export async function dummyGetAllFetcher(): Promise< + BaseApiResponse +> { + // Simulasi delay network + await new Promise((resolve) => setTimeout(resolve, 500)); + + return { + code: 200, + status: 'success', + message: 'Data report expense berhasil diambil', + data: dummyReportExpenses, + meta: { + page: 1, + limit: 10, + total_results: dummyReportExpenses.length, + total_pages: 1, + }, + }; +} + +/** + * Dummy fetcher untuk mendapatkan single data report expense berdasarkan ID + * @param id - ID dari report expense yang ingin diambil + * @returns Promise dengan BaseApiResponse berisi single ReportExpense + */ +export async function dummyGetSingle( + id: number +): Promise> { + // Simulasi delay network + await new Promise((resolve) => setTimeout(resolve, 300)); + + const reportExpense = dummyReportExpenses.find((item) => item.id === id); + + if (!reportExpense) { + return { + code: 404, + status: 'error', + message: `Report expense dengan ID ${id} tidak ditemukan`, + }; + } + + return { + code: 200, + status: 'success', + message: 'Data report expense berhasil diambil', + data: reportExpense, + }; +} diff --git a/src/services/api/report.ts b/src/services/api/report.ts new file mode 100644 index 00000000..c8c71d44 --- /dev/null +++ b/src/services/api/report.ts @@ -0,0 +1,49 @@ +import { BaseApiService } from '@/services/api/base'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import axios from 'axios'; + +export class ReportExpenseApiService extends BaseApiService< + ReportExpense, + unknown, + unknown +> { + constructor(basePath: string) { + super(basePath); + } + + async getAllFetcher( + endpoint: string + ): Promise> { + // TODO: Remove this block when backend is ready + // const { dummyGetAllFetcher } = await import('@/dummy/report/expense.dummy'); + // return await dummyGetAllFetcher(); + + // Uncomment this when backend is ready + return await httpClientFetcher>(endpoint); + } + + async getSingle( + id: number + ): Promise | undefined> { + // TODO: Remove this block when backend is ready + 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'); diff --git a/src/types/api/report/report-expense.d.ts b/src/types/api/report/report-expense.d.ts new file mode 100644 index 00000000..51ef95c8 --- /dev/null +++ b/src/types/api/report/report-expense.d.ts @@ -0,0 +1,57 @@ +import { BaseApproval, CreatedUser } from '@/types/api/api-general'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Location } from '@/types/api/master-data/location'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { Kandang } from '@/types/api/master-data/kandang'; + +export type Pengajuan = { + id: number; + expense_id: number; + project_flock_kandang_id: number; + kandang_id: number; + nonstock_id: number; + qty: number; + price: number; + notes: string; + nonstock: Nonstock; + created_at: string; +}; + +export type Realisasi = { + id: number; + expense_nonstock_id: number; + qty: number; + price: number; + notes: string; + nonstock: Nonstock; + created_at: string; +}; + +export type ReportExpense = { + id: number; + reference_number: string; + po_number: string; + category: string; + supplier: Supplier; + realization_date: string; + transaction_date: string; + location: Location; + pengajuan: Pengajuan; + realisasi: Realisasi; + kandang: Kandang; + created_at: string; + updated_at: string; + created_user: CreatedUser; + latest_approval: BaseApproval; +}; + +export type ReportExpenseSearchParams = { + locationId: string | null; + supplierId: string | null; + kandangId: string | null; + startDate: string | null; + endDate: string | null; + category: string | null; + period: string | number; + search: string; +};