diff --git a/CLAUDE.md b/CLAUDE.md index b1025264..d0a2f23c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,3 +160,62 @@ const handleFilterLocationChange = useCallback( - `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` - Use same pattern for data tables in other modules (inventory, finance, purchase, etc.) + +## Server-side file export pattern + +All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries. + +**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method. + +### Service method (in `src/services/api/{feature}.ts`) + +```ts +async exportToExcel(initialQueryString: string) { + const params = new URLSearchParams(initialQueryString); + + params.set('export', 'excel'); // or 'pdf', 'csv', etc. + params.set('page', '1'); + params.set('limit', '99999999999'); + + const res = await httpClient(`${this.basePath}?${params.toString()}`, { + method: 'GET', + responseType: 'blob', + }); + + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`); + document.body.appendChild(link); + link.click(); + link.remove(); +} +``` + +- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports. +- Add one method per format; keep them side-by-side in the same service class. + +### Component handler (in the page/tab component) + +```ts +const handleExportExcel = useCallback(async () => { + setIsExcelExportLoading(true); + try { + const params = new URLSearchParams(); + if (filterParams.foo) params.set('foo', filterParams.foo); + // ... map all active filter params ... + + await FeatureApi.exportToExcel(params.toString()); + toast.success('Excel berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + } finally { + setIsExcelExportLoading(false); + } +}, [filterParams, searchValue]); +``` + +- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method. +- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components. + +**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx). diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index 167be1fa..f1b63684 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -17,16 +17,10 @@ import { formatVechicleNumber, formatTitleCase, } from '@/lib/helper'; -import { - DailyMarketingRow, - DailyMarketingReportResponse, -} from '@/types/api/report/marketing'; +import { DailyMarketingRow } 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'; @@ -39,8 +33,6 @@ import Modal, { useModal } from '@/components/Modal'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.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, @@ -284,68 +276,30 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { [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('page', '1'); - params.set('limit', '9999999'); - - const queryString = `?${params.toString()}`; - - try { - const response = await httpClient( - `${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(); + const params = new URLSearchParams(); - if (!allDataForExport || allDataForExport.length === 0) { - toast.error('Tidak ada data untuk diekspor.'); - return; - } + 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); - 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, - }); + await MarketingReportApi.exportDailyMarketingToExcel(params.toString()); toast.success('Excel berhasil dibuat dan diunduh.'); } catch { @@ -353,34 +307,39 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { } finally { setIsExcelExportLoading(false); } - }, [filterParams, dailyMarketingsExport, summaryTotal]); + }, [filterParams, searchValue]); const handleExportPDF = useCallback(async () => { setIsPdfExportLoading(true); try { - const allDataForExport = await dailyMarketingsExport(); + const params = new URLSearchParams(); - if (!allDataForExport || allDataForExport.length === 0) { - toast.error('Tidak ada data untuk diekspor.'); - return; - } + 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); - const dailyMarketingReportPdfBlob = await pdf( - - ).toBlob(); + await MarketingReportApi.exportDailyMarketingToPDF(params.toString()); - const dailyMarketingReportPdfUrl = URL.createObjectURL( - dailyMarketingReportPdfBlob - ); - window.open(dailyMarketingReportPdfUrl, '_blank'); - - toast.success('PDF berhasil dibuat.'); + toast.success('PDF berhasil dibuat dan diunduh.'); } catch { toast.error('Gagal membuat PDF. Silakan coba lagi.'); } finally { setIsPdfExportLoading(false); } - }, [dailyMarketingsExport, summaryTotal]); + }, [filterParams, searchValue]); // ===== TAB ACTIONS COMPONENT ===== const TabActions = useMemo(() => { diff --git a/src/services/api/report/marketing-report.ts b/src/services/api/report/marketing-report.ts index 0f7c5cbb..48562969 100644 --- a/src/services/api/report/marketing-report.ts +++ b/src/services/api/report/marketing-report.ts @@ -1,14 +1,9 @@ -import * as XLSX from 'xlsx'; -import toast from 'react-hot-toast'; - import { BaseApiService } from '@/services/api/base'; -import { httpClientFetcher } from '@/services/http/client'; -import { BaseApiResponse } from '@/types/api/api-general'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; import { DailyMarketingReport, DailyMarketingReportResponse, } from '@/types/api/report/marketing'; -import { isResponseError } from '@/lib/api-helper'; import { formatDate } from '@/lib/helper'; export class MarketingReportApiService extends BaseApiService< @@ -29,48 +24,53 @@ export class MarketingReportApiService extends BaseApiService< async exportDailyMarketingToExcel(initialQueryString: string) { const params = new URLSearchParams(initialQueryString); - params.set('limit', '9999999'); + params.set('export', 'excel'); + params.set('page', '1'); + params.set('limit', '99999999999'); const queryString = `?${params.toString()}`; - try { - const dailyMarketingsReport = await httpClientFetcher< - BaseApiResponse - >(`${this.basePath}${queryString}`); + const res = await httpClient(`${this.basePath}${queryString}`, { + method: 'GET', + responseType: 'blob', + }); - if (isResponseError(dailyMarketingsReport)) { - toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); - return; - } + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; - const rows = dailyMarketingsReport.data; + const fileName = `laporan-penjualan-harian-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`; + link.setAttribute('download', fileName); - const formattedRows = []; + document.body.appendChild(link); + link.click(); + link.remove(); + } - for (let i = 0; i < rows.length; i++) { - formattedRows.push({ - ...rows[i], - // created_user: rows[i].created_user.name, - // created_at: formatDate(rows[i].created_at, 'YYYY-MM-DD'), - // updated_at: formatDate(rows[i].updated_at, 'YYYY-MM-DD'), - so_date: formatDate(rows[i].so_date, 'YYYY-MM-DD'), - realization_date: formatDate(rows[i].realization_date, 'YYYY-MM-DD'), - sales: rows[i].sales.name, - warehouse: rows[i].warehouse.name, - customer: rows[i].customer.name, - product: rows[i].product.name, - }); - } + async exportDailyMarketingToPDF(initialQueryString: string) { + const params = new URLSearchParams(initialQueryString); - const ws = XLSX.utils.json_to_sheet(formattedRows); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'laporan-penjualan-harian'); + params.set('export', 'pdf'); + params.set('page', '1'); + params.set('limit', '99999999999'); - // triggers download in browser - XLSX.writeFile(wb, 'laporan-penjualan-harian.xlsx'); - } catch { - toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); - } + const queryString = `?${params.toString()}`; + + const res = await httpClient(`${this.basePath}${queryString}`, { + method: 'GET', + responseType: 'blob', + }); + + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; + + const fileName = `laporan-penjualan-harian-${formatDate(Date.now(), 'DD-MM-YYYY')}.pdf`; + link.setAttribute('download', fileName); + + document.body.appendChild(link); + link.click(); + link.remove(); } }