diff --git a/app/Exports/StockProductsExport.php b/app/Exports/StockProductsExport.php
new file mode 100644
index 0000000..4d87c19
--- /dev/null
+++ b/app/Exports/StockProductsExport.php
@@ -0,0 +1,316 @@
+reportData = $reportData;
+
+ // Debug: Log the structure of report data
+ Log::info('StockProductsExport constructor', [
+ 'has_data' => isset($reportData['data']),
+ 'has_dealers' => isset($reportData['dealers']),
+ 'data_count' => isset($reportData['data']) ? count($reportData['data']) : 0,
+ 'dealers_count' => isset($reportData['dealers']) ? count($reportData['dealers']) : 0,
+ 'dealers' => isset($reportData['dealers']) ? $reportData['dealers']->pluck('name')->toArray() : []
+ ]);
+ }
+
+ public function collection()
+ {
+ try {
+ $data = collect();
+ $no = 1;
+
+ foreach ($this->reportData['data'] as $row) {
+ $exportRow = [
+ 'no' => $no++,
+ 'kode_produk' => $row['product_code'] ?? '',
+ 'nama_produk' => $row['product_name'] ?? '',
+ 'kategori' => $row['category_name'] ?? '',
+ 'satuan' => $row['unit'] ?? ''
+ ];
+
+ // Add dealer columns
+ foreach ($this->reportData['dealers'] as $dealer) {
+ try {
+ $dealerKey = "dealer_{$dealer->id}";
+ // Clean dealer name for array key to avoid special characters
+ $cleanDealerName = $this->cleanDealerName($dealer->name);
+ $exportRow[$cleanDealerName] = $row[$dealerKey] ?? 0;
+
+ Log::info('Processing dealer column', [
+ 'original_name' => $dealer->name,
+ 'clean_name' => $cleanDealerName,
+ 'dealer_key' => $dealerKey,
+ 'value' => $row[$dealerKey] ?? 0
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Error processing dealer column', [
+ 'dealer_id' => $dealer->id,
+ 'dealer_name' => $dealer->name,
+ 'error' => $e->getMessage()
+ ]);
+ // Use a safe fallback name
+ $exportRow['Dealer_' . $dealer->id] = $row["dealer_{$dealer->id}"] ?? 0;
+ }
+ }
+
+ // Add total stock
+ $exportRow['total_stok'] = $row['total_stock'] ?? 0;
+
+ $data->push($exportRow);
+ }
+
+ return $data;
+
+ } catch (\Exception $e) {
+ Log::error('Error in collection method', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ // Return empty collection as fallback
+ return collect();
+ }
+ }
+
+ public function headings(): array
+ {
+ try {
+ $headings = [
+ 'No',
+ 'Kode Produk',
+ 'Nama Produk',
+ 'Kategori',
+ 'Satuan'
+ ];
+
+ // Add dealer headings
+ foreach ($this->reportData['dealers'] as $dealer) {
+ try {
+ $cleanName = $this->cleanDealerName($dealer->name);
+ $headings[] = $cleanName;
+
+ Log::info('Processing dealer heading', [
+ 'original_name' => $dealer->name,
+ 'clean_name' => $cleanName
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Error processing dealer heading', [
+ 'dealer_id' => $dealer->id,
+ 'dealer_name' => $dealer->name,
+ 'error' => $e->getMessage()
+ ]);
+ // Use a safe fallback name
+ $headings[] = 'Dealer_' . $dealer->id;
+ }
+ }
+
+ // Add total heading
+ $headings[] = 'Total Stok';
+
+ return $headings;
+
+ } catch (\Exception $e) {
+ Log::error('Error in headings method', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ // Return basic headings as fallback
+ return ['No', 'Kode Produk', 'Nama Produk', 'Kategori', 'Satuan', 'Total Stok'];
+ }
+ }
+
+ public function styles(Worksheet $sheet)
+ {
+ try {
+ $lastColumn = $sheet->getHighestColumn();
+ $lastRow = $sheet->getHighestRow();
+
+ // Validate column and row values
+ if (!$lastColumn || !$lastRow || $lastRow < 1) {
+ Log::warning('Invalid sheet dimensions', ['lastColumn' => $lastColumn, 'lastRow' => $lastRow]);
+ return $sheet;
+ }
+
+ // Style header row
+ $sheet->getStyle('A1:' . $lastColumn . '1')->applyFromArray([
+ 'font' => [
+ 'bold' => true,
+ 'color' => ['rgb' => 'FFFFFF'],
+ ],
+ 'fill' => [
+ 'fillType' => Fill::FILL_SOLID,
+ 'startColor' => ['rgb' => '4472C4'],
+ ],
+ 'alignment' => [
+ 'horizontal' => Alignment::HORIZONTAL_CENTER,
+ 'vertical' => Alignment::VERTICAL_CENTER,
+ ],
+ ]);
+
+ // Style all cells
+ $sheet->getStyle('A1:' . $lastColumn . $lastRow)->applyFromArray([
+ 'borders' => [
+ 'allBorders' => [
+ 'borderStyle' => Border::BORDER_THIN,
+ 'color' => ['rgb' => '000000'],
+ ],
+ ],
+ 'alignment' => [
+ 'vertical' => Alignment::VERTICAL_CENTER,
+ ],
+ ]);
+
+ // Center align specific columns
+ $sheet->getStyle('A:A')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+ $sheet->getStyle('D:D')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+
+ // Right align numeric columns (dealer columns and total)
+ $dealerStartCol = 'E';
+ $dealerCount = count($this->reportData['dealers']);
+
+ if ($dealerCount > 0) {
+ $dealerEndCol = chr(ord('E') + $dealerCount - 1);
+ $totalCol = chr(ord($dealerStartCol) + $dealerCount);
+
+ // Validate column letters
+ if (ord($dealerEndCol) <= ord('Z') && ord($totalCol) <= ord('Z')) {
+ $sheet->getStyle($dealerStartCol . ':' . $dealerEndCol)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+ $sheet->getStyle($totalCol . ':' . $totalCol)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+
+ // Bold total column
+ $sheet->getStyle($totalCol . '1:' . $totalCol . $lastRow)->getFont()->setBold(true);
+ }
+ }
+
+ // Auto-size columns safely
+ foreach (range('A', $lastColumn) as $column) {
+ try {
+ $sheet->getColumnDimension($column)->setAutoSize(true);
+ } catch (\Exception $e) {
+ Log::warning('Failed to auto-size column', ['column' => $column, 'error' => $e->getMessage()]);
+ }
+ }
+
+ return $sheet;
+
+ } catch (\Exception $e) {
+ Log::error('Error in styles method', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ return $sheet;
+ }
+ }
+
+ public function columnWidths(): array
+ {
+ try {
+ $widths = [
+ 'A' => 8, // No
+ 'B' => 15, // Kode Produk
+ 'C' => 30, // Nama Produk
+ 'D' => 15, // Kategori
+ 'E' => 10, // Satuan
+ ];
+
+ // Add dealer column widths safely
+ $currentCol = 'F';
+ $dealerCount = count($this->reportData['dealers']);
+
+ for ($i = 0; $i < $dealerCount; $i++) {
+ // Validate column letter
+ if (ord($currentCol) <= ord('Z')) {
+ $widths[$currentCol] = 15;
+ $currentCol = chr(ord($currentCol) + 1);
+ } else {
+ Log::warning('Too many dealer columns, stopping at column Z');
+ break;
+ }
+ }
+
+ // Add total column width if we haven't exceeded Z
+ if (ord($currentCol) <= ord('Z')) {
+ $widths[$currentCol] = 15;
+ }
+
+ return $widths;
+
+ } catch (\Exception $e) {
+ Log::error('Error in columnWidths method', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ // Return basic widths as fallback
+ return [
+ 'A' => 8, // No
+ 'B' => 15, // Kode Produk
+ 'C' => 30, // Nama Produk
+ 'D' => 15, // Kategori
+ 'E' => 10, // Satuan
+ 'F' => 15 // Total Stok
+ ];
+ }
+ }
+
+ /**
+ * Clean dealer name to make it safe for array keys and Excel headers
+ */
+ private function cleanDealerName($dealerName)
+ {
+ // Remove or replace special characters that can cause issues with Excel
+ $cleanName = preg_replace('/[^a-zA-Z0-9\s\-_]/', '', $dealerName);
+ $cleanName = trim($cleanName);
+
+ // If name becomes empty, use a default
+ if (empty($cleanName)) {
+ $cleanName = 'Dealer';
+ }
+
+ // Limit length to avoid Excel issues
+ if (strlen($cleanName) > 31) {
+ $cleanName = substr($cleanName, 0, 31);
+ }
+
+ return $cleanName;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Reports/ReportStockProductsController.php b/app/Http/Controllers/Reports/ReportStockProductsController.php
new file mode 100644
index 0000000..074a873
--- /dev/null
+++ b/app/Http/Controllers/Reports/ReportStockProductsController.php
@@ -0,0 +1,102 @@
+first();
+ abort_if(!Gate::allows('view', $menu), 403);
+
+ return view('reports.stock-products');
+ }
+
+ public function getData(Request $request)
+ {
+ $menu = Menu::where('link','reports.stock-product.index')->first();
+ abort_if(!Gate::allows('view', $menu), 403);
+
+ if ($request->ajax()) {
+ $filterDate = $request->get('filter_date');
+
+ $stockService = new StockReportService();
+ $reportData = $stockService->getOptimizedStockReportData($filterDate);
+
+ return DataTables::of($reportData['data'])
+ ->addIndexColumn()
+ ->addColumn('product_info', function($row) {
+ return "{$row['product_name']}
{$row['product_code']}";
+ })
+ ->addColumn('total_stock', function($row) {
+ return number_format($row['total_stock'], 2);
+ })
+ ->rawColumns(['product_info'])
+ ->make(true);
+ }
+
+ return response()->json(['error' => 'Invalid request'], 400);
+ }
+
+ public function getDealers()
+ {
+ $dealers = Dealer::orderBy('name')->get(['id', 'name', 'dealer_code']);
+ return response()->json($dealers);
+ }
+
+ public function export(Request $request)
+ {
+ try {
+ $menu = Menu::where('link','reports.stock-product.index')->first();
+ abort_if(!Gate::allows('view', $menu), 403);
+
+ $filterDate = $request->get('filter_date');
+
+ $stockService = new StockReportService();
+ $reportData = $stockService->getOptimizedStockReportData($filterDate);
+
+ // Validate report data
+ if (!isset($reportData['data']) || !isset($reportData['dealers'])) {
+ throw new \Exception('Invalid report data structure');
+ }
+
+ // Debug: Log dealer names to identify problematic characters
+ Log::info('Export data validation', [
+ 'data_count' => count($reportData['data']),
+ 'dealers_count' => count($reportData['dealers']),
+ 'dealer_names' => $reportData['dealers']->pluck('name')->toArray(),
+ 'first_data_row' => isset($reportData['data'][0]) ? array_keys($reportData['data'][0]) : []
+ ]);
+
+ $fileName = 'laporan_stok_produk_' . ($filterDate ?: date('Y-m-d')) . '.xlsx';
+
+ return Excel::download(new StockProductsExport($reportData), $fileName);
+
+ } catch (\Exception $e) {
+ Log::error('Export error: ' . $e->getMessage(), [
+ 'filter_date' => $request->get('filter_date'),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ return back()->with('error', 'Gagal mengexport data: ' . $e->getMessage());
+ }
+ }
+
+
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 4551eb4..7468ae2 100755
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -30,7 +30,7 @@ class AppServiceProvider extends ServiceProvider
public function boot()
{
Carbon::setLocale('id');
- View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*', 'warehouse_management.*'], function ($view) {
+ View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*', 'warehouse_management.*', 'reports.*', 'kpi.*'], function ($view) {
$menuQuery = Menu::all();
$menus = [];
foreach($menuQuery as $menu) {
diff --git a/app/Services/StockReportService.php b/app/Services/StockReportService.php
new file mode 100644
index 0000000..cb78785
--- /dev/null
+++ b/app/Services/StockReportService.php
@@ -0,0 +1,178 @@
+get();
+
+ // Get all active products
+ $products = Product::where('active', true)
+ ->with(['category'])
+ ->orderBy('name')
+ ->get();
+
+ $data = [];
+
+ foreach ($products as $product) {
+ $row = [
+ 'product_id' => $product->id,
+ 'product_code' => $product->code,
+ 'product_name' => $product->name,
+ 'category_name' => $product->category ? $product->category->name : '-',
+ 'unit' => $product->unit ?? '-',
+ 'total_stock' => 0
+ ];
+
+ // Calculate stock for each dealer on the target date
+ foreach ($dealers as $dealer) {
+ $stockOnDate = $this->getStockOnDate($product->id, $dealer->id, $targetDate);
+ $row["dealer_{$dealer->id}"] = $stockOnDate;
+ $row['total_stock'] += $stockOnDate;
+ }
+
+ $data[] = $row;
+ }
+
+ return [
+ 'data' => $data,
+ 'dealers' => $dealers
+ ];
+ }
+
+ /**
+ * Get stock quantity for a specific product and dealer on a given date
+ */
+ public function getStockOnDate($productId, $dealerId, $targetDate)
+ {
+ // Get the latest stock log entry before or on the target date
+ $latestStockLog = StockLog::whereHas('stock', function($query) use ($productId, $dealerId) {
+ $query->where('product_id', $productId)
+ ->where('dealer_id', $dealerId);
+ })
+ ->where('created_at', '<=', $targetDate->endOfDay())
+ ->orderBy('created_at', 'desc')
+ ->first();
+
+ if ($latestStockLog) {
+ // Return the new_quantity from the latest log entry
+ return $latestStockLog->new_quantity;
+ }
+
+ // If no stock log found, check if there's a current stock record
+ $currentStock = Stock::where('product_id', $productId)
+ ->where('dealer_id', $dealerId)
+ ->first();
+
+ if ($currentStock) {
+ // Check if the stock was created before or on the target date
+ if ($currentStock->created_at <= $targetDate) {
+ return $currentStock->quantity;
+ }
+ }
+
+ // No stock data available for this date
+ return 0;
+ }
+
+ /**
+ * Get optimized stock data using a single query approach
+ */
+ public function getOptimizedStockReportData($targetDate = null)
+ {
+ $targetDate = $targetDate ? Carbon::parse($targetDate) : now();
+
+ // Get all dealers
+ $dealers = Dealer::orderBy('name')->get();
+
+ // Get all active products with their stock data
+ $products = Product::where('active', true)
+ ->with(['category', 'stocks.dealer'])
+ ->orderBy('name')
+ ->get();
+
+ $data = [];
+
+ foreach ($products as $product) {
+ $row = [
+ 'product_id' => $product->id,
+ 'product_code' => $product->code,
+ 'product_name' => $product->name,
+ 'category_name' => $product->category ? $product->category->name : '-',
+ 'unit' => $product->unit ?? '-',
+ 'total_stock' => 0
+ ];
+
+ // Calculate stock for each dealer on the target date
+ foreach ($dealers as $dealer) {
+ $stockOnDate = $this->getOptimizedStockOnDate($product->id, $dealer->id, $targetDate);
+ $row["dealer_{$dealer->id}"] = $stockOnDate;
+ $row['total_stock'] += $stockOnDate;
+ }
+
+ $data[] = $row;
+ }
+
+ return [
+ 'data' => $data,
+ 'dealers' => $dealers
+ ];
+ }
+
+ /**
+ * Optimized method to get stock on date using subquery
+ */
+ private function getOptimizedStockOnDate($productId, $dealerId, $targetDate)
+ {
+ try {
+ // Use a subquery to get the latest stock log entry efficiently
+ $latestStockLog = DB::table('stock_logs')
+ ->join('stocks', 'stock_logs.stock_id', '=', 'stocks.id')
+ ->where('stocks.product_id', $productId)
+ ->where('stocks.dealer_id', $dealerId)
+ ->where('stock_logs.created_at', '<=', $targetDate->endOfDay())
+ ->orderBy('stock_logs.created_at', 'desc')
+ ->select('stock_logs.new_quantity')
+ ->first();
+
+ if ($latestStockLog) {
+ return $latestStockLog->new_quantity;
+ }
+
+ // If no stock log found, check current stock
+ $currentStock = Stock::where('product_id', $productId)
+ ->where('dealer_id', $dealerId)
+ ->first();
+
+ if ($currentStock && $currentStock->created_at <= $targetDate) {
+ return $currentStock->quantity;
+ }
+
+ return 0;
+ } catch (\Exception $e) {
+ // Log error and return 0
+ Log::error('Error getting stock on date: ' . $e->getMessage(), [
+ 'product_id' => $productId,
+ 'dealer_id' => $dealerId,
+ 'target_date' => $targetDate
+ ]);
+ return 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php
index 500b73b..490b44b 100755
--- a/database/seeders/MenuSeeder.php
+++ b/database/seeders/MenuSeeder.php
@@ -38,6 +38,10 @@ class MenuSeeder extends Seeder
[
'name' => 'Target',
'link' => 'kpi.targets.index'
+ ],
+ [
+ 'name' => 'Stock Produk',
+ 'link' => 'reports.stock-product.index'
]
];
diff --git a/resources/views/kpi/targets/index.blade.php b/resources/views/kpi/targets/index.blade.php
index 84c5bbb..f33e99f 100644
--- a/resources/views/kpi/targets/index.blade.php
+++ b/resources/views/kpi/targets/index.blade.php
@@ -159,10 +159,7 @@ $(document).ready(function() {
// Initialize DataTable
$('#kpiTargetsTable').DataTable({
"pageLength": 25,
- "order": [[0, "asc"]],
- "language": {
- "url": "//cdn.datatables.net/plug-ins/1.10.24/i18n/Indonesian.json"
- }
+ "order": [[0, "asc"]]
});
// Auto hide alerts after 5 seconds
diff --git a/resources/views/layouts/partials/sidebarMenu.blade.php b/resources/views/layouts/partials/sidebarMenu.blade.php
index c30f916..0e7497e 100755
--- a/resources/views/layouts/partials/sidebarMenu.blade.php
+++ b/resources/views/layouts/partials/sidebarMenu.blade.php
@@ -205,9 +205,9 @@
@endcan
- @can('view', $menus['work.index'])
+ @can('view', $menus['reports.stock-product.index'])
| No | +Produk | +Kategori | +Satuan | +Total Stok | +
|---|