From 16ad569297442000452422e9d288ba07323f5957 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 14 Aug 2025 03:17:17 +0700 Subject: [PATCH] feat: report inventory --- .../apps/inventory/stock/export/page.tsx | 317 ++++++++++++++++++ src/services/queries/analytics.ts | 41 +++ src/types/services/analytic.ts | 53 +++ .../ecommerce/stock/list/StockListTable.tsx | 25 +- 4 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/inventory/stock/export/page.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/inventory/stock/export/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/inventory/stock/export/page.tsx new file mode 100644 index 0000000..f087a27 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/inventory/stock/export/page.tsx @@ -0,0 +1,317 @@ +'use client' + +import { useInventoryAnalytics } from '@/services/queries/analytics' +import { useOutletById } from '@/services/queries/outlets' +import { formatDateDDMMYYYY } from '@/utils/transform' +import ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator' +import ReportHeader from '@/views/dashboards/daily-report/report-header' +import { useRef, useState } from 'react' +const ExportInventoryPage = () => { + const reportRef = useRef(null) + const [now, setNow] = useState(new Date()) + const [selectedDate, setSelectedDate] = useState(new Date()) + const [dateRange, setDateRange] = useState({ + startDate: new Date(), + endDate: new Date() + }) + const [filterType, setFilterType] = useState<'single' | 'range'>('single') // 'single' or 'range' + + const getDateParams = () => { + if (filterType === 'single') { + return { + date_from: formatDateDDMMYYYY(selectedDate), + date_to: formatDateDDMMYYYY(selectedDate) + } + } else { + return { + date_from: formatDateDDMMYYYY(dateRange.startDate), + date_to: formatDateDDMMYYYY(dateRange.endDate) + } + } + } + + const dateParams = getDateParams() + + const { data: outlet } = useOutletById() + const { data: inventory } = useInventoryAnalytics(dateParams) + + const handleGeneratePDF = async () => { + const reportElement = reportRef.current + + try { + // Import jsPDF dan html2canvas + const jsPDF = (await import('jspdf')).default + const html2canvas = (await import('html2canvas')).default + + // Optimized canvas capture dengan scale lebih rendah + const canvas = await html2canvas(reportElement!, { + scale: 1.5, // Reduced from 2 to 1.5 + useCORS: true, + allowTaint: true, + backgroundColor: '#ffffff', + logging: false, // Disable logging for performance + removeContainer: true, // Clean up after capture + imageTimeout: 0, // No timeout for image loading + height: reportElement!.scrollHeight, + width: reportElement!.scrollWidth + }) + + // Compress canvas using JPEG with quality setting + const imgData = canvas.toDataURL('image/jpeg', 0.85) // JPEG with 85% quality instead of PNG + + // Create PDF with compression + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + compress: true // Enable built-in compression + }) + + const imgWidth = 210 + const pageHeight = 295 + const imgHeight = (canvas.height * imgWidth) / canvas.width + let heightLeft = imgHeight + let position = 0 + + // Add first page with compressed image + pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight, '', 'FAST') // Use FAST compression + heightLeft -= pageHeight + + // Handle multiple pages if needed + while (heightLeft >= 0) { + position = heightLeft - imgHeight + pdf.addPage() + pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight, '', 'FAST') + heightLeft -= pageHeight + } + + // Additional compression options + pdf.setProperties({ + title: `Laporan Inventori`, + subject: 'Laporan Inventori', + author: 'Apskel POS System', + creator: 'Apskel' + }) + + // Save with optimized settings + const fileName = + filterType === 'single' + ? `laporan-inventory-${formatDateForInput(selectedDate)}.pdf` + : `laporan-inventory-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.pdf` + + pdf.save(fileName, { + returnPromise: true + }) + + // Clean up canvas to free memory + canvas.width = canvas.height = 0 + } catch (error) { + console.error('Error generating PDF:', error) + alert('Terjadi kesalahan saat membuat PDF. Pastikan jsPDF dan html2canvas sudah terinstall.') + } + } + + const formatDateForInput = (date: Date) => { + return date.toISOString().split('T')[0] + } + + const getReportPeriodText = () => { + if (filterType === 'single') { + return `${formatDateDDMMYYYY(selectedDate)} - ${formatDateDDMMYYYY(selectedDate)}` + } else { + return `${formatDateDDMMYYYY(dateRange.startDate)} - ${formatDateDDMMYYYY(dateRange.endDate)}` + } + } + + const productSummary = { + totalQuantity: inventory?.products?.reduce((sum, item) => sum + (item?.quantity || 0), 0) || 0, + totalIn: inventory?.products?.reduce((sum, item) => sum + (item?.total_in || 0), 0) || 0, + totalOut: inventory?.products?.reduce((sum, item) => sum + (item?.total_out || 0), 0) || 0 + } + + const ingredientSummary = { + totalQuantity: inventory?.ingredients?.reduce((sum, item) => sum + (item?.quantity || 0), 0) || 0, + totalIn: inventory?.ingredients?.reduce((sum, item) => sum + (item?.total_in || 0), 0) || 0, + totalOut: inventory?.ingredients?.reduce((sum, item) => sum + (item?.total_out || 0), 0) || 0 + } + + return ( +
+ {/* Control Panel */} + +
+ {/* Header */} + + + {/* Ringkasan */} +
+

+ 1. Ringkasan +

+ +
+
+
+ Total Item + {inventory?.summary.total_products} +
+
+ Total Item Masuk + {inventory?.summary.low_stock_products} +
+
+ Total Item Keluar + {inventory?.summary.zero_stock_products} +
+
+ +
+
+ Total Ingredient + {inventory?.summary.total_ingredients} +
+
+ Total Ingredient Masuk + {inventory?.summary.low_stock_ingredients} +
+
+ Total Ingredient Keluar + {inventory?.summary.zero_stock_ingredients} +
+
+
+
+ + {/* Item */} +
+

+ 2. Item +

+ +
+ + + + + + + + + + + + {inventory?.products?.map((product, index) => { + let rowClass = index % 2 === 0 ? 'bg-white' : 'bg-gray-50' + + if (product.is_zero_stock) { + rowClass = 'bg-red-300' // Merah untuk stok habis + } else if (product.is_low_stock) { + rowClass = 'bg-yellow-300' // Kuning untuk stok sedikit + } + return ( + + + + + + + + + ) + }) || []} + + + + + + + + + + +
NamaKategoriStockMasukKeluar
{product.product_name}{product.category_name}{product.quantity}{product.total_in}{product.total_out}
TOTAL{productSummary.totalQuantity ?? 0}{productSummary.totalIn ?? 0}{productSummary.totalOut ?? 0}
+
+
+ + {/* Ingredient */} +
+

+ 3. Ingredient +

+ +
+ + + + + + + + + + + {inventory?.ingredients?.map((ingredient, index) => { + let rowClass = index % 2 === 0 ? 'bg-white' : 'bg-gray-50' + + if (ingredient.is_zero_stock) { + rowClass = 'bg-red-300' // Merah untuk stok habis + } else if (ingredient.is_low_stock) { + rowClass = 'bg-yellow-300' // Kuning untuk stok sedikit + } + return ( + + + + + + + ) + }) || []} + + + + + + + + + +
NamaStockMasukKeluar
{ingredient.ingredient_name}{ingredient.quantity}{ingredient.total_in}{ingredient.total_out}
TOTAL{ingredientSummary.totalQuantity ?? 0}{ingredientSummary.totalIn ?? 0}{ingredientSummary.totalOut ?? 0}
+
+
+ + {/* Footer */} +
+
+

© 2025 Apskel - Sistem POS Terpadu

+

+

Dicetak pada: {now.toLocaleDateString('id-ID')}

+
+
+
+
+ ) +} + +export default ExportInventoryPage diff --git a/src/services/queries/analytics.ts b/src/services/queries/analytics.ts index f169c28..b636956 100644 --- a/src/services/queries/analytics.ts +++ b/src/services/queries/analytics.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { CategoryReport, DashboardReport, + InventoryReport, PaymentReport, ProductSalesReport, ProfitLossReport, @@ -194,3 +195,43 @@ export function useCategoryAnalytics(params: AnalyticQueryParams = {}) { } }) } + +export function useInventoryAnalytics(params: AnalyticQueryParams = {}) { + const today = new Date() + const monthAgo = new Date() + monthAgo.setDate(today.getDate() - 30) + + const defaultDateTo = formatDateDDMMYYYY(today) + const defaultDateFrom = formatDateDDMMYYYY(monthAgo) + + const { date_from = defaultDateFrom, date_to = defaultDateTo, ...filters } = params + + const user = (() => { + try { + return JSON.parse(localStorage.getItem('user') || '{}') + } catch { + return {} + } + })() + + const outletId = user?.outlet_id // + + return useQuery({ + queryKey: ['analytics-inventory', { date_from, date_to, ...filters }], + queryFn: async () => { + const queryParams = new URLSearchParams() + + queryParams.append('date_from', date_from) + queryParams.append('date_to', date_to) + + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, value.toString()) + } + }) + + const res = await api.get(`/inventory/report/details/${outletId}?${queryParams.toString()}`) + return res.data.data + } + }) +} diff --git a/src/types/services/analytic.ts b/src/types/services/analytic.ts index a978ccf..6b49b0e 100644 --- a/src/types/services/analytic.ts +++ b/src/types/services/analytic.ts @@ -172,3 +172,56 @@ export interface CategoryDataReport { product_count: number order_count: number } + +export interface InventoryReport { + summary: InventorySummaryReport + products: InventoryProductReport[] + ingredients: InventoryIngredientReport[] +} + +export interface InventorySummaryReport { + total_products: number + total_ingredients: number + total_value: number + low_stock_products: number + low_stock_ingredients: number + zero_stock_products: number + zero_stock_ingredients: number + total_sold_products: number + total_sold_ingredients: number + outlet_id: string + outlet_name: string + generated_at: string +} + +export interface InventoryProductReport { + id: string + product_id: string + product_name: string + category_name: string + quantity: number + reorder_level: number + unit_cost: number + total_value: number + total_in: number + total_out: number + is_low_stock: boolean + is_zero_stock: boolean + updated_at: string +} + +export interface InventoryIngredientReport { + id: string + ingredient_id: string + ingredient_name: string + unit_name: string + quantity: number + reorder_level: number + unit_cost: number + total_value: number + total_in: number + total_out: number + is_low_stock: boolean + is_zero_stock: boolean + updated_at: string +} diff --git a/src/views/apps/ecommerce/stock/list/StockListTable.tsx b/src/views/apps/ecommerce/stock/list/StockListTable.tsx index ecbec50..7064946 100644 --- a/src/views/apps/ecommerce/stock/list/StockListTable.tsx +++ b/src/views/apps/ecommerce/stock/list/StockListTable.tsx @@ -41,6 +41,10 @@ import { useInventoriesMutation } from '../../../../../services/mutations/invent import { useInventories } from '../../../../../services/queries/inventories' import { Inventory } from '../../../../../types/services/inventory' import AddStockDrawer from './AddStockDrawer' +import Link from 'next/link' +import { getLocalizedUrl } from '@/utils/i18n' +import { Locale } from '@/configs/i18n' +import { useParams } from 'next/navigation' declare module '@tanstack/table-core' { interface FilterFns { @@ -109,6 +113,9 @@ const StockListTable = () => { const [addInventoryOpen, setAddInventoryOpen] = useState(false) const [search, setSearch] = useState('') + // Hooks + const { lang: locale } = useParams() + // Fetch products with pagination and search const { data, isLoading, error, isFetching } = useInventories({ page: currentPage, @@ -259,14 +266,16 @@ const StockListTable = () => { 25 50 - + + +