Merge branch 'feat/refactor-report-module-ui' into 'development'

[FEAT/FE] Refactor UI on Report Module and Additional Adjustment (Project Flock, Product Stock, Marketing)

See merge request mbugroup/lti-web-client!324
This commit is contained in:
Rivaldi A N S
2026-02-13 03:59:03 +00:00
56 changed files with 5198 additions and 3966 deletions
+2 -6
View File
@@ -1,13 +1,9 @@
'use client';
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
import ReportExpenseTabs from '@/components/pages/report/expense/ReportExpenseTabs';
const ReportExpense = () => {
return (
<div className='w-full p-4'>
<ReportExpenseTable />
</div>
);
return <ReportExpenseTabs />;
};
export default ReportExpense;
+2 -6
View File
@@ -1,11 +1,7 @@
import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent';
import MarketingReportContent from '@/components/pages/report/marketing/MarketingTabs';
const MarketingReportPage = () => {
return (
<section className='w-full p-4'>
<MarketingReportContent />
</section>
);
return <MarketingReportContent />;
};
export default MarketingReportPage;
+2 -2
View File
@@ -1,9 +1,9 @@
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
import ProductionResultTabs from '@/components/pages/report/production-result/ProductionResultTabs';
const ProductionResultReportPage = () => {
return (
<section className='w-full max-w-full'>
<ProductionResultContent />
<ProductionResultTabs />
</section>
);
};
+23 -30
View File
@@ -284,23 +284,22 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-error!': isError,
'ring-2 ring-error/20': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
isFocused && !startAdornment && !isError,
'border-base-content/10!': !isError && !isFocused,
'rounded-l-none!': inputPrefix && !startAdornment,
'rounded-r-none!': inputSuffix && !startAdornment,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
cn('text-gray-400 text-sm leading-tight', {
'text-error!': isError,
}),
singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
cn('m-0! text-gray-900 text-sm leading-tight', {
'text-error!': isError && !readOnly,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
@@ -404,32 +403,26 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full border transition-shadow',
// Gunakan rounded-lg untuk semua kasus
'rounded-lg!',
{
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
'border-base-content/10!': !isError && !isFocused,
}
),
cn('w-full border transition-shadow rounded-lg!', {
'bg-base-100!': !isDisabled && !readOnly,
'bg-base-200! text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'cursor-pointer!': !readOnly && !isDisabled,
'border-error!': isError,
'ring-2 ring-error/20': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment && !isError,
'border-base-content/10!': !isError && !isFocused,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
cn('text-gray-400 text-sm leading-tight', {
'text-error!': isError,
}),
singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
cn('m-0! text-gray-900 text-sm leading-tight', {
'text-error!': isError && !readOnly,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
@@ -35,6 +35,13 @@ const StockLogTable = ({
header: 'Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
@@ -49,17 +49,19 @@ const SalesOrderProductTable = ({
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onDeleteRef.current(item.id as number);
}}
className='p-0 text-error hover:text-base-content'
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
{data.length > 1 && (
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onDeleteRef.current(item.id as number);
}}
className='p-0 text-error hover:text-base-content'
>
<Icon icon='heroicons:trash' width={20} height={20} />
</Button>
)}
</div>
)}
</div>
@@ -34,7 +34,7 @@ import StatusBadge from '@/components/helper/StatusBadge';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal';
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema';
const RowOptionsMenu = ({
@@ -384,7 +384,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows;
const selectableRows = allRows.filter((row) => {
const projectFlock = row.original;
return (
projectFlock.approval?.step_number === 1 &&
projectFlock.approval?.action !== 'REJECTED'
);
});
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
@@ -398,6 +404,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
selectableRows.forEach((row) => row.toggleSelected(shouldSelect));
};
const hasNoSelectableRows = selectableRows.length === 0;
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
@@ -405,6 +413,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={hasNoSelectableRows}
/>
</div>
);
@@ -845,6 +854,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={(row) => {
const projectFlock = row.original;
return (
projectFlock.approval?.step_number === 1 &&
projectFlock.approval?.action !== 'REJECTED'
);
}}
withCheckbox
className={{
containerClassName: cn('p-3', {
@@ -52,7 +52,7 @@ import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import StatusBadge from '@/components/helper/StatusBadge';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store';
import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store';
interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -26,7 +26,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton';
import RequirePermission from '@/components/helper/RequirePermission';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
import FloatingActionsButton from '@/components/FloatingActionsButton';
import Modal from '@/components/Modal';
import SelectInput, {
@@ -7,7 +7,7 @@ import { Icon } from '@iconify/react';
import { toast } from 'react-hot-toast';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
import RequirePermission from '@/components/helper/RequirePermission';
import Table from '@/components/Table';
import {
@@ -7,7 +7,7 @@ import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { useUiStore } from '@/stores/ui/ui.store';
import { useUniformityStore } from '@/stores/uniformity/uniformity.store';
import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store';
import RequirePermission from '@/components/helper/RequirePermission';
import Table from '@/components/Table';
import { useRouter } from 'next/navigation';
@@ -1,901 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Button from '@/components/Button';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
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 { ReportExpenseApi } from '@/services/api/report';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import Pagination from '@/components/Pagination';
import Dropdown from '@/components/dropdown/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import * as XLSX from 'xlsx';
import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
import toast from 'react-hot-toast';
import {
KandangApi,
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
import { Nonstock } from '@/types/api/master-data/nonstock';
const ReportExpenseTable = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [pdfProgress, setPdfProgress] = useState(0);
const [excelProgress, setExcelProgress] = useState(0);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const {
state: filterState,
updateFilter,
setPage,
setPageSize,
reset: resetFilterState,
toQueryString,
} = useTableFilter({
initial: {
location_id: '',
supplier_id: '',
kandang_id: '',
nonstock_id: '',
realization_date: '',
category: '',
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
// ===== SELECT OPTIONS =====
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const categoryOptions = useMemo(
() => [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'Non BOP' },
],
[]
);
// Mendapatkan value option select dari filter state
const selectedLocation = useMemo(
() =>
locationOptions.find(
(opt) => String(opt.value) === filterState.location_id
) || null,
[locationOptions, filterState.location_id]
);
const selectedSupplier = useMemo(
() =>
supplierOptions.find(
(opt) => String(opt.value) === filterState.supplier_id
) || null,
[supplierOptions, filterState.supplier_id]
);
const selectedKandang = useMemo(
() =>
kandangOptions.find(
(opt) => String(opt.value) === filterState.kandang_id
) || null,
[kandangOptions, filterState.kandang_id]
);
const selectedNonstock = useMemo(
() =>
nonstockOptions.find(
(opt) => String(opt.value) === filterState.nonstock_id
) || null,
[nonstockOptions, filterState.nonstock_id]
);
const selectedCategory = useMemo(
() =>
categoryOptions.find((opt) => opt.value === filterState.category) || null,
[categoryOptions, filterState.category]
);
// ===== FILTER CHANGE HANDLERS =====
const locationChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('location_id', option ? String(option.value) : '');
updateFilter('kandang_id', '');
setIsSubmitted(false);
},
[updateFilter]
);
const kandangChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('kandang_id', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const supplierChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('supplier_id', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const nonstockChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('nonstock_id', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const categoryChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType;
updateFilter('category', option ? String(option.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const realizationDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
updateFilter('realization_date', e.target.value || '');
setIsSubmitted(false);
},
[updateFilter]
);
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setIsSubmitted(false);
},
[updateFilter]
);
// ===== RESET FILTERS =====
const resetFilters = useCallback(() => {
resetFilterState();
setIsSubmitted(false);
}, [resetFilterState]);
// ===== SUBMIT HANDLER =====
const handleSubmit = useCallback(() => {
setIsSubmitted(true);
setPage(1);
}, [setPage]);
// ===== DATA FETCHING FOR TABLE =====
const { data: reportExpenseResponse, isLoading } = useSWR(
isSubmitted
? () => {
return ['report-expense', toQueryString()];
}
: null,
([, query]) => {
const endpoint = `${ReportExpenseApi.basePath}${query}`;
return ReportExpenseApi.getAllFetcher(endpoint);
}
);
const data: ReportExpense[] = useMemo(
() =>
isResponseSuccess(reportExpenseResponse)
? (reportExpenseResponse?.data as ReportExpense[]) || []
: [],
[reportExpenseResponse]
);
const meta = useMemo(
() =>
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
? reportExpenseResponse.meta
: null,
[reportExpenseResponse]
);
// ===== EXPORT DATA FETCHER =====
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
const params = new URLSearchParams(toQueryString().replace('?', ''));
params.set('limit', 'limit');
params.set('page', '1');
const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`;
const response = await ReportExpenseApi.getAllFetcher(endpoint);
return isResponseSuccess(response) ? response.data : null;
}, [toQueryString]);
// ===== EXPORT HANDLERS =====
const handleExportPdf = useCallback(async () => {
if (isPdfExportLoading) return;
setIsPdfExportLoading(true);
setPdfProgress(0);
await new Promise((resolve) =>
requestAnimationFrame(() => resolve(undefined))
);
try {
// Stage 1: Fetching data (0-20%)
setPdfProgress(10);
await new Promise((resolve) => setTimeout(resolve, 50));
const allData = await reportExpenseExport();
if (!allData || allData.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
setIsPdfExportLoading(false);
setPdfProgress(0);
return;
}
// Stage 2: Data fetched - langsung loncat ke progress tinggi
setPdfProgress(30);
await new Promise((resolve) => setTimeout(resolve, 50));
const progressInterval = setInterval(() => {
setPdfProgress((prev) => {
// Increment kecil dan random antara 0.5-2%
const increment = Math.random() * 1.5 + 0.5;
const newProgress = Math.min(prev + increment, 50);
return newProgress;
});
}, 300); // Update setiap 300ms
const pdfParams = {
location_name: selectedLocation?.label,
supplier_name: selectedSupplier?.label,
kandang_name: selectedKandang?.label,
nonstock_name: selectedNonstock?.label,
category: selectedCategory?.label,
realization_date: filterState.realization_date,
search: filterState.search,
};
setDropdownOpen(false);
// Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck
const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85%
setPdfProgress(baseProgress);
await new Promise((resolve) => setTimeout(resolve, 100));
// Stage 4: Berikan jeda untuk UI update
await new Promise((resolve) =>
requestAnimationFrame(() => resolve(undefined))
);
// Proses PDF yang sebenarnya
await generateReportExpensePDF(allData, pdfParams);
clearInterval(progressInterval);
// Stage 5: Finalizing (98-100%)
setPdfProgress(99);
await new Promise((resolve) => setTimeout(resolve, 100));
setPdfProgress(100);
toast.success('PDF berhasil dibuat dan diunduh.');
// Reset progress setelah selesai
setTimeout(() => setPdfProgress(0), 500);
} catch (error) {
console.error('PDF Export Error:', error);
toast.error('Gagal membuat PDF. Silakan coba lagi.');
setPdfProgress(0);
} finally {
setIsPdfExportLoading(false);
}
}, [
reportExpenseExport,
selectedLocation,
selectedSupplier,
selectedKandang,
selectedNonstock,
selectedCategory,
filterState.realization_date,
filterState.search,
]);
const handleExportExcel = useCallback(async () => {
if (isExcelExportLoading) return;
setIsExcelExportLoading(true);
setExcelProgress(0);
setDropdownOpen(false);
await new Promise((resolve) =>
requestAnimationFrame(() => resolve(undefined))
);
try {
// Stage 1: Fetching data (0-20%)
setExcelProgress(15);
await new Promise((resolve) => setTimeout(resolve, 50));
const allDataForExport = await reportExpenseExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
setIsExcelExportLoading(false);
setExcelProgress(0);
return;
}
// Stage 2: Data fetched (20-40%)
setExcelProgress(30);
await new Promise((resolve) => setTimeout(resolve, 50));
// Stage 3: Grouping data (40-60%)
setExcelProgress(50);
const groupedBySupplier: Record<string, ReportExpense[]> = {};
allDataForExport.forEach((item) => {
const supplierName = item.supplier?.name || 'Unknown Supplier';
if (!groupedBySupplier[supplierName]) {
groupedBySupplier[supplierName] = [];
}
groupedBySupplier[supplierName].push(item);
});
await new Promise((resolve) => setTimeout(resolve, 50));
// Stage 4: Creating workbook (60-80%)
setExcelProgress(70);
const workbook = XLSX.utils.book_new();
const supplierEntries = Object.entries(groupedBySupplier);
const totalSuppliers = supplierEntries.length;
for (let i = 0; i < supplierEntries.length; i++) {
const [supplierName, supplierData] = supplierEntries[i];
// Update progress per supplier
const progressIncrement = (20 / totalSuppliers) * (i + 1);
setExcelProgress(70 + progressIncrement);
const totals = supplierData.reduce(
(acc, item) => ({
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
total_pengajuan:
acc.total_pengajuan +
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
total_realisasi:
acc.total_realisasi +
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
}),
{
qty_pengajuan: 0,
total_pengajuan: 0,
qty_realisasi: 0,
total_realisasi: 0,
}
);
const excelData = supplierData.map((item, index) => ({
No: index + 1,
'No. PO': item.po_number || '',
'No. Referensi': item.reference_number || '',
'Tanggal Realisasi': item.realization_date
? formatDate(item.realization_date, 'DD MMM YYYY')
: '',
'Tanggal Transaksi': item.transaction_date
? formatDate(item.transaction_date, 'DD MMM YYYY')
: '',
Kategori: item.category || '',
Produk: item.pengajuan?.nonstock?.name || '',
Lokasi: item.kandang?.location?.name || '',
Kandang: item.kandang?.name || '',
'Qty Pengajuan': item.pengajuan?.qty || 0,
'Harga Pengajuan': item.pengajuan?.price || 0,
'Total Pengajuan':
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
'Qty Realisasi': item.realisasi?.qty || 0,
'Harga Realisasi': item.realisasi?.price || 0,
'Total Realisasi':
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
'Status Pencairan': item.latest_approval?.step_name || '',
}));
excelData.push({
No: 'Total' as unknown as number,
'No. PO': '',
'No. Referensi': '',
'Tanggal Realisasi': '',
'Tanggal Transaksi': '',
Kategori: '',
Produk: '',
Lokasi: '',
Kandang: '',
'Qty Pengajuan': totals.qty_pengajuan,
'Harga Pengajuan': 0,
'Total Pengajuan': totals.total_pengajuan,
'Qty Realisasi': totals.qty_realisasi,
'Harga Realisasi': 0,
'Total Realisasi': totals.total_realisasi,
'Status Pencairan': '',
});
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 20 }, // No. PO
{ wch: 20 }, // No. Referensi
{ wch: 15 }, // Tanggal Realisasi
{ wch: 15 }, // Tanggal Transaksi
{ wch: 15 }, // Kategori
{ wch: 30 }, // Produk
{ wch: 20 }, // Lokasi
{ wch: 15 }, // Kandang
{ wch: 15 }, // Qty Pengajuan
{ wch: 15 }, // Harga Pengajuan
{ wch: 20 }, // Total Pengajuan
{ wch: 15 }, // Qty Realisasi
{ wch: 15 }, // Harga Realisasi
{ wch: 20 }, // Total Realisasi
{ wch: 20 }, // Status Pencairan
];
worksheet['!cols'] = colWidths;
const sheetName = supplierName.slice(0, 31);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
// Small delay to allow UI update
if (i < supplierEntries.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
// Stage 5: Writing file (90-100%)
setExcelProgress(95);
await new Promise((resolve) => setTimeout(resolve, 50));
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
setExcelProgress(100);
toast.success('Excel berhasil dibuat dan diunduh.');
// Reset progress
setTimeout(() => setExcelProgress(0), 500);
} catch (error) {
console.error('Excel Export Error:', error);
toast.error('Gagal membuat Excel. Silakan coba lagi.');
setExcelProgress(0);
} finally {
setIsExcelExportLoading(false);
}
}, [isExcelExportLoading, reportExpenseExport]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && filterState.page < meta.total_pages) {
setPage(filterState.page + 1);
}
};
const handlePrevPage = () => {
if (filterState.page > 1) {
setPage(filterState.page - 1);
}
};
// ===== TABLE COLUMNS DEFINITION =====
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
return [
{
header: 'No',
accessorFn: (_, index) =>
(filterState.page - 1) * filterState.pageSize + 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: 'Produk',
accessorFn: (row) => row.pengajuan?.nonstock?.name,
},
{
header: 'Supplier',
accessorFn: (row) => row.supplier?.name,
},
{
header: 'Lokasi',
accessorFn: (row) => row.kandang?.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') || '0',
},
{
header: 'Harga',
id: 'harga_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0),
},
{
header: 'Total',
id: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
cell: ({ row }) => {
const total =
(row.original.pengajuan?.qty || 0) *
(row.original.pengajuan?.price || 0);
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') || '0',
},
{
header: 'Harga',
id: 'harga_realisasi',
accessorFn: (row) => row.realisasi?.price,
cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0),
},
{
header: 'Total',
id: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
cell: ({ row }) => {
const total =
(row.original.realisasi?.qty || 0) *
(row.original.realisasi?.price || 0);
return formatCurrency(total);
},
},
],
},
{
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge
approval={props.row.original?.latest_approval}
/>
),
},
{
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
),
},
];
}, [filterState.page, filterState.pageSize]);
// ===== RENDER =====
return (
<div className='flex flex-col gap-4'>
{isAnyExportLoading && (
<div className='flex flex-col gap-2'>
<progress
className='progress progress-primary w-full'
value={
isPdfExportLoading
? pdfProgress
: isExcelExportLoading
? excelProgress
: 0
}
max='100'
></progress>
{((isPdfExportLoading && pdfProgress > 0) ||
(isExcelExportLoading && excelProgress > 0)) && (
<div className='text-sm text-center text-gray-600'>
<div className='font-semibold'>
{(() => {
const currentProgress = isPdfExportLoading
? pdfProgress
: excelProgress;
const exportType = isPdfExportLoading ? 'PDF' : 'Excel';
if (currentProgress < 20)
return 'Mengambil data dari server...';
if (currentProgress < 30) return 'Memproses data laporan...';
if (currentProgress < 40)
return `Menyiapkan struktur dokumen ${exportType}...`;
if (currentProgress < 50)
return 'Mengelompokkan data per supplier...';
if (currentProgress < 70)
return 'Merender tabel dan kalkulasi...';
if (currentProgress < 96)
return `Memformat dokumen ${exportType}...`;
if (currentProgress < 100)
return 'Menyelesaikan dan mengunduh...';
return 'Selesai!';
})()}{' '}
{Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}%
</div>
{((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) ||
(isExcelExportLoading &&
excelProgress >= 35 &&
excelProgress < 90)) && (
<div className='text-xs text-gray-500 mt-1'>
{(isPdfExportLoading ? pdfProgress : excelProgress) < 96
? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...'
: 'Sedang memproses baris data. Hampir selesai...'}
</div>
)}
</div>
)}
</div>
)}
<Card
title='Laporan Biaya Operasional'
variant='bordered'
className={{
wrapper: 'w-full',
}}
footer={
<div className='flex flex-col gap-6'>
<div className='flex flex-row items-center justify-end gap-2'>
<div className='flex flex-row items-center gap-2'>
<Button className='min-w-24' onClick={handleSubmit}>
Cari
</Button>
<Button
className='min-w-24'
color='warning'
onClick={resetFilters}
>
Reset
</Button>
</div>
<div>
<Dropdown
trigger={
<Button
color='success'
className='min-w-24'
isLoading={isAnyExportLoading}
onClick={() => {
setDropdownOpen(!dropdownOpen);
}}
>
Export
</Button>
}
align='end'
direction='bottom'
open={dropdownOpen}
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
</div>
</div>
}
>
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
<SelectInput
isClearable
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
placeholder='Lokasi'
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
/>
<SelectInput
isClearable
label='Kandang'
options={kandangOptions}
isLoading={isLoadingKandangOptions}
placeholder='Kandang'
value={selectedKandang}
onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
/>
<SelectInput
isClearable
label='Supplier'
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
placeholder='Supplier'
value={selectedSupplier}
onChange={supplierChangeHandler}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
/>
<SelectInput
isClearable
label='Produk'
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
placeholder='Produk'
value={selectedNonstock}
onChange={nonstockChangeHandler}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
/>
<SelectInput
isClearable
label='Kategori'
options={categoryOptions}
placeholder='Kategori'
value={selectedCategory}
onChange={categoryChangeHandler}
/>
<DateInput
label='Tanggal Realisasi'
value={filterState.realization_date}
onChange={realizationDateChangeHandler}
name='realization_date'
placeholder='Tanggal Realisasi'
/>
<DebouncedTextInput
label='Cari'
name='search'
value={filterState.search}
onChange={searchChangeHandler}
placeholder='Cari'
startAdornment={<Icon icon='mdi:magnify' width={24} height={24} />}
/>
</div>
</Card>
{/* ===== TABLE CONTENT ===== */}
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
<>
<Table<ReportExpense>
columns={columns}
data={data}
pageSize={10}
className={{
containerClassName: 'mb-0',
headerRowClassName: cn(
TABLE_DEFAULT_STYLING,
'whitespace-nowrap'
),
bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'),
paginationClassName: 'hidden',
}}
/>
{meta && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</>
)}
</div>
);
};
export default ReportExpenseTable;
@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import Tabs from '@/components/Tabs';
import { useReportTabStore } from '@/stores/report/report-tab.store';
import ReportExpenseTab from './tab/ReportExpenseTab';
const ReportExpenseTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useReportTabStore((state) => state.tabActions);
const tabs = [
{
id: '1',
label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'1'} />,
},
];
return (
<section className='w-full'>
<Tabs
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
tab: 'w-fit',
content: 'p-0 m-0',
}}
sideContent={tabActions[activeTabId] || null}
/>
</section>
);
};
export default ReportExpenseTabs;
@@ -2,6 +2,7 @@ import { ReportExpense } from '@/types/api/report/report-expense';
import { formatCurrency, formatDate } from '@/lib/helper';
import jsPDF from 'jspdf';
import autoTable, { UserOptions } from 'jspdf-autotable';
interface jsPDFWithAutoTable extends jsPDF {
lastAutoTable: {
finalY: number;
@@ -0,0 +1,109 @@
import * as XLSX from 'xlsx';
import { ReportExpense } from '@/types/api/report/report-expense';
import { formatCurrency, formatDate } from '@/lib/helper';
export const generateReportExpenseExcel = async (
data: ReportExpense[]
): Promise<void> => {
// Group by supplier
const groupedBySupplier: Record<string, ReportExpense[]> = {};
data.forEach((item) => {
const supplierName = item.supplier?.name || 'Unknown Supplier';
if (!groupedBySupplier[supplierName]) {
groupedBySupplier[supplierName] = [];
}
groupedBySupplier[supplierName].push(item);
});
const workbook = XLSX.utils.book_new();
Object.entries(groupedBySupplier).forEach(([supplierName, supplierData]) => {
const totals = supplierData.reduce(
(acc, item) => ({
qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0),
total_pengajuan:
acc.total_pengajuan +
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0),
total_realisasi:
acc.total_realisasi +
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
}),
{
qty_pengajuan: 0,
total_pengajuan: 0,
qty_realisasi: 0,
total_realisasi: 0,
}
);
const excelData = supplierData.map((item, index) => ({
No: index + 1,
'No. PO': item.po_number || '',
'No. Referensi': item.reference_number || '',
'Tanggal Realisasi': item.realization_date
? formatDate(item.realization_date, 'DD MMM YYYY')
: '',
'Tanggal Transaksi': item.transaction_date
? formatDate(item.transaction_date, 'DD MMM YYYY')
: '',
Kategori: item.category || '',
Produk: item.pengajuan?.nonstock?.name || '',
Lokasi: item.kandang?.location?.name || '',
Kandang: item.kandang?.name || '',
'Qty Pengajuan': item.pengajuan?.qty || 0,
'Harga Pengajuan': item.pengajuan?.price || 0,
'Total Pengajuan':
(item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0),
'Qty Realisasi': item.realisasi?.qty || 0,
'Harga Realisasi': item.realisasi?.price || 0,
'Total Realisasi':
(item.realisasi?.qty || 0) * (item.realisasi?.price || 0),
'Status Pencairan': item.latest_approval?.step_name || '',
}));
excelData.push({
No: 'Total' as unknown as number,
'No. PO': '',
'No. Referensi': '',
'Tanggal Realisasi': '',
'Tanggal Transaksi': '',
Kategori: '',
Produk: '',
Lokasi: '',
Kandang: '',
'Qty Pengajuan': totals.qty_pengajuan,
'Harga Pengajuan': 0,
'Total Pengajuan': totals.total_pengajuan,
'Qty Realisasi': totals.qty_realisasi,
'Harga Realisasi': 0,
'Total Realisasi': totals.total_realisasi,
'Status Pencairan': '',
});
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 },
{ wch: 20 },
{ wch: 20 },
{ wch: 15 },
{ wch: 15 },
{ wch: 15 },
{ wch: 30 },
{ wch: 20 },
{ wch: 15 },
{ wch: 15 },
{ wch: 15 },
{ wch: 20 },
{ wch: 15 },
{ wch: 20 },
];
worksheet['!cols'] = colWidths;
const sheetName = supplierName.slice(0, 31);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
};
@@ -0,0 +1,73 @@
import { OptionType } from '@/components/input/SelectInput';
import * as yup from 'yup';
export type ReportExpenseFilterProps = {
location_id: string | null;
supplier_id: string | null;
kandang_id: string | null;
nonstock_id: string | null;
realization_date: string | null;
category: string | null;
};
export type ReportExpenseFilterFormType = {
location_id: OptionType | null;
supplier_id: OptionType | null;
kandang_id: OptionType | null;
nonstock_id: OptionType | null;
realization_date: string | null;
category: OptionType | null;
};
export const ReportExpenseFilterSchema = yup.object({
location_id: yup
.mixed<OptionType>()
.nullable()
.test('is-not-empty', 'Lokasi wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}),
supplier_id: yup
.mixed<OptionType>()
.nullable()
.test('is-not-empty', 'Supplier wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}),
kandang_id: yup
.mixed<OptionType>()
.nullable()
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}),
nonstock_id: yup
.mixed<OptionType>()
.nullable()
.test('is-not-empty', 'Produk wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}),
realization_date: yup.string().nullable(),
category: yup
.mixed<OptionType>()
.nullable()
.test('is-not-empty', 'Kategori wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}),
}) as yup.ObjectSchema<ReportExpenseFilterFormType>;
export type ReportExpenseFilterValues = yup.InferType<
typeof ReportExpenseFilterSchema
>;
@@ -1,365 +0,0 @@
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',
},
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',
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: 8,
},
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;
@@ -0,0 +1,49 @@
import React from 'react';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ColumnDef } from '@tanstack/react-table';
type ReportExpenseColumn =
| ColumnDef<ReportExpense>
| {
header: string;
columns: Array<{
header: string;
accessorKey?: string;
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
}>;
};
const ReportExpenseSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ReportExpenseColumn[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default ReportExpenseSkeleton;
@@ -0,0 +1,755 @@
'use client';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { useFormik } from 'formik';
import {
ReportExpenseFilterSchema,
type ReportExpenseFilterValues,
} from '@/components/pages/report/expense/filter/ReportExpenseFilter';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import Table from '@/components/Table';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report';
import { isResponseSuccess } from '@/lib/api-helper';
import { useReportTabStore } from '@/stores/report/report-tab.store';
import Modal, { useModal } from '@/components/Modal';
import Pagination from '@/components/Pagination';
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF';
import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX';
import toast from 'react-hot-toast';
import {
KandangApi,
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
interface ReportExpenseTabProps {
tabId: string;
}
interface FilterParams {
location_id?: string;
supplier_id?: string;
kandang_id?: string;
nonstock_id?: string;
realization_date?: string;
category?: string;
search?: string;
}
const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
const [filterParams, setFilterParams] = useState<FilterParams>({});
// ===== PAGINATION STATE =====
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const filterModal = useModal();
// ===== OPTIONS =====
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect<Kandang>(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstocks,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name', 'search');
const categoryOptions = useMemo(
() => [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'Non BOP' },
],
[]
);
// ===== FORMIK SETUP =====
const formik = useFormik<ReportExpenseFilterValues>({
initialValues: {
location_id: null,
supplier_id: null,
kandang_id: null,
nonstock_id: null,
realization_date: null,
category: null,
},
validationSchema: ReportExpenseFilterSchema,
onSubmit: (values) => {
setFilterParams({
location_id: values.location_id?.value
? String(values.location_id.value)
: undefined,
supplier_id: values.supplier_id?.value
? String(values.supplier_id.value)
: undefined,
kandang_id: values.kandang_id?.value
? String(values.kandang_id.value)
: undefined,
nonstock_id: values.nonstock_id?.value
? String(values.nonstock_id.value)
: undefined,
realization_date: values.realization_date || undefined,
category: values.category?.value
? String(values.category.value)
: undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setPage(1);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setPage(1);
},
});
// ===== FILTER VALUES =====
const locationValue = useMemo(
() => formik.values.location_id,
[formik.values.location_id]
);
const supplierValue = useMemo(
() => formik.values.supplier_id,
[formik.values.supplier_id]
);
const kandangValue = useMemo(
() => formik.values.kandang_id,
[formik.values.kandang_id]
);
const nonstockValue = useMemo(
() => formik.values.nonstock_id,
[formik.values.nonstock_id]
);
const categoryValue = useMemo(
() => formik.values.category,
[formik.values.category]
);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.location_id) count += 1;
if (filterParams.supplier_id) count += 1;
if (filterParams.kandang_id) count += 1;
if (filterParams.nonstock_id) count += 1;
if (filterParams.realization_date) count += 1;
if (filterParams.category) count += 1;
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR(
isSubmitted
? () => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category)
params.append('category', filterParams.category);
params.append('page', String(page));
params.append('limit', String(pageSize));
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
}
: null,
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
);
const data: ReportExpense[] = useMemo(
() =>
isResponseSuccess(reportExpenseResponse)
? (reportExpenseResponse.data as ReportExpense[]) || []
: [],
[reportExpenseResponse]
);
const meta = useMemo(
() =>
isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta
? reportExpenseResponse.meta
: null,
[reportExpenseResponse]
);
// ===== EXPORT DATA FETCHER =====
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category) params.append('category', filterParams.category);
params.append('limit', '100');
params.append('page', '1');
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${params.toString()}`
);
return isResponseSuccess(response) ? response.data : null;
}, [filterParams]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await reportExpenseExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateReportExpenseExcel(allDataForExport);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [reportExpenseExport]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allData = await reportExpenseExport();
if (!allData || allData.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const pdfParams = {
location_name: locationValue?.label,
supplier_name: supplierValue?.label,
realization_date: formik.values.realization_date || undefined,
};
await generateReportExpensePDF(allData, pdfParams);
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
reportExpenseExport,
locationValue,
supplierValue,
kandangValue,
nonstockValue,
categoryValue,
formik.values.realization_date,
]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
onClick={() => filterModal.openModal()}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPDF}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
hasFilters,
activeFiltersCount,
isAnyExportLoading,
handleExportExcel,
handleExportPDF,
setTabActions,
]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [tabId, clearTabActions]);
// ===== TABLE COLUMNS DEFINITION =====
const columns = useMemo((): ColumnDef<ReportExpense>[] => {
return [
{
header: 'No',
cell: (props) => (page - 1) * pageSize + props.row.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: 'Produk',
accessorFn: (row) => row.pengajuan?.nonstock?.name,
},
{
header: 'Supplier',
accessorFn: (row) => row.supplier?.name,
},
{
header: 'Lokasi',
accessorFn: (row) => row.kandang?.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') || '0',
},
{
header: 'Harga',
id: 'harga_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0),
},
{
header: 'Total',
id: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
cell: ({ row }) => {
const total =
(row.original.pengajuan?.qty || 0) *
(row.original.pengajuan?.price || 0);
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') || '0',
},
{
header: 'Harga',
id: 'harga_realisasi',
accessorFn: (row) => row.realisasi?.price,
cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0),
},
{
header: 'Total',
id: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
cell: ({ row }) => {
const total =
(row.original.realisasi?.qty || 0) *
(row.original.realisasi?.price || 0);
return formatCurrency(total);
},
},
],
},
{
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge
approval={props.row.original?.latest_approval}
/>
),
},
{
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
),
},
];
}, [page, pageSize]);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<ReportExpenseSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !data || data.length === 0 ? (
<ReportExpenseSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
<>
<Table
data={data}
columns={columns}
pageSize={pageSize}
page={meta?.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
{meta && (
<div className='max-w-sm ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setPage((currPage) =>
currPage > 1 ? currPage - 1 : currPage
)
}
onNextPage={() =>
setPage((currPage) =>
meta && meta.total_pages && currPage < meta.total_pages
? currPage + 1
: currPage
)
}
onPageChange={(pageNumber) => setPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-3'>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
isLoading={isLoadingLocations}
value={locationValue}
onChange={(val) => {
formik.setFieldValue('location_id', val);
formik.setFieldValue('kandang_id', null);
}}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={kandangOptions}
isLoading={isLoadingKandangs}
value={kandangValue}
onChange={(val) => {
formik.setFieldValue('kandang_id', val);
}}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isClearable
isDisabled={!formik.values.location_id}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
options={supplierOptions}
isLoading={isLoadingSuppliers}
value={supplierValue}
onChange={(val) => {
formik.setFieldValue('supplier_id', val);
}}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={nonstockOptions}
isLoading={isLoadingNonstocks}
value={nonstockValue}
onChange={(val) => {
formik.setFieldValue('nonstock_id', val);
}}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={categoryValue}
onChange={(val) => {
formik.setFieldValue('category', val);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
<DateInput
label='Tanggal Realisasi'
name='realization_date'
value={formik.values.realization_date || ''}
onChange={(e) => {
formik.setFieldValue(
'realization_date',
e.target.value || null
);
}}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default ReportExpenseTab;
@@ -4,11 +4,11 @@ import { useState } from 'react';
import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
import { useReportTabStore } from '@/stores/report/report-tab.store';
const FinanceTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useFinanceTabStore((state) => state.tabActions);
const tabActions = useReportTabStore((state) => state.tabActions);
const tabs = [
{
@@ -0,0 +1,31 @@
import * as yup from 'yup';
export type CustomerPaymentFilterType = {
start_date: string | null;
end_date: string | null;
customer_ids: string | null;
filter_by: string | null;
};
export const CustomerPaymentFilterSchema = yup.object({
start_date: yup.string().optional().nullable(),
end_date: yup
.string()
.optional()
.nullable()
.test(
'is-greater-than-start',
'Tanggal akhir tidak boleh masa lampau',
function (value) {
const { start_date } = this.parent;
if (!start_date || !value) return true;
return new Date(value) >= new Date(start_date);
}
),
customer_ids: yup.string().nullable(),
filter_by: yup.string().nullable(),
});
export type CustomerPaymentFilterValues = yup.InferType<
typeof CustomerPaymentFilterSchema
>;
@@ -2,17 +2,22 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
// import { UserApi } from '@/services/api/user';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper';
import {
formatCurrency,
formatDate,
formatNumber,
formatTitleCase,
cn,
} from '@/lib/helper';
import {
CustomerPaymentReport,
CustomerPaymentSummary,
@@ -20,20 +25,31 @@ import {
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import Modal from '@/components/Modal';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import {
CustomerPaymentFilterSchema,
CustomerPaymentFilterType,
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
import { useReportTabStore } from '@/stores/report/report-tab.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme';
interface CustomerPaymentTabProps {
tabId: string;
}
interface FilterParams {
customer_ids?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
}
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
@@ -46,31 +62,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [appliedFilterCustomer, setAppliedFilterCustomer] = useState<
typeof customerOptions
>([]);
// TODO: Uncomment when BE is ready
// const [appliedFilterSales, setAppliedFilterSales] = useState<
// typeof salesOptions
// >([]);
const [appliedFilterByType, setAppliedFilterByType] = useState<
(typeof dataTypeOptions)[0] | null
>(null);
const [appliedFilterStartDate, setAppliedFilterStartDate] = useState('');
const [appliedFilterEndDate, setAppliedFilterEndDate] = useState('');
const [filterParams, setFilterParams] = useState<FilterParams>({});
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
[]
);
// TODO: Uncomment when BE is ready
// const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const filterModal = useModal();
const dataTypeOptions = useMemo(
@@ -81,10 +76,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
[]
);
const [filterByType, setFilterByType] = useState<
(typeof dataTypeOptions)[0] | null
>(null);
const {
options: customerOptions,
setInputValue: setCustomerInputValue,
@@ -92,108 +83,67 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// TODO: Uncomment when BE is ready
// const {
// options: salesOptions,
// setInputValue: setSalesInputValue,
// isLoadingOptions: isLoadingSales,
// loadMore: loadMoreSales,
// hasMore: hasMoreSales,
// } = useSelect(UserApi.basePath, 'id', 'name', 'search');
const getPaymentStatusColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info/10 text-black border-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning/10 text-black border-warning';
}
return 'bg-gray-100 text-black border-gray-300';
};
const getPaymentStatusIndicatorColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning';
}
return 'bg-gray-400';
};
const getPaymentStatusText = (notes: string) => {
return notes
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// ===== FILTER HANDLERS =====
const handleFilterModalOpen = useCallback(() => {
setFilterCustomer(appliedFilterCustomer);
// setFilterSales(appliedFilterSales);
setFilterByType(appliedFilterByType);
setFilterStartDate(appliedFilterStartDate);
setFilterEndDate(appliedFilterEndDate);
const handleFilterModalOpen = () => {
filterModal.openModal();
}, [
filterModal,
appliedFilterCustomer,
appliedFilterByType,
appliedFilterStartDate,
appliedFilterEndDate,
]);
formik.validateForm();
};
const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterCustomer([]);
setFilterByType(null);
setFilterStartDate('');
setFilterEndDate('');
setAppliedFilterCustomer([]);
setAppliedFilterByType(null);
setAppliedFilterStartDate('');
setAppliedFilterEndDate('');
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
// ===== FORMIK SETUP =====
const formik = useFormik<CustomerPaymentFilterType>({
initialValues: {
start_date: null,
end_date: null,
customer_ids: null,
filter_by: null,
},
validationSchema: CustomerPaymentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
customer_ids: values.customer_ids || undefined,
filter_by: values.filter_by || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
},
});
const getPaymentStatusBadgeColor = (notes: string): Color => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'primary';
}
}, [dateErrorShown]);
const handleApplyFilters = useCallback(() => {
setAppliedFilterCustomer(filterCustomer);
setAppliedFilterByType(filterByType);
setAppliedFilterStartDate(filterStartDate);
setAppliedFilterEndDate(filterEndDate);
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}, [
filterModal,
filterCustomer,
filterByType,
filterStartDate,
filterEndDate,
]);
if (normalizedValue.includes('belum')) {
return 'warning';
}
return 'neutral';
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilterStartDate(value);
formik.setFieldValue('start_date', value || null);
if (value && filterEndDate) {
if (value && formik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(filterEndDate);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true);
@@ -214,16 +164,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
setHasDateError(false);
}
},
[filterEndDate, dateErrorShown]
[formik, dateErrorShown]
);
const handleEndDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilterEndDate(value);
formik.setFieldValue('end_date', value || null);
if (value && filterStartDate) {
const startDateObj = new Date(filterStartDate);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
@@ -244,41 +194,46 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
setDateErrorShown(false);
}
},
[filterStartDate, dateErrorShown]
[formik, dateErrorShown]
);
// ===== FILTER HELPERS =====
const customerIdsValue = useMemo(() => {
if (!formik.values.customer_ids) return [];
return customerOptions.filter((opt) =>
formik.values.customer_ids?.split(',').includes(String(opt.value))
);
}, [formik.values.customer_ids, customerOptions]);
const filterByValue = useMemo(() => {
if (!formik.values.filter_by) return null;
return (
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
null
);
}, [formik.values.filter_by]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Date filter (start_date + end_date = 1 filter)
if (appliedFilterStartDate || appliedFilterEndDate) {
if (filterParams.start_date || filterParams.end_date) {
count += 1;
}
// Customer filter
if (appliedFilterCustomer.length > 0) {
if (filterParams.customer_ids) {
count += 1;
}
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
if (appliedFilterByType) {
if (filterParams.filter_by) {
count += 1;
}
// TODO: Uncomment when BE is ready
// // Sales filter
// if (appliedFilterSales.length > 0) {
// count += 1;
// }
return count;
}, [
appliedFilterStartDate,
appliedFilterEndDate,
appliedFilterCustomer,
appliedFilterByType,
]);
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
@@ -287,21 +242,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
isSubmitted
? () => {
const params = {
customer_ids:
appliedFilterCustomer.length > 0
? appliedFilterCustomer.map((v) => String(v.value)).join(',')
: undefined,
// TODO: Uncomment when BE is ready
// sales_id:
// appliedFilterSales.length > 0
// ? appliedFilterSales.map((v) => String(v.value)).join(',')
// : undefined,
filter_by: appliedFilterByType?.value as
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: appliedFilterStartDate || undefined,
end_date: appliedFilterEndDate || undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
@@ -333,21 +280,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
CustomerPaymentReport[] | null
> => {
const params = {
customer_ids:
appliedFilterCustomer.length > 0
? appliedFilterCustomer.map((v) => String(v.value)).join(',')
: undefined,
// TODO: Uncomment when BE is ready
// sales_id:
// appliedFilterSales.length > 0
// ? appliedFilterSales.map((v) => String(v.value)).join(',')
// : undefined,
filter_by: appliedFilterByType?.value as
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: appliedFilterStartDate || undefined,
end_date: appliedFilterEndDate || undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
limit: 100,
page: 1,
};
@@ -364,13 +303,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [
appliedFilterCustomer,
// appliedFilterSales,
appliedFilterStartDate,
appliedFilterEndDate,
appliedFilterByType,
]);
}, [filterParams]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
@@ -410,21 +343,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return;
}
const customerName = filterParams.customer_ids
? customerOptions
.filter((opt) =>
filterParams.customer_ids?.split(',').includes(String(opt.value))
)
.map((opt) => opt.label)
.join(', ') || 'Semua Customer'
: 'Semua Customer';
await generateCustomerPaymentPDF({
data: allDataForExport,
params: {
customer_name:
appliedFilterCustomer.length > 0
? appliedFilterCustomer.map((c) => c.label).join(', ')
: undefined,
// TODO: Uncomment when BE is ready
// sales:
// appliedFilterSales.length > 0
// ? appliedFilterSales.map((s) => s.label).join(', ')
// : undefined,
start_date: appliedFilterStartDate || undefined,
end_date: appliedFilterEndDate || undefined,
filter_by: appliedFilterByType?.value as
customer_name: customerName,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
@@ -436,11 +370,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport]);
}, [customerPaymentExport, filterParams, customerOptions]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
@@ -451,13 +385,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
color='none'
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5',
'rounded-lg! font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
hasFilters && 'border-primary-gradient text-primary'
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={18} height={18} />
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
@@ -467,42 +401,55 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</Button>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className={cn(
'px-3 py-2.5',
'rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
align='end'
className={{
content:
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
}}
>
<Menu className='p-0 w-full'>
<MenuItem
className='text-sm p-3'
title='Excel'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPdf}
/>
</Menu>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
@@ -517,7 +464,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
setTabActions,
]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTabActions(tabId);
@@ -735,17 +681,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
}
return (
<Badge
statusIndicator={true}
size='sm'
variant='soft'
className={{
badge: `py-2.5 px-2 font-thin text-xs border border-gray-200 rounded-xl justify-start ${getPaymentStatusColor(value)}`,
status: getPaymentStatusIndicatorColor(value),
}}
>
{getPaymentStatusText(value)}
</Badge>
<StatusBadge
color={getPaymentStatusBadgeColor(value)}
text={formatTitleCase(value)}
/>
);
},
},
@@ -931,95 +870,86 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='p-4 flex flex-col gap-1.5'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={filterStartDate}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={filterEndDate}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={customerIdsValue}
onChange={(val) => {
formik.setFieldValue(
'customer_ids',
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={filterByValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('filter_by', val?.value || null);
}
}}
className={{ wrapper: 'w-full' }}
isClearable={true}
/>
</div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={filterCustomer}
onChange={(val) => {
setFilterCustomer(Array.isArray(val) ? val : val ? [val] : []);
}}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
{/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={filterSales}
onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}}
onInputChange={setSalesInputValue}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
</div> */}
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={filterByType}
onChange={(val) => {
if (val && !Array.isArray(val)) {
setFilterByType(val);
}
}}
className={{ wrapper: 'w-full' }}
/>
{/* Action Buttons */}
</div>
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={handleResetFilters}
>
Reset Filter
</Button>
<Button
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
onClick={handleApplyFilters}
disabled={hasDateError}
>
Apply Filter
</Button>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
@@ -3,8 +3,6 @@ import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import DateInput from '@/components/input/DateInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Modal, { useModal } from '@/components/Modal';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -33,7 +31,7 @@ import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
import { useReportTabStore } from '@/stores/report/report-tab.store';
import StatusBadge from '@/components/helper/StatusBadge';
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
@@ -271,13 +269,13 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
}, [debtSupplierExport]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3 '>
<div className='flex flex-row gap-3'>
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
@@ -286,42 +284,55 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className={cn(
'px-3 py-2.5',
'rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
align='end'
className={{
content:
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
}}
>
<Menu className='p-0 w-full'>
<MenuItem
className='text-sm p-3'
title='Excel'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPdf}
/>
</Menu>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
@@ -1,14 +1,19 @@
'use client';
import { useState } from 'react';
import Tabs from '@/components/Tabs';
import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab';
import { useReportTabStore } from '@/stores/report/report-tab.store';
const LogisticStockTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useReportTabStore((state) => state.tabActions);
const tabs = [
{
id: '1',
label: 'Rekapitulasi Pembelian Per Supplier',
content: <PurchasesPerSupplierTab />,
content: <PurchasesPerSupplierTab tabId='1' />,
},
// {
// id: '2',
@@ -23,8 +28,20 @@ const LogisticStockTabs = () => {
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
<section className='w-full'>
<Tabs
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
tab: 'w-fit',
content: 'p-0 m-0',
}}
sideContent={tabActions[activeTabId] || null}
/>
</section>
);
};
@@ -0,0 +1,39 @@
import * as yup from 'yup';
export type PurchasesPerSupplierFilterType = {
start_date: string | null;
end_date: string | null;
area_ids: string | null;
supplier_ids: string | null;
product_ids: string | null;
product_category_ids: string | null;
filter_by: string | null;
sort_by: string | null;
};
export const PurchasesPerSupplierFilterSchema = yup.object({
start_date: yup.string().optional().nullable(),
end_date: yup
.string()
.optional()
.nullable()
.test(
'is-greater-than-start',
'Tanggal akhir tidak boleh masa lampau',
function (value) {
const { start_date } = this.parent;
if (!start_date || !value) return true;
return new Date(value) >= new Date(start_date);
}
),
area_ids: yup.string().nullable(),
supplier_ids: yup.string().nullable(),
product_ids: yup.string().nullable(),
product_category_ids: yup.string().nullable(),
filter_by: yup.string().nullable(),
sort_by: yup.string().nullable(),
});
export type PurchasesPerSupplierFilterValues = yup.InferType<
typeof PurchasesPerSupplierFilterSchema
>;
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
import { ColumnDef } from '@tanstack/react-table';
const PurchasePerSupplierSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ColumnDef<LogisticPurchasePerSupplierReport['rows'][0]>[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default PurchasePerSupplierSkeleton;
File diff suppressed because it is too large Load Diff
@@ -1,289 +0,0 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { DailyMarketingRow } from '@/types/api/report/marketing';
import { MarketingReportApi } from '@/services/api/report/marketing-report';
interface DailyMarketingsTableProps {
dailyMarketingsReportUrl: string;
onSetPage: (page: number) => void;
pageSize: number;
onSetPageSize: (pageSize: number) => void;
searchValue: string;
onSearchChange: ChangeEventHandler<HTMLInputElement>;
onFilterByChange: (filterBy: string) => void;
onSortByChange: (sort: 'asc' | 'desc' | '') => void;
}
const DailyMarketingsTable = ({
dailyMarketingsReportUrl,
onSetPage,
pageSize,
onSetPageSize,
searchValue,
onSearchChange,
onFilterByChange,
onSortByChange,
}: DailyMarketingsTableProps) => {
const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR(
dailyMarketingsReportUrl,
MarketingReportApi.getAllDailyMarketingFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const dailyMarketingColumns: ColumnDef<DailyMarketingRow>[] = [
{
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'so_date',
header: 'Tanggal Jual',
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
footer: 'Total',
},
{
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) =>
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
},
{
accessorKey: 'aging_days',
header: 'Aging',
cell: (props) => `${props.row.original.aging_days} hari`,
},
{
accessorKey: 'warehouse',
header: 'Gudang',
cell: ({ row }) => row.original.warehouse.name,
},
{
accessorKey: 'customer',
header: 'Pelanggan',
cell: ({ row }) => row.original.customer.name,
},
{
accessorKey: 'do_number',
header: 'No. DO',
enableSorting: false,
},
{
accessorKey: 'sales_person',
header: 'Sales/Marketing',
cell: (props) => props.row.original.sales.name,
},
{
accessorKey: 'vehicle_number',
header: 'No. Polisi',
cell: (props) => (
<span className='text-nowrap'>
{formatVechicleNumber(props.row.original.vehicle_number)}
</span>
),
},
{
accessorKey: 'marketing_type',
header: 'Marketing Type',
enableSorting: false,
},
{
accessorKey: 'product',
header: 'Produk',
cell: ({ row }) => row.original.product.name,
},
{
accessorKey: 'qty',
header: 'Kuantitas',
cell: (props) => formatNumber(props.row.original.qty),
footer: () => {
const totalQty = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_qty
: 0;
return totalQty ? formatNumber(totalQty) : '-';
},
},
{
accessorKey: 'average_weight',
header: 'Bobot Rata-Rata (Kg)',
cell: (props) => formatNumber(props.row.original.average_weight_kg),
footer: () => {
const totalAverageWeightKg = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.average_weight_kg
: 0;
return totalAverageWeightKg ? formatNumber(totalAverageWeightKg) : '-';
},
},
{
accessorKey: 'total_weight',
header: 'Bobot Total (Kg)',
cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => {
const totalWeightKg = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_weight_kg
: 0;
return totalWeightKg ? formatNumber(totalWeightKg) : '-';
},
},
{
accessorKey: 'sales_price',
header: 'Harga Jual (Rp)',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
footer: () => {
const totalSalesPrice = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.average_sales_price
: 0;
return totalSalesPrice ? formatNumber(totalSalesPrice) : '-';
},
},
{
accessorKey: 'hpp_price',
header: 'HPP (Rp)',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => {
const totalHppPricePerKg = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_hpp_price_per_kg
: 0;
return totalHppPricePerKg ? formatCurrency(totalHppPricePerKg) : '-';
},
},
{
accessorKey: 'sales_amount',
header: 'Total (Rp)',
cell: (props) => formatCurrency(props.row.original.sales_amount),
footer: () => {
const totalSalesAmount = isResponseSuccess(dailyMarketings)
? dailyMarketings?.total?.total_sales_amount
: 0;
return totalSalesAmount ? formatCurrency(totalSalesAmount) : '-';
},
},
];
useEffect(() => {
if (sorting.length === 1) {
onFilterByChange(sorting[0].id);
onSortByChange(sorting[0].desc ? 'desc' : 'asc');
} else {
onFilterByChange('');
onSortByChange('');
}
}, [sorting]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(dailyMarketings)
? dailyMarketings.data.length > 0
: false
);
}
}, [dailyMarketings, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Penjualan Harian</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Penjualan Harian'
value={searchValue}
onChange={onSearchChange}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<DailyMarketingRow>
data={
isResponseSuccess(dailyMarketings) ? dailyMarketings?.data : []
}
columns={dailyMarketingColumns}
pageSize={pageSize}
onPageSizeChange={onSetPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.page
: 0
}
totalItems={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.total_results
: 0
}
onPageChange={onSetPage}
isLoading={isLoadingDailyMarketings}
sorting={sorting}
setSorting={setSorting}
renderFooter={true}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(dailyMarketings) &&
dailyMarketings?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default DailyMarketingsTable;
@@ -1,50 +0,0 @@
'use client';
import { JSX, useState } from 'react';
import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent';
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
type MarketingReportTabType =
| 'daily'
| 'transaction'
| 'hpp-comparison'
| 'daily-hpp';
const marketingReportTabs: {
id: MarketingReportTabType;
label: string;
content: JSX.Element;
}[] = [
{
id: 'daily',
label: 'Penjualan Harian',
content: <DailyMarketingReportContent />,
},
{
id: 'daily-hpp',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab />,
},
];
const MarketingReportContent = () => {
const [activeTab, setActiveTab] = useState<string>('daily');
return (
<section className='w-full max-w-full pb-16'>
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
tabs={marketingReportTabs}
variant='lifted'
className={{
content: '-m-px pl-px',
}}
/>
</section>
);
};
export default MarketingReportContent;
@@ -0,0 +1,45 @@
'use client';
import { useState } from 'react';
import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab';
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
import { useReportTabStore } from '@/stores/report/report-tab.store';
const MarketingReportContent = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useReportTabStore((state) => state.tabActions);
const tabs = [
{
id: '1',
label: 'Penjualan Harian',
content: <DailyMarketingReportContent tabId={'1'} />,
},
{
id: '2',
label: 'HPP Harian Kandang',
content: <HppPerKandangTab tabId={'2'} />,
},
];
return (
<section className='w-full'>
<Tabs
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
tab: 'w-fit',
content: 'p-0 m-0',
}}
sideContent={tabActions[activeTabId] || null}
/>
</section>
);
};
export default MarketingReportContent;
@@ -0,0 +1,118 @@
'use client';
import ExcelJS from 'exceljs';
import {
formatCurrency,
formatNumber,
formatDate,
formatVechicleNumber,
} from '@/lib/helper';
import { DailyMarketingRow, SalesSummary } from '@/types/api/report/marketing';
interface DailyMarketingExportExcelParams {
data: DailyMarketingRow[];
summaryTotal?: SalesSummary;
period?: string;
}
export const generateDailyMarketingExcel = async (
params: DailyMarketingExportExcelParams
): Promise<void> => {
if (!params.data || params.data.length === 0) {
return;
}
const workbook = new ExcelJS.Workbook();
// ===== DAILY MARKETING WORKSHEET =====
const columns = [
{ header: 'No', key: 'no', width: 5 },
{ header: 'Tanggal Jual', key: 'soDate', width: 15 },
{ header: 'Tanggal Realisasi', key: 'realizationDate', width: 18 },
{ header: 'Aging', key: 'aging', width: 10 },
{ header: 'Gudang', key: 'warehouse', width: 25 },
{ header: 'Pelanggan', key: 'customer', width: 25 },
{ header: 'No. DO', key: 'doNumber', width: 15 },
{ header: 'Sales/Marketing', key: 'sales', width: 20 },
{ header: 'No. Polisi', key: 'vehicleNumber', width: 15 },
{ header: 'Marketing Type', key: 'marketingType', width: 15 },
{ header: 'Produk', key: 'product', width: 20 },
{ header: 'Kuantitas', key: 'qty', width: 12 },
{ header: 'Bobot Rata-Rata (Kg)', key: 'averageWeight', width: 20 },
{ header: 'Bobot Total (Kg)', key: 'totalWeight', width: 18 },
{ header: 'Harga Jual (Rp)', key: 'salesPrice', width: 18 },
{ header: 'HPP (Rp)', key: 'hppPrice', width: 15 },
{ header: 'Total (Rp)', key: 'salesAmount', width: 20 },
];
const worksheet = workbook.addWorksheet('Laporan Marketing Harian');
worksheet.columns = columns;
// Add data rows
params.data.forEach((item: DailyMarketingRow, index: number) => {
worksheet.addRow({
no: index + 1,
soDate: formatDate(item.so_date, 'DD-MMM-YYYY'),
realizationDate: formatDate(item.realization_date, 'DD-MMM-YYYY'),
aging: `${item.aging_days} hari`,
warehouse: item.warehouse?.name || '',
customer: item.customer?.name || '',
doNumber: item.do_number || '',
sales: item.sales?.name || '',
vehicleNumber: formatVechicleNumber(item.vehicle_number),
marketingType: item.marketing_type || '',
product: item.product?.name || '',
qty: formatNumber(item.qty || 0),
averageWeight: formatNumber(item.average_weight_kg || 0),
totalWeight: formatNumber(item.total_weight_kg || 0),
salesPrice: formatCurrency(item.sales_price_per_kg || 0),
hppPrice: formatCurrency(item.hpp_price_per_kg || 0),
salesAmount: formatCurrency(item.sales_amount || 0),
});
});
// Add TOTAL row if summary data is available
if (params.summaryTotal) {
worksheet.addRow({
no: 'TOTAL',
soDate: 'ALL',
realizationDate: '-',
aging: '-',
warehouse: '-',
customer: '-',
doNumber: '-',
sales: '-',
vehicleNumber: '-',
marketingType: '-',
product: '-',
qty: formatNumber(params.summaryTotal.total_qty || 0),
averageWeight: formatNumber(params.summaryTotal.average_weight_kg || 0),
totalWeight: formatNumber(params.summaryTotal.total_weight_kg || 0),
salesPrice: formatNumber(params.summaryTotal.average_sales_price || 0),
hppPrice: formatCurrency(params.summaryTotal.total_hpp_price_per_kg || 0),
salesAmount: formatCurrency(params.summaryTotal.total_sales_amount || 0),
});
}
worksheet.columns.forEach((column) => {
if (column.width && column.width < 10) {
column.width = 10;
}
});
const currentDate = new Date().toISOString().split('T')[0];
const filename = params.period
? `laporan-marketing-harian-${params.period}.xlsx`
: `laporan-marketing-harian-${currentDate}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
@@ -0,0 +1,42 @@
import * as yup from 'yup';
export type DailyMarketingReportFilterType = {
search: string | null;
area_id: string | null;
location_id: string | null;
warehouse_id: string | null;
customer_id: string | null;
start_date: string | null;
end_date: string | null;
marketing_type: string | null;
filter_by: string | null;
sort_by: string | null;
};
export const DailyMarketingReportFilterSchema = yup.object({
search: yup.string().nullable(),
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
warehouse_id: yup.string().nullable(),
customer_id: yup.string().nullable(),
start_date: yup.string().nullable(),
end_date: yup
.string()
.nullable()
.test(
'is-greater-than-start',
'Tanggal akhir tidak boleh masa lampau',
function (value) {
const { start_date } = this.parent;
if (!start_date || !value) return true;
return new Date(value) >= new Date(start_date);
}
),
marketing_type: yup.string().nullable(),
filter_by: yup.string().nullable(),
sort_by: yup.string().nullable(),
});
export type DailyMarketingReportFilterValues = yup.InferType<
typeof DailyMarketingReportFilterSchema
>;
@@ -0,0 +1,40 @@
import * as yup from 'yup';
export type HppPerKandangFilterType = {
area_id: string | null;
location_id: string | null;
kandang_id: string | null;
weight_min: string | null;
weight_max: string | null;
period: string | null;
sort_by: string | null;
show_unrecorded: boolean | null;
};
export const HppPerKandangFilterSchema = yup.object({
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
kandang_id: yup.string().nullable(),
weight_min: yup.string().nullable(),
weight_max: yup
.string()
.nullable()
.test(
'is-greater-than-min',
'Rentang bobot max tidak boleh lebih kecil dari min',
function (value) {
const { weight_min } = this.parent;
if (!weight_min || !value) return true;
const weightMinNum = parseFloat(weight_min) || 0;
const weightMaxNum = parseFloat(value) || 0;
return weightMaxNum >= weightMinNum;
}
),
period: yup.string().required('Periode wajib diisi'),
sort_by: yup.string().nullable(),
show_unrecorded: yup.boolean().nullable(),
});
export type HppPerKandangFilterValues = yup.InferType<
typeof HppPerKandangFilterSchema
>;
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { DailyMarketingRow } from '@/types/api/report/marketing.d';
import { ColumnDef } from '@tanstack/react-table';
const DailyMarketingReportSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ColumnDef<DailyMarketingRow>[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default DailyMarketingReportSkeleton;
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
import { ColumnDef } from '@tanstack/react-table';
const HppPerKandangSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ColumnDef<HppPerKandangReport['rows'][0]>[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default HppPerKandangSkeleton;
@@ -1,472 +0,0 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingReportPDF';
import { Area } from '@/types/api/master-data/area';
import {
AreaApi,
CustomerApi,
LocationApi,
WarehouseApi,
} from '@/services/api/master-data';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Customer } from '@/types/api/master-data/customer';
import { MarketingReportApi } from '@/services/api/report/marketing-report';
import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import {
DailyMarketingReport,
DailyMarketingReportResponse,
} from '@/types/api/report/marketing';
import { isResponseError } from '@/lib/api-helper';
const DailyMarketingReportContent = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter({
initial: {
search: '',
area_id: '',
location_id: '',
warehouse_id: '',
customer_id: '',
start_date: '',
end_date: '',
marketing_type: '',
filter_by: '',
sort_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
area_id: 'area_id',
location_id: 'location_id',
warehouse_id: 'warehouse_id',
customer_id: 'customer_id',
start_date: 'start_date',
end_date: 'end_date',
marketing_type: 'marketing_type',
filter_by: 'filter_by',
sort_by: 'sort_by',
},
});
const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`;
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedArea(val as OptionType);
updateFilter('area_id', val ? ((val as OptionType).value as string) : '');
};
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'location_id',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
null
);
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedWarehouse(val as OptionType);
updateFilter(
'warehouse_id',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
null
);
const {
setInputValue: setCustomerInputValue,
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedCustomer(val as OptionType);
updateFilter(
'customer_id',
val ? ((val as OptionType).value as string) : ''
);
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('start_date', e.target.value ? e.target.value : '');
};
const endDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('end_date', e.target.value ? e.target.value : '');
};
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
useState<OptionType | null>(null);
const marketingDateFilterTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingDateFilterType(val as OptionType);
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
};
const [selectedMarketingType, setSelectedMarketingType] =
useState<OptionType | null>(null);
const marketingTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingType(val as OptionType);
updateFilter(
'marketing_type',
val ? ((val as OptionType).value as string) : ''
);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const filterByChangeHandler = (filterBy: string) => {
updateFilter('filter_by', filterBy);
};
const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => {
updateFilter('sort_by', sort);
};
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await MarketingReportApi.exportDailyMarketingToExcel(
getTableFilterQueryString()
);
setIsLoadingExportingToExcel(false);
};
const exportToPdfHandler = async () => {
setIsLoadingExportingToPdf(true);
const params = new URLSearchParams(getTableFilterQueryString());
params.set('limit', '9999999');
const queryString = `?${params.toString()}`;
try {
const dailyMarketingsReport =
await httpClient<DailyMarketingReportResponse>(
`${MarketingReportApi.basePath}${queryString}`
);
if (isResponseError(dailyMarketingsReport)) {
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
return;
}
const openPdf = async () => {
const dailyMarketingReportPdfBlob = await pdf(
<DailyMarketingReportPDF
data={dailyMarketingsReport.data}
total={dailyMarketingsReport.total}
/>
).toBlob();
const dailyMarketingReportPdfUrl = URL.createObjectURL(
dailyMarketingReportPdfBlob
);
window.open(dailyMarketingReportPdfUrl, '_blank');
};
const downloadPdf = async () => {
const blob = await pdf(
<DailyMarketingReportPDF
data={dailyMarketingsReport.data}
total={dailyMarketingsReport.total}
/>
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'laporan-penjualan-harian.pdf';
link.click();
URL.revokeObjectURL(url);
};
await openPdf();
} catch (error) {
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
}
setIsLoadingExportingToPdf(false);
};
const handleReset = () => {
setSelectedArea(null);
setSelectedLocation(null);
setSelectedWarehouse(null);
setSelectedCustomer(null);
setSelectedMarketingType(null);
resetFilter();
};
useEffect(() => {
if (
tableFilterState.filter_by === 'realization_date' ||
tableFilterState.filter_by === 'so_date'
) {
setSelectedMarketingDateFilterType({
label:
tableFilterState.filter_by === 'realization_date'
? 'Tanggal Realisasi'
: 'Tanggal SO',
value: tableFilterState.filter_by,
});
} else {
setSelectedMarketingDateFilterType(null);
}
}, [tableFilterState.filter_by]);
return (
<div className='w-full border border-gray-200 p-4'>
<div>
<h2 className='text-xl font-bold text-center'>Penjualan Harian</h2>
</div>
{/* Filters */}
<div className='flex flex-col gap-4 mb-6'>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
isLoading={isLoadingAreaOptions}
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={selectedCustomer}
onChange={customerChangeHandler}
onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<DateInput
name='startDate'
label='Tanggal Awal'
placeholder='Tanggal Realisasi'
value={tableFilterState.start_date}
onChange={startDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<DateInput
name='endDate'
label='Tanggal Akhir'
placeholder='Tanggal Realisasi'
value={tableFilterState.end_date}
onChange={endDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
</div>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Filter Tanggal'
placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
value={selectedMarketingDateFilterType}
onChange={marketingDateFilterTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Tipe Marketing'
placeholder='Pilih Tipe Marketing'
options={MARKETING_TYPE_OPTIONS}
value={selectedMarketingType}
onChange={marketingTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<div className='col-span-12 sm:col-span-6 lg:col-span-8 flex flex-wrap sm:justify-end items-end gap-2'>
<Button
color='primary'
// onClick={handleSearch}
className='flex-1 sm:flex-none'
>
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
Cari
</Button>
<Button
color='warning'
onClick={handleReset}
className='flex-1 sm:flex-none'
>
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
Reset
</Button>
<Dropdown
align='end'
direction='bottom'
trigger={
<Button>
Export{' '}
<Icon
icon='heroicons-outline:download'
width={20}
height={20}
/>
</Button>
}
>
<Menu>
<MenuItem
title='Export to Excel'
icon='icon-park-outline:excel'
isLoading={isLoadingExportingToExcel}
onClick={exportToExcelHandler}
className='text-nowrap'
/>
<MenuItem
title='Export to PDF'
icon='icon-park-outline:file-pdf-one'
onClick={exportToPdfHandler}
className='text-nowrap'
/>
</Menu>
</Dropdown>
</div>
</div>
</div>
<DailyMarketingsTable
dailyMarketingsReportUrl={dailyMarketingsReportUrl}
onSetPage={setPage}
pageSize={tableFilterState.pageSize}
onSetPageSize={setPageSize}
searchValue={tableFilterState.search}
onSearchChange={searchChangeHandler}
onFilterByChange={filterByChangeHandler}
onSortByChange={sortByChangeHandler}
/>
</div>
);
};
export default DailyMarketingReportContent;
@@ -0,0 +1,939 @@
import { useState, useMemo, useCallback } from 'react';
import useSWR from 'swr';
import { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import { WarehouseApi } from '@/services/api/master-data';
import { CustomerApi } from '@/services/api/master-data';
import { MarketingReportApi } from '@/services/api/report/marketing-report';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import {
formatCurrency,
formatNumber,
formatDate,
formatVechicleNumber,
formatTitleCase,
} from '@/lib/helper';
import {
DailyMarketingRow,
DailyMarketingReportResponse,
} from '@/types/api/report/marketing';
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import {
DailyMarketingReportFilterSchema,
DailyMarketingReportFilterType,
} from '@/components/pages/report/marketing/filter/DailyMarketingFilter';
import SelectInput from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
import { cn } from '@/lib/helper';
import { useReportTabStore } from '@/stores/report/report-tab.store';
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
import { useEffect as useEffectHook } from 'react';
import { httpClient } from '@/services/http/client';
import { isResponseError } from '@/lib/api-helper';
import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import Badge from '@/components/Badge';
interface DailyMarketingTabProps {
tabId: string;
}
interface FilterParams {
area_id?: string;
location_id?: string;
warehouse_id?: string;
customer_id?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
marketing_type?: string;
sort_by?: string;
}
const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== SEARCH STATE =====
const [searchValue, setSearchValue] = useState<string>('');
// ===== FILTER STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
const filterModal = useModal();
// ===== OPTIONS =====
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: warehouseOptions, isLoadingOptions: isLoadingWarehouses } =
useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
// ===== FORMIK SETUP =====
const formik = useFormik<DailyMarketingReportFilterType>({
initialValues: {
search: null,
area_id: null,
location_id: null,
warehouse_id: null,
customer_id: null,
start_date: null,
end_date: null,
filter_by: null,
marketing_type: null,
sort_by: null,
},
validationSchema: DailyMarketingReportFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
area_id: values.area_id || undefined,
location_id: values.location_id || undefined,
warehouse_id: values.warehouse_id || undefined,
customer_id: values.customer_id || undefined,
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
filter_by: values.filter_by || undefined,
marketing_type: values.marketing_type || undefined,
sort_by: values.sort_by || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
},
});
// ===== SEARCH CHANGE HANDLER =====
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
},
[]
);
// ===== DERIVED VALUES =====
const areaValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const warehouseValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
const customerValue = useMemo(() => {
if (!formik.values.customer_id) return null;
return (
customerOptions.find(
(opt) => String(opt.value) === formik.values.customer_id
) || null
);
}, [formik.values.customer_id, customerOptions]);
const filterByValue = useMemo(() => {
if (!formik.values.filter_by) return null;
return (
MARKETING_DATE_FILTER_TYPE_OPTIONS.find(
(opt) => opt.value === formik.values.filter_by
) || null
);
}, [formik.values.filter_by]);
const marketingTypeValue = useMemo(() => {
if (!formik.values.marketing_type) return null;
return (
MARKETING_TYPE_OPTIONS.find(
(opt) => opt.value === formik.values.marketing_type
) || null
);
}, [formik.values.marketing_type]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.area_id) {
count += 1;
}
if (filterParams.location_id) {
count += 1;
}
if (filterParams.warehouse_id) {
count += 1;
}
if (filterParams.customer_id) {
count += 1;
}
if (filterParams.start_date || filterParams.end_date) {
count += 1;
}
if (filterParams.filter_by) {
count += 1;
}
if (filterParams.marketing_type) {
count += 1;
}
if (filterParams.sort_by) {
count += 1;
}
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: dailyMarketings, isLoading } = useSWR(
isSubmitted
? () => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date)
params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
return ['daily-marketing-report', params.toString()];
}
: null,
([, params]) =>
MarketingReportApi.getAllDailyMarketingFetcher(
`${MarketingReportApi.basePath}?${params}`
)
);
const data: DailyMarketingRow[] = useMemo(
() =>
isResponseSuccess(dailyMarketings)
? (dailyMarketings?.data as DailyMarketingRow[]) || []
: [],
[dailyMarketings]
);
const summaryTotal = useMemo(
() =>
isResponseSuccess(dailyMarketings) && dailyMarketings?.total
? dailyMarketings.total
: undefined,
[dailyMarketings]
);
// ===== EXPORT DATA FETCHER =====
const dailyMarketingsExport = useCallback(async (): Promise<
DailyMarketingRow[] | null
> => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
params.set('limit', '9999999');
const queryString = `?${params.toString()}`;
try {
const response = await httpClient<DailyMarketingReportResponse>(
`${MarketingReportApi.basePath}${queryString}`
);
if (isResponseError(response)) {
return null;
}
return response.data || [];
} catch {
return null;
}
}, [filterParams, searchValue]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await dailyMarketingsExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const period =
filterParams.start_date && filterParams.end_date
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
: undefined;
await generateDailyMarketingExcel({
data: allDataForExport,
summaryTotal: summaryTotal,
period: period,
});
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, dailyMarketingsExport, summaryTotal]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await dailyMarketingsExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
const dailyMarketingReportPdfBlob = await pdf(
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
).toBlob();
const dailyMarketingReportPdfUrl = URL.createObjectURL(
dailyMarketingReportPdfBlob
);
window.open(dailyMarketingReportPdfUrl, '_blank');
toast.success('PDF berhasil dibuat.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [dailyMarketingsExport, summaryTotal]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffectHook(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3 items-center'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={searchValue}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-48 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input: 'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<Button
variant='outline'
color='none'
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<Badge
className={{
badge:
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
}}
>
{activeFiltersCount}
</Badge>
)}
</Button>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPDF}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
searchValue,
hasFilters,
activeFiltersCount,
isAnyExportLoading,
filterModal.open,
setTabActions,
]);
useEffectHook(() => {
return () => {
clearTabActions(tabId);
};
}, [tabId, clearTabActions]);
const getTableColumns = (): ColumnDef<DailyMarketingRow>[] => {
const tableColumns: ColumnDef<DailyMarketingRow>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
},
{
id: 'so_date',
header: 'Tanggal Jual',
accessorKey: 'so_date',
cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'),
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
},
{
id: 'realization_date',
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
cell: (props) =>
formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'),
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'aging_days',
header: 'Aging',
accessorKey: 'aging_days',
cell: (props) => `${props.row.original.aging_days} hari`,
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'warehouse',
header: 'Gudang',
accessorKey: 'warehouse',
cell: ({ row }) => row.original.warehouse.name,
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'customer',
header: 'Pelanggan',
accessorKey: 'customer',
cell: ({ row }) => row.original.customer.name,
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'do_number',
header: 'No. DO',
accessorKey: 'do_number',
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'sales_person',
header: 'Sales/Marketing',
accessorKey: 'sales',
cell: (props) => props.row.original.sales.name,
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'vehicle_number',
header: 'No. Polisi',
accessorKey: 'vehicle_number',
cell: (props) => (
<span className='text-nowrap'>
{formatVechicleNumber(props.row.original.vehicle_number)}
</span>
),
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'marketing_type',
header: 'Marketing Type',
accessorKey: 'marketing_type',
cell: (props) => (
<span className='text-nowrap'>
{formatTitleCase(props.row.original.marketing_type || '-')}
</span>
),
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'product',
header: 'Produk',
accessorKey: 'product',
cell: ({ row }) => row.original.product.name,
footer: () => <div className='font-semibold text-gray-900'>-</div>,
},
{
id: 'qty',
header: 'Kuantitas',
accessorKey: 'qty',
cell: (props) => formatNumber(props.row.original.qty),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_qty
? formatNumber(summaryTotal.total_qty)
: '-'}
</div>
),
},
{
id: 'average_weight',
header: 'Bobot Rata-Rata (Kg)',
accessorKey: 'average_weight_kg',
cell: (props) => formatNumber(props.row.original.average_weight_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summaryTotal?.average_weight_kg
? formatNumber(summaryTotal.average_weight_kg)
: '-'}
</div>
),
},
{
id: 'total_weight',
header: 'Bobot Total (Kg)',
accessorKey: 'total_weight_kg',
cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_weight_kg
? formatNumber(summaryTotal.total_weight_kg)
: '-'}
</div>
),
},
{
id: 'sales_price',
header: 'Harga Jual (Rp)',
accessorKey: 'sales_price_per_kg',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summaryTotal?.average_sales_price
? formatNumber(summaryTotal.average_sales_price)
: '-'}
</div>
),
},
{
id: 'hpp_price',
header: 'HPP (Rp)',
accessorKey: 'hpp_price_per_kg',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_hpp_price_per_kg
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
: '-'}
</div>
),
},
{
id: 'sales_amount',
header: 'Total (Rp)',
accessorKey: 'sales_amount',
cell: (props) => formatCurrency(props.row.original.sales_amount),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_sales_amount
? formatCurrency(summaryTotal.total_sales_amount)
: '-'}
</div>
),
},
];
return tableColumns;
};
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<DailyMarketingReportSkeleton
columns={getTableColumns()}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
<DailyMarketingReportSkeleton
columns={getTableColumns()}
icon={
<Icon
icon='heroicons:document-report'
className='text-white'
width={20}
height={20}
/>
}
title='Memuat Data Penjualan Harian'
subtitle='Silakan tunggu sebentar...'
/>
) : data.length === 0 ? (
<DailyMarketingReportSkeleton
columns={getTableColumns()}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
<Table
data={data}
columns={getTableColumns()}
renderFooter={data.length > 0}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-3'>
{/* Area Filter */}
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
isLoading={isLoadingAreas}
value={areaValue}
onChange={(val) => {
formik.setFieldValue(
'area_id',
val && !Array.isArray(val) ? String(val.value) : null
);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
{/* Location Filter */}
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
isLoading={isLoadingLocations}
value={locationValue}
onChange={(val) => {
formik.setFieldValue(
'location_id',
val && !Array.isArray(val) ? String(val.value) : null
);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
{/* Warehouse Filter */}
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouses}
value={warehouseValue}
onChange={(val) => {
formik.setFieldValue(
'warehouse_id',
val && !Array.isArray(val) ? String(val.value) : null
);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
{/* Customer Filter */}
<SelectInput
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
isLoading={isLoadingCustomers}
value={customerValue}
onChange={(val) => {
formik.setFieldValue(
'customer_id',
val && !Array.isArray(val) ? String(val.value) : null
);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
{/* Date Range Filter */}
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Rentang Tanggal
</label>
<div className='flex flex-col gap-2'>
<DateInput
name='start_date'
label='Tanggal Awal'
placeholder='Pilih Tanggal Awal'
value={formik.values.start_date || ''}
onChange={(e) => {
formik.setFieldValue('start_date', e.target.value || null);
}}
className={{ wrapper: 'w-full' }}
isError={
!!formik.errors.start_date && formik.touched.start_date
}
/>
{formik.errors.start_date && formik.touched.start_date && (
<div className='text-error text-xs mt-1'>
{formik.errors.start_date}
</div>
)}
<DateInput
name='end_date'
label='Tanggal Akhir'
placeholder='Pilih Tanggal Akhir'
value={formik.values.end_date || ''}
onChange={(e) => {
formik.setFieldValue('end_date', e.target.value || null);
}}
className={{ wrapper: 'w-full' }}
isError={!!formik.errors.end_date && formik.touched.end_date}
/>
{formik.errors.end_date && formik.touched.end_date && (
<div className='text-error text-xs mt-1'>
{formik.errors.end_date}
</div>
)}
</div>
</div>
{/* Filter By Date Type */}
<SelectInput
label='Filter Tanggal'
placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
value={filterByValue}
onChange={(val) => {
formik.setFieldValue(
'filter_by',
val && !Array.isArray(val) ? (val.value as string) : null
);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
{/* Marketing Type Filter */}
<SelectInput
label='Tipe Marketing'
placeholder='Pilih Tipe Marketing'
options={MARKETING_TYPE_OPTIONS}
value={marketingTypeValue}
onChange={(val) => {
formik.setFieldValue(
'marketing_type',
val && !Array.isArray(val) ? (val.value as string) : null
);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default DailyMarketingTab;
File diff suppressed because it is too large Load Diff
@@ -1,527 +0,0 @@
'use client';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Card from '@/components/Card';
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable';
import { BaseKandang } from '@/types/api/master-data/kandang';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import {
BaseProjectFlockKandang,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination';
import { ProductionResultReportApi } from '@/services/api/report/production-result';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import { ProductionResult } from '@/types/api/report/production-result';
import ProductionResultReportPDF from './ProductionResultReportPDF';
import { pdf } from '@react-pdf/renderer';
const ProductionResultContent = () => {
const [projectFlockKandangs, setProjectFlockKandangs] = useState<
ProjectFlockKandang[] | null
>(null);
const [projectFlockKandangMetadata, setProjectFlockKandangMetadata] =
useState<
| {
page: number;
limit: number;
total_pages: number;
total_results: number;
}
| undefined
>(undefined);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null);
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
useState<OptionType | null>(null);
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedArea(val as OptionType);
setSelectedLocation(null);
setSelectedProjectFlock(null);
setSelectedProjectFlockKandang(null);
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
});
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
setSelectedProjectFlock(null);
setSelectedProjectFlockKandang(null);
};
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
area_id: selectedArea
? ((selectedArea as OptionType).value as string)
: '',
location_id: selectedLocation
? ((selectedLocation as OptionType).value as string)
: '',
category: 'LAYING',
}
);
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedProjectFlock(val as OptionType);
setSelectedProjectFlockKandang(null);
};
const {
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>(
ProjectFlockKandangApi.basePath,
'id',
'kandang.name',
'search',
{
area_id: selectedArea
? ((selectedArea as OptionType).value as string)
: '',
location_id: selectedLocation
? ((selectedLocation as OptionType).value as string)
: '',
project_flock_id: selectedProjectFlock
? ((selectedProjectFlock as OptionType).value as string)
: '',
}
);
const projectFlockKandangChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedProjectFlockKandang(val as OptionType);
};
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await ProductionResultReportApi.exportProductionResultToExcel(
projectFlockKandangs
);
setIsLoadingExportingToExcel(false);
};
const exportToPdfHandler = async () => {
setIsLoadingExportingToPdf(true);
try {
let projectFlockKandangsData: BaseProjectFlockKandang[] = [];
if (selectedProjectFlockKandang) {
const projectFlockKandangResponse =
await ProjectFlockKandangApi.getSingle(
selectedProjectFlockKandang?.value as number
);
projectFlockKandangsData = isResponseSuccess(
projectFlockKandangResponse
)
? [projectFlockKandangResponse.data]
: [];
} else {
const projectFlockKandangsResponse =
await ProjectFlockKandangApi.getAll({
area_id: selectedArea?.value,
project_flock_id: selectedProjectFlock?.value,
});
projectFlockKandangsData = isResponseSuccess(
projectFlockKandangsResponse
)
? projectFlockKandangsResponse.data
: [];
}
const mappedProductionResults: {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
}[] = await Promise.all(
projectFlockKandangsData.map(async (projectFlockKandang) => {
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
const getProductionResultRes = await httpClient<
BaseApiResponse<ProductionResult[]>
>(getProductionResultPath);
return {
projectFlockKandang,
productionResult: isResponseSuccess(getProductionResultRes)
? getProductionResultRes.data
: null,
};
})
);
if (mappedProductionResults.length === 0) {
toast.error('Tidak ada data untuk diexport.');
setIsLoadingExportingToPdf(false);
return;
}
const openPdf = async () => {
const productionResultPdfBlob = await pdf(
<ProductionResultReportPDF
mappedProductionResults={mappedProductionResults}
/>
).toBlob();
const productionResultReportPdfUrl = URL.createObjectURL(
productionResultPdfBlob
);
window.open(productionResultReportPdfUrl, '_blank');
};
await openPdf();
} catch (error) {
console.error(error);
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
}
setIsLoadingExportingToPdf(false);
};
const searchHandler = async () => {
setProjectFlockKandangs(null);
setIsLoadingSearch(true);
try {
if (selectedProjectFlockKandang) {
const projectFlockKandangResponse =
await ProjectFlockKandangApi.getSingle(
selectedProjectFlockKandang?.value as number
);
if (
!projectFlockKandangResponse ||
isResponseError(projectFlockKandangResponse)
) {
throw new Error();
}
setProjectFlockKandangs([projectFlockKandangResponse.data]);
setProjectFlockKandangMetadata({
page: 1,
limit: 10,
total_pages: 1,
total_results: 1,
});
setIsLoadingSearch(false);
return;
}
const projectFlockKandangsResponse = await ProjectFlockKandangApi.getAll({
area_id: selectedArea?.value,
project_flock_id: selectedProjectFlock?.value,
});
if (
!projectFlockKandangsResponse ||
isResponseError(projectFlockKandangsResponse)
) {
throw new Error();
}
setProjectFlockKandangs(projectFlockKandangsResponse.data);
setProjectFlockKandangMetadata(projectFlockKandangsResponse.meta);
setIsLoadingSearch(false);
} catch (error) {
toast.error('Gagal mencari data! Coba lagi.');
setProjectFlockKandangs(null);
setProjectFlockKandangMetadata(undefined);
setIsLoadingSearch(false);
}
};
const resetHandler = () => {
setProjectFlockKandangs(null);
setSelectedArea(null);
setSelectedLocation(null);
setSelectedProjectFlock(null);
setSelectedProjectFlockKandang(null);
// resetFilter();
};
return (
<div className='w-full p-4'>
<Card
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div>
<h2 className='text-xl font-bold text-center'>
Laporan Hasil Produksi
</h2>
</div>
{/* Filters */}
<div className='flex flex-col gap-4 mb-6 mt-4'>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
isLoading={isLoadingAreaOptions}
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Lokasi'
placeholder={
selectedArea ? 'Pilih Lokasi' : 'Pilih Area terlebih dahulu'
}
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!selectedArea}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Project Flock'
placeholder={
selectedArea && selectedLocation
? 'Pilih Project Flock'
: 'Pilih Area dan Lokasi terlebih dahulu'
}
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
value={selectedProjectFlock}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!selectedArea || !selectedLocation}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Project Flock Kandang'
placeholder={
selectedProjectFlock
? 'Pilih Project Flock Kandang'
: 'Pilih Project Flock terlebih dahulu'
}
options={projectFlockKandangOptions}
isLoading={isLoadingProjectFlockKandangOptions}
value={selectedProjectFlockKandang}
onChange={projectFlockKandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!selectedProjectFlock}
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
</div>
<div className='grid grid-cols-12 gap-4'>
<div className='col-span-12 flex flex-wrap sm:justify-end items-end gap-2'>
<Button
onClick={searchHandler}
isLoading={isLoadingSearch}
disabled={
!selectedArea || !selectedLocation || !selectedProjectFlock
}
className='flex-1 sm:flex-none'
>
<Icon icon='heroicons-outline:search' width={20} height={20} />
Cari
</Button>
<Button
color='warning'
onClick={resetHandler}
className='flex-1 sm:flex-none'
>
<Icon icon='heroicons-outline:refresh' width={20} height={20} />
Reset
</Button>
<Dropdown
align='end'
direction='bottom'
trigger={
<Button
disabled={
!selectedArea ||
!selectedLocation ||
!selectedProjectFlock
}
>
Export{' '}
<Icon
icon='heroicons-outline:download'
width={20}
height={20}
/>
</Button>
}
>
<Menu>
<MenuItem
title='Export to Excel'
icon='icon-park-outline:excel'
isLoading={isLoadingExportingToExcel}
onClick={exportToExcelHandler}
className='text-nowrap'
/>
<MenuItem
title='Export to PDF'
icon='icon-park-outline:file-pdf-one'
isLoading={isLoadingExportingToPdf}
onClick={exportToPdfHandler}
className='text-nowrap'
/>
</Menu>
</Dropdown>
</div>
</div>
</div>
</Card>
<div className='mt-4'>
{isLoadingSearch && (
<span className='loading loading-dots loading-xl block mx-auto text-gray-400' />
)}
{!isLoadingSearch && !projectFlockKandangs && (
<p className='text-center text-gray-500'>
Silakan pilih filter dan klik tombol Cari untuk menampilkan data.
</p>
)}
{!isLoadingSearch && projectFlockKandangs?.length === 0 && (
<p className='text-center text-gray-500'>
Tidak ada data kandang project flock yang dapat ditampilkan.
</p>
)}
{!isLoadingSearch && projectFlockKandangs && (
<Card
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
{projectFlockKandangs.map((projectFlockKandang) => (
<ProductionResultProjectFlockKandangTable
key={projectFlockKandang.id}
projectFlockKandangId={projectFlockKandang.id}
kandangName={projectFlockKandang.kandang.name}
/>
))}
<div className='max-w-sm ml-auto mt-5'>
<Pagination
totalItems={projectFlockKandangMetadata?.total_results || 0}
itemsPerPage={projectFlockKandangMetadata?.limit || 0}
currentPage={projectFlockKandangMetadata?.page || 0}
onPrevPage={() =>
setPage((currPage) =>
currPage > 1 ? currPage - 1 : currPage
)
}
onNextPage={() =>
setPage((currPage) =>
projectFlockKandangMetadata?.total_pages &&
currPage < projectFlockKandangMetadata.total_pages
? currPage + 1
: currPage
)
}
onPageChange={(pageNumber) => setPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
</Card>
)}
</div>
</div>
);
};
export default ProductionResultContent;
@@ -0,0 +1,39 @@
'use client';
import { useState } from 'react';
import Tabs from '@/components/Tabs';
import ProductionResultTab from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab';
import { useReportTabStore } from '@/stores/report/report-tab.store';
const ProductionResultTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const tabActions = useReportTabStore((state) => state.tabActions);
const tabs = [
{
id: '1',
label: 'Hasil Produksi',
content: <ProductionResultTab tabId={'1'} />,
},
];
return (
<section className='w-full'>
<Tabs
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
tab: 'w-fit',
content: 'p-0 m-0',
}}
sideContent={tabActions[activeTabId] || null}
/>
</section>
);
};
export default ProductionResultTabs;
@@ -66,7 +66,7 @@ const getBwTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'woa',
@@ -114,7 +114,7 @@ const getDepTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'dep_kum',
@@ -141,7 +141,7 @@ const getButiranTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'butiran_utuh',
@@ -196,7 +196,7 @@ const getKgTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'kg_utuh',
@@ -251,7 +251,7 @@ const getPersenTableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'persen_utuh',
@@ -292,7 +292,7 @@ const getProduksi1TableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'hd',
@@ -361,7 +361,7 @@ const getProduksi2TableColumns = (): PdfColumn<ProductionResult>[] => [
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ row, index }) => index + 1,
cell: ({ index }) => index + 1,
},
{
key: 'fcr',
@@ -0,0 +1,135 @@
'use client';
import ExcelJS from 'exceljs';
import { formatNumber } from '@/lib/helper';
import { ProductionResult } from '@/types/api/report/production-result';
interface ProductionResultExportExcelParams {
data: ProductionResult[];
period?: string;
}
export const generateProductionResultExcel = async (
params: ProductionResultExportExcelParams
): Promise<void> => {
if (!params.data || params.data.length === 0) {
return;
}
const workbook = new ExcelJS.Workbook();
// ===== PRODUCTION RESULT WORKSHEET =====
const columns = [
{ header: 'No', key: 'no', width: 6 },
{ header: 'Project Flock', key: 'projectFlockName', width: 25 },
{
header: 'Category',
key: 'projectFlockCategory',
width: 18,
},
{ header: 'Kandang', key: 'kandangName', width: 18 },
{ header: 'Week of Age (WOA)', key: 'woa', width: 20 },
{ header: 'Body Weight (BW)', key: 'bw', width: 18 },
{ header: 'Body Weight (Std BW)', key: 'stdBw', width: 22 },
{ header: 'Uniformity (%)', key: 'uniformity', width: 16 },
{ header: 'Uniformity Std (%)', key: 'stdUniformity', width: 20 },
{ header: 'Depletion Cumulative', key: 'depKum', width: 22 },
{ header: 'Depletion Standard', key: 'depStd', width: 20 },
{ header: 'Telur Utuh', key: 'butiranUtuh', width: 14 },
{ header: 'Telur Putih', key: 'butiranPutih', width: 14 },
{ header: 'Telur Retak', key: 'butiranRetak', width: 14 },
{ header: 'Telur Pecah', key: 'butiranPecah', width: 14 },
{ header: 'Jumlah Telur', key: 'butiranJumlah', width: 16 },
{ header: 'Total Telur', key: 'totalButir', width: 14 },
{ header: 'Utuh (Kg)', key: 'kgUtuh', width: 12 },
{ header: 'Putih (Kg)', key: 'kgPutih', width: 12 },
{ header: 'Retak (Kg)', key: 'kgRetak', width: 12 },
{ header: 'Pecah (Kg)', key: 'kgPecah', width: 12 },
{ header: 'Jumlah (Kg)', key: 'kgJumlah', width: 14 },
{ header: 'Total Weight (Kg)', key: 'totalKg', width: 20 },
{ header: 'Utuh (%)', key: 'persenUtuh', width: 12 },
{ header: 'Putih (%)', key: 'persenPutih', width: 12 },
{ header: 'Retak (%)', key: 'persenRetak', width: 12 },
{ header: 'Pecah (%)', key: 'persenPecah', width: 12 },
{ header: 'Hen Day (HD)', key: 'hd', width: 15 },
{ header: 'Hen Day Std (HD Std)', key: 'hdStd', width: 22 },
{ header: 'Feed Intake (FI)', key: 'fi', width: 18 },
{ header: 'Feed Intake Std (FI Std)', key: 'fiStd', width: 25 },
{ header: 'Egg Mass (EM)', key: 'em', width: 16 },
{ header: 'Egg Mass Std (EM Std)', key: 'emStd', width: 23 },
{ header: 'Egg Weight (EW)', key: 'ew', width: 18 },
{ header: 'Egg Weight Std (EW Std)', key: 'ewStd', width: 25 },
{ header: 'Feed Conversion Ratio (FCR)', key: 'fcr', width: 30 },
{
header: 'Feed Conversion Ratio Std (FCR Std)',
key: 'fcrStd',
width: 35,
},
{ header: 'Hen House (HH)', key: 'hh', width: 18 },
{ header: 'Hen House Std (HH Std)', key: 'hhStd', width: 25 },
];
const worksheet = workbook.addWorksheet('Production Result');
worksheet.columns = columns;
// Add data rows
params.data.forEach((item: ProductionResult, index: number) => {
worksheet.addRow({
no: index + 1,
projectFlockName: item.project_flock?.name || '',
projectFlockCategory: item.project_flock?.category || '',
kandangName: item.project_flock?.kandang?.name || '',
woa: formatNumber(item.woa || 0),
bw: formatNumber(item.bw || 0),
stdBw: formatNumber(item.std_bw || 0),
uniformity: formatNumber(item.uniformity || 0),
stdUniformity: item.std_uniformity || '',
depKum: formatNumber(item.dep_kum || 0),
depStd: formatNumber(item.dep_std || 0),
butiranUtuh: formatNumber(item.butiran_utuh || 0),
butiranPutih: formatNumber(item.butiran_putih || 0),
butiranRetak: formatNumber(item.butiran_retak || 0),
butiranPecah: formatNumber(item.butiran_pecah || 0),
butiranJumlah: formatNumber(item.butiran_jumlah || 0),
totalButir: formatNumber(item.total_butir || 0),
kgUtuh: formatNumber(item.kg_utuh || 0),
kgPutih: formatNumber(item.kg_putih || 0),
kgRetak: formatNumber(item.kg_retak || 0),
kgPecah: formatNumber(item.kg_pecah || 0),
kgJumlah: formatNumber(item.kg_jumlah || 0),
totalKg: formatNumber(item.total_kg || 0),
persenUtuh: formatNumber(item.persen_utuh || 0),
persenPutih: formatNumber(item.persen_putih || 0),
persenRetak: formatNumber(item.persen_retak || 0),
persenPecah: formatNumber(item.persen_pecah || 0),
hd: formatNumber(item.hd || 0),
hdStd: formatNumber(item.hd_std || 0),
fi: formatNumber(item.fi || 0),
fiStd: formatNumber(item.fi_std || 0),
em: formatNumber(item.em || 0),
emStd: formatNumber(item.em_std || 0),
ew: formatNumber(item.ew || 0),
ewStd: formatNumber(item.ew_std || 0),
fcr: formatNumber(item.fcr || 0),
fcrStd: formatNumber(item.fcr_std || 0),
hh: formatNumber(item.hh || 0),
hhStd: formatNumber(item.hh_std || 0),
});
});
const currentDate = new Date().toISOString().split('T')[0];
const filename = params.period
? `laporan-hasil-produksi-${params.period}.xlsx`
: `laporan-hasil-produksi-${currentDate}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
@@ -0,0 +1,59 @@
import { OptionType } from '@/components/input/SelectInput';
import * as yup from 'yup';
export type ProductionResultFilterProps = {
area_id: string | null;
location_id: string | null;
project_flock_id: string | null;
kandang_id: string | null;
};
export type ProductionResultFilterFormType = {
area_id: OptionType | null;
location_id: OptionType | null;
project_flock_id: OptionType | null;
kandang_id: OptionType | null;
};
export const ProductionResultFilterSchema = yup.object({
area_id: yup
.mixed<OptionType>()
.required('Area wajib dipilih')
.test('is-not-empty', 'Area wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
location_id: yup
.mixed<OptionType>()
.required('Lokasi wajib dipilih')
.test('is-not-empty', 'Lokasi wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
project_flock_id: yup
.mixed<OptionType>()
.required('Project Flock wajib dipilih')
.test('is-not-empty', 'Project Flock wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
kandang_id: yup
.mixed<OptionType>()
.required('Kandang wajib dipilih')
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
}) as yup.ObjectSchema<ProductionResultFilterFormType>;
export type ProductionResultFilterValues = yup.InferType<
typeof ProductionResultFilterSchema
>;
@@ -0,0 +1,51 @@
import React from 'react';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ProductionResult } from '@/types/api/report/production-result';
import { ColumnDef } from '@tanstack/react-table';
type ProductionResultColumn =
| ColumnDef<ProductionResult>
| {
header: string;
columns: Array<{
header: string;
accessorKey?: string;
cell?: (props: {
row: { original: ProductionResult };
}) => React.ReactNode;
}>;
};
const ProductionResultSkeleton = ({
columns,
icon,
title,
subtitle,
}: {
columns: ProductionResultColumn[];
icon: React.ReactNode;
title: string;
subtitle: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default ProductionResultSkeleton;
@@ -0,0 +1,842 @@
'use client';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import useSWR from 'swr';
import { generateProductionResultExcel } from '../export/ProductionResultExportXLSX';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable';
import { useFormik } from 'formik';
import {
ProductionResultFilterSchema,
type ProductionResultFilterValues,
} from '@/components/pages/report/production-result/filter/ProductionResultFilter';
import { BaseKandang } from '@/types/api/master-data/kandang';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import {
BaseProjectFlockKandang,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { isResponseSuccess } from '@/lib/api-helper';
import { ProductionResultReportApi } from '@/services/api/report/production-result';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import { ColumnDef } from '@tanstack/react-table';
import { ProductionResult } from '@/types/api/report/production-result';
import ProductionResultReportPDF from '../export/ProductionResultExportPDF';
import { pdf } from '@react-pdf/renderer';
import { useReportTabStore } from '@/stores/report/report-tab.store';
import Modal, { useModal } from '@/components/Modal';
import { cn, formatNumber } from '@/lib/helper';
import Pagination from '@/components/Pagination';
import ProductionResultSkeleton from '@/components/pages/report/production-result/skeleton/ProductionResultSkeleton';
interface ProductionResultTabProps {
tabId: string;
}
interface FilterParams {
area_id?: string;
location_id?: string;
project_flock_id?: string;
project_flock_kandang_id?: string;
}
const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
const [filterParams, setFilterParams] = useState<FilterParams>({});
// ===== PAGINATION STATE =====
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const filterModal = useModal();
// ===== TABLE COLUMNS =====
const productionResultColumns: ColumnDef<ProductionResult>[] = [
{
header: 'No',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'woa',
header: 'WOA',
},
{
accessorKey: 'bw',
header: 'BW',
cell: (props) => formatNumber(props.row.original.bw),
},
{
accessorKey: 'std_bw',
header: 'STD BW',
cell: (props) => formatNumber(props.row.original.std_bw),
},
{
accessorKey: 'uniformity',
header: 'Uniformity',
cell: (props) => formatNumber(props.row.original.uniformity),
},
{
accessorKey: 'std_uniformity',
header: 'STD Uniformity',
},
{
accessorKey: 'dep_kum',
header: 'Dep Kum',
cell: (props) => formatNumber(props.row.original.dep_kum),
},
{
accessorKey: 'dep_std',
header: 'Dep STD',
cell: (props) => formatNumber(props.row.original.dep_std),
},
{
header: 'Butiran',
columns: [
{
accessorKey: 'butiran_utuh',
header: 'Utuh',
cell: (props) => formatNumber(props.row.original.butiran_utuh),
},
{
accessorKey: 'butiran_putih',
header: 'Putih',
cell: (props) => formatNumber(props.row.original.butiran_putih),
},
{
accessorKey: 'butiran_retak',
header: 'Retak',
cell: (props) => formatNumber(props.row.original.butiran_retak),
},
{
accessorKey: 'butiran_pecah',
header: 'Pecah',
cell: (props) => formatNumber(props.row.original.butiran_pecah),
},
{
accessorKey: 'butiran_jumlah',
header: 'Jumlah (Butir)',
cell: (props) => formatNumber(props.row.original.butiran_jumlah),
},
{
accessorKey: 'total_butir',
header: 'Total Butir',
cell: (props) => formatNumber(props.row.original.total_butir),
},
],
},
{
header: 'Kg',
columns: [
{
accessorKey: 'kg_utuh',
header: 'Utuh (Kg)',
cell: (props) => formatNumber(props.row.original.kg_utuh),
},
{
accessorKey: 'kg_putih',
header: 'Putih (Kg)',
cell: (props) => formatNumber(props.row.original.kg_putih),
},
{
accessorKey: 'kg_retak',
header: 'Retak (Kg)',
cell: (props) => formatNumber(props.row.original.kg_retak),
},
{
accessorKey: 'kg_pecah',
header: 'Pecah (Kg)',
cell: (props) => formatNumber(props.row.original.kg_pecah),
},
{
accessorKey: 'kg_jumlah',
header: 'Jumlah (Kg)',
cell: (props) => formatNumber(props.row.original.kg_jumlah),
},
{
accessorKey: 'total_kg',
header: 'Total Kg',
cell: (props) => formatNumber(props.row.original.total_kg),
},
],
},
{
header: '%',
columns: [
{
accessorKey: 'persen_utuh',
header: 'Utuh',
cell: (props) => formatNumber(props.row.original.persen_utuh),
},
{
accessorKey: 'persen_putih',
header: 'Putih',
cell: (props) => formatNumber(props.row.original.persen_putih),
},
{
accessorKey: 'persen_retak',
header: 'Retak',
cell: (props) => formatNumber(props.row.original.persen_retak),
},
{
accessorKey: 'persen_pecah',
header: 'Pecah',
cell: (props) => formatNumber(props.row.original.persen_pecah),
},
],
},
];
// ===== FORMIK SETUP =====
const formik = useFormik<ProductionResultFilterValues>({
initialValues: {
area_id: null,
location_id: null,
project_flock_id: null,
kandang_id: null,
},
validationSchema: ProductionResultFilterSchema,
validateOnBlur: true,
validateOnChange: true,
onSubmit: (values) => {
setFilterParams({
area_id: values.area_id?.value
? String(values.area_id.value)
: undefined,
location_id: values.location_id?.value
? String(values.location_id.value)
: undefined,
project_flock_id: values.project_flock_id?.value
? String(values.project_flock_id.value)
: undefined,
project_flock_kandang_id: values.kandang_id?.value
? String(values.kandang_id.value)
: undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setPage(1);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setPage(1);
},
});
// ===== OPTIONS =====
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreas,
loadMore: loadMoreAreas,
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: formik.values.area_id?.value
? String(formik.values.area_id.value)
: '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlocks,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
area_id: formik.values.area_id?.value
? String(formik.values.area_id.value)
: '',
location_id: formik.values.location_id?.value
? String(formik.values.location_id.value)
: '',
category: 'LAYING',
}
);
const {
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangs,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>(
ProjectFlockKandangApi.basePath,
'id',
'kandang.name',
'search',
{
area_id: formik.values.area_id?.value
? String(formik.values.area_id.value)
: '',
location_id: formik.values.location_id?.value
? String(formik.values.location_id.value)
: '',
project_flock_id: formik.values.project_flock_id?.value
? String(formik.values.project_flock_id.value)
: '',
}
);
// ===== FILTER HELPERS =====
const areaValue = useMemo(
() => formik.values.area_id,
[formik.values.area_id]
);
const locationValue = useMemo(
() => formik.values.location_id,
[formik.values.location_id]
);
const projectFlockValue = useMemo(
() => formik.values.project_flock_id,
[formik.values.project_flock_id]
);
const projectFlockKandangValue = useMemo(
() => formik.values.kandang_id,
[formik.values.kandang_id]
);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.area_id) count += 1;
if (filterParams.location_id) count += 1;
if (filterParams.project_flock_id) count += 1;
if (filterParams.project_flock_kandang_id) count += 1;
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: projectFlockKandangsData, isLoading } = useSWR<
BaseApiResponse<ProjectFlockKandang[]>
>(
isSubmitted
? () => {
const params = new URLSearchParams();
if (filterParams.area_id)
params.append('area_id', filterParams.area_id);
if (filterParams.project_flock_id)
params.append('project_flock_id', filterParams.project_flock_id);
params.append('page', String(page));
params.append('limit', String(pageSize));
return [`/production/project-flock-kandangs?${params.toString()}`];
}
: null,
([url]: string[]) => httpClient<BaseApiResponse<ProjectFlockKandang[]>>(url)
);
const projectFlockKandangs = useMemo(
() =>
isResponseSuccess(projectFlockKandangsData)
? projectFlockKandangsData.data
: null,
[projectFlockKandangsData]
);
const projectFlockKandangMetadata = useMemo(
() =>
isResponseSuccess(projectFlockKandangsData)
? projectFlockKandangsData.meta
: undefined,
[projectFlockKandangsData]
);
// ===== EXPORT HANDLERS =====
const exportToExcelHandler = useCallback(async () => {
setIsExcelExportLoading(true);
try {
let projectFlockKandangsFetch: BaseProjectFlockKandang[] = [];
if (filterParams.project_flock_kandang_id) {
const projectFlockKandangResponse =
await ProjectFlockKandangApi.getSingle(
Number(filterParams.project_flock_kandang_id)
);
projectFlockKandangsFetch = isResponseSuccess(
projectFlockKandangResponse
)
? [projectFlockKandangResponse.data]
: [];
} else {
const projectFlockKandangsResponse =
await ProjectFlockKandangApi.getAll({
area_id: filterParams.area_id,
project_flock_id: filterParams.project_flock_id,
});
projectFlockKandangsFetch = isResponseSuccess(
projectFlockKandangsResponse
)
? projectFlockKandangsResponse.data
: [];
}
const productionResultData: ProductionResult[] = [];
for (const kandang of projectFlockKandangsFetch) {
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${kandang.id}?page=1&limit=100`;
const getProductionResultRes = await httpClient<
BaseApiResponse<ProductionResult[]>
>(getProductionResultPath);
if (isResponseSuccess(getProductionResultRes)) {
productionResultData.push(
...(getProductionResultRes.data?.map((result) => ({
...result,
project_flock: {
...result.project_flock,
name:
projectFlockValue?.label ||
kandang.project_flock?.name ||
`Project Flock #${kandang.project_flock_id}`,
category: kandang.project_flock?.category || '',
kandang: {
...result.project_flock?.kandang,
name:
kandang.kandang?.name || `Kandang #${kandang.kandang_id}`,
},
},
})) || [])
);
}
}
if (productionResultData.length === 0) {
toast.error('Tidak ada data untuk diexport.');
setIsExcelExportLoading(false);
return;
}
await generateProductionResultExcel({
data: productionResultData,
period: '',
});
} catch {
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, projectFlockValue]);
const exportToPdfHandler = useCallback(async () => {
setIsPdfExportLoading(true);
try {
let projectFlockKandangsFetch: BaseProjectFlockKandang[] = [];
if (filterParams.project_flock_kandang_id) {
const projectFlockKandangResponse =
await ProjectFlockKandangApi.getSingle(
Number(filterParams.project_flock_kandang_id)
);
projectFlockKandangsFetch = isResponseSuccess(
projectFlockKandangResponse
)
? [projectFlockKandangResponse.data]
: [];
} else {
const projectFlockKandangsResponse =
await ProjectFlockKandangApi.getAll({
area_id: filterParams.area_id,
project_flock_id: filterParams.project_flock_id,
});
projectFlockKandangsFetch = isResponseSuccess(
projectFlockKandangsResponse
)
? projectFlockKandangsResponse.data
: [];
}
const mappedProductionResults: {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
}[] = await Promise.all(
projectFlockKandangsFetch.map(async (projectFlockKandang) => {
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
const getProductionResultRes = await httpClient<
BaseApiResponse<ProductionResult[]>
>(getProductionResultPath);
return {
projectFlockKandang,
productionResult: isResponseSuccess(getProductionResultRes)
? getProductionResultRes.data
: null,
};
})
);
if (mappedProductionResults.length === 0) {
toast.error('Tidak ada data untuk diexport.');
setIsPdfExportLoading(false);
return;
}
const openPdf = async () => {
const productionResultPdfBlob = await pdf(
<ProductionResultReportPDF
mappedProductionResults={mappedProductionResults}
/>
).toBlob();
const productionResultReportPdfUrl = URL.createObjectURL(
productionResultPdfBlob
);
window.open(productionResultReportPdfUrl, '_blank');
};
await openPdf();
} catch (error) {
console.error(error);
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
}
setIsPdfExportLoading(false);
}, [filterParams]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useReportTabStore((state) => state.setTabActions);
const clearTabActions = useReportTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
onClick={() => filterModal.openModal()}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportToPdfHandler}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
hasFilters,
activeFiltersCount,
isAnyExportLoading,
exportToExcelHandler,
exportToPdfHandler,
setTabActions,
]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [tabId, clearTabActions]);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<ProductionResultSkeleton
columns={productionResultColumns}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !projectFlockKandangs || projectFlockKandangs.length === 0 ? (
<ProductionResultSkeleton
columns={productionResultColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
<>
{projectFlockKandangs.map(
(projectFlockKandang: ProjectFlockKandang) => (
<ProductionResultProjectFlockKandangTable
key={projectFlockKandang.id}
projectFlockKandangId={projectFlockKandang.id}
kandangName={projectFlockKandang.kandang.name}
/>
)
)}
<div className='max-w-sm ml-auto'>
<Pagination
totalItems={projectFlockKandangMetadata?.total_results || 0}
itemsPerPage={projectFlockKandangMetadata?.limit || 0}
currentPage={projectFlockKandangMetadata?.page || 0}
onPrevPage={() =>
setPage((currPage) =>
currPage > 1 ? currPage - 1 : currPage
)
}
onNextPage={() =>
setPage((currPage) =>
projectFlockKandangMetadata?.total_pages &&
currPage < projectFlockKandangMetadata.total_pages
? currPage + 1
: currPage
)
}
onPageChange={(pageNumber) => setPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
</>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
required
label='Area'
placeholder='Pilih Area'
options={areaOptions}
isLoading={isLoadingAreas}
value={areaValue}
onChange={(val) => {
formik.setFieldValue('area_id', val);
formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
}}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
isError={formik.touched.area_id && Boolean(formik.errors.area_id)}
errorMessage={formik.errors.area_id}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
required
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
isLoading={isLoadingLocations}
value={locationValue}
onChange={(val) => {
formik.setFieldValue('location_id', val);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
}}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!formik.values.area_id}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
required
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
isLoading={isLoadingProjectFlocks}
value={projectFlockValue}
onChange={(val) => {
formik.setFieldValue('project_flock_id', val);
formik.setFieldValue('kandang_id', null);
}}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!formik.values.location_id}
isError={
formik.touched.project_flock_id &&
Boolean(formik.errors.project_flock_id)
}
errorMessage={formik.errors.project_flock_id}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
required
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
isLoading={isLoadingProjectFlockKandangs}
value={projectFlockKandangValue}
onChange={(val) => {
formik.setFieldValue('kandang_id', val);
}}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!formik.values.project_flock_id}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
errorMessage={formik.errors.kandang_id}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default ProductionResultContent;
@@ -4,12 +4,10 @@ import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { ProductionResult } from '@/types/api/report/production-result';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -52,8 +50,6 @@ const ProductionResultProjectFlockKandangTable = ({
}
);
const [open, setOpen] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const productionResultColumns: ColumnDef<ProductionResult>[] = [
@@ -270,93 +266,60 @@ const ProductionResultProjectFlockKandangTable = ({
}
}, [sorting]);
useEffect(() => {
if (!open) {
setOpen(
return (
<Card
title={kandangName}
className={{
wrapper: 'w-full rounded-lg border-none',
body: 'p-0',
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
collapsible: 'rounded-lg',
}}
variant='bordered'
collapsible={true}
defaultCollapsed={
isResponseSuccess(productionResults)
? productionResults.data.length > 0
: false
);
}
}, [productionResults, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>{kandangName}</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
<Table<ProductionResult>
data={
isResponseSuccess(productionResults) ? productionResults?.data : []
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
{/* <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Record'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div> */}
<Table<ProductionResult>
data={
isResponseSuccess(productionResults)
? productionResults?.data
: []
}
columns={productionResultColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(productionResults)
? productionResults?.meta?.page
: 0
}
totalItems={
isResponseSuccess(productionResults)
? productionResults?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingProductionResults}
sorting={sorting}
setSorting={setSorting}
renderFooter={false}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(productionResults) &&
productionResults?.data?.length === 0,
}),
headerColumnClassName:
'px-4 py-3 border-x border-base-content/10 text-base-content/50',
}}
/>
</div>
</Collapse>
columns={productionResultColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(productionResults)
? productionResults?.meta?.page
: 0
}
totalItems={
isResponseSuccess(productionResults)
? productionResults?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingProductionResults}
sorting={sorting}
setSorting={setSorting}
renderFooter={false}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</Card>
);
};
@@ -1,51 +0,0 @@
'use client';
import { ReactNode } from 'react';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export type FinanceTabActionsSlice = {
// State - actions per tab ID
tabActions: Record<string, ReactNode>;
// Actions
setTabActions: (tabId: string, actions: ReactNode) => void;
clearTabActions: (tabId: string) => void;
clearAllTabActions: () => void;
};
export const useFinanceTabStore = create<FinanceTabActionsSlice>()(
devtools(
(set) => ({
tabActions: {},
setTabActions: (tabId, actions) =>
set(
(state) => ({
tabActions: {
...state.tabActions,
[tabId]: actions,
},
}),
false,
'setTabActions'
),
clearTabActions: (tabId) =>
set(
(state) => {
const { [tabId]: _, ...rest } = state.tabActions;
return { tabActions: rest };
},
false,
'clearTabActions'
),
clearAllTabActions: () =>
set({ tabActions: {} }, false, 'clearAllTabActions'),
}),
{
name: 'FinanceTabStore',
}
)
);
@@ -2,7 +2,7 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createProjectFlockSlice } from '@/stores/project-flock/slices/project-flock.slice';
import { createProjectFlockSlice } from '@/stores/production/project-flock/slices/project-flock.slice';
import { ProjectFlockSlice } from '@/types/stores';
export type ProjectFlockStore = ProjectFlockSlice;
@@ -2,7 +2,7 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createUniformitySlice } from '@/stores/uniformity/slices/uniformity.slice';
import { createUniformitySlice } from '@/stores/production/uniformity/slices/uniformity.slice';
import { UniformitySlice } from '@/types/stores';
export type UniformityStore = UniformitySlice;
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import {
createReportTabSlice,
ReportTabSlice,
} from '@/stores/report/slices/report-tab.slice';
export type ReportTabStore = ReportTabSlice;
export const useReportTabStore = create<ReportTabStore>()(
devtools(
(...args) => ({
...createReportTabSlice(...args),
}),
{
name: 'ReportTabStore',
}
)
);
@@ -0,0 +1,37 @@
import { ReactNode } from 'react';
import { StateCreator } from 'zustand';
export type ReportTabSlice = {
// State - actions per tab ID
tabActions: Record<string, ReactNode>;
// Actions
setTabActions: (tabId: string, actions: ReactNode) => void;
clearTabActions: (tabId: string) => void;
clearAllTabActions: () => void;
};
export const createReportTabSlice: StateCreator<
ReportTabSlice,
[],
[],
ReportTabSlice
> = (set) => ({
tabActions: {},
setTabActions: (tabId, actions) =>
set((state) => ({
tabActions: {
...state.tabActions,
[tabId]: actions,
},
})),
clearTabActions: (tabId) =>
set((state) => {
const { [tabId]: _, ...rest } = state.tabActions;
return { tabActions: rest };
}),
clearAllTabActions: () => set({ tabActions: {} }),
});
+1
View File
@@ -38,6 +38,7 @@ export type StockLog = {
id: number;
increase: number;
decrease: number;
stock: number;
loggable_type: string;
loggable_id: number;
notes: string;