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'])
  • - + Stok Produk diff --git a/resources/views/reports/stock-products.blade.php b/resources/views/reports/stock-products.blade.php new file mode 100644 index 0000000..fb6494b --- /dev/null +++ b/resources/views/reports/stock-products.blade.php @@ -0,0 +1,576 @@ +@extends('layouts.backapp') + +@section('styles') + +@endsection + +@section('content') +
    +
    +
    +
    +

    + Laporan Stok Produk +

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + + + +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    + + {{-- Table Section --}} +
    + + + + + + + + + + + + +
    NoProdukKategoriSatuanTotal Stok
    +
    +
    +
    +
    +@endsection + +@section('javascripts') + +@endsection \ No newline at end of file diff --git a/resources/views/warehouse_management/products/index.blade.php b/resources/views/warehouse_management/products/index.blade.php index 582dd9c..f6eeadc 100755 --- a/resources/views/warehouse_management/products/index.blade.php +++ b/resources/views/warehouse_management/products/index.blade.php @@ -85,7 +85,7 @@ table.dataTable thead th.sorting:hover:before {
    - Export Stok Dealer + Export @can('create', $menus['products.index']) Tambah diff --git a/resources/views/warehouse_management/stock_audit/index.blade.php b/resources/views/warehouse_management/stock_audit/index.blade.php index be16423..67e542d 100644 --- a/resources/views/warehouse_management/stock_audit/index.blade.php +++ b/resources/views/warehouse_management/stock_audit/index.blade.php @@ -27,14 +27,13 @@ input.datepicker { @section('content')
    -
    -
    - -

    - Histori Stock -

    -
    -
    +
    +
    +

    + Histori Stock +

    +
    +
    diff --git a/routes/web.php b/routes/web.php index a7af76b..8d65b84 100755 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\WorkDealerPriceController; use App\Http\Controllers\WarehouseManagement\MutationsController; use App\Http\Controllers\WarehouseManagement\StockAuditController; use App\Http\Controllers\KPI\TargetsController; +use App\Http\Controllers\Reports\ReportStockProductsController; use App\Models\Menu; use App\Models\Privilege; use App\Models\Role; @@ -316,6 +317,13 @@ Route::group(['middleware' => 'auth'], function() { Route::post('/targets/{target}/toggle-status', [TargetsController::class, 'toggleStatus'])->name('kpi.targets.toggle-status'); Route::get('/targets/user/{user}', [TargetsController::class, 'getUserTargets'])->name('kpi.targets.user'); }); + + Route::prefix('reports')->group(function () { + Route::get('stock-products', [ReportStockProductsController::class, 'index'])->name('reports.stock-product.index'); + Route::get('stock-products/data', [ReportStockProductsController::class, 'getData'])->name('reports.stock-product.data'); + Route::get('stock-products/dealers', [ReportStockProductsController::class, 'getDealers'])->name('reports.stock-product.dealers'); + Route::get('stock-products/export', [ReportStockProductsController::class, 'export'])->name('reports.stock-product.export'); + }); }); Auth::routes();