fix feature report stock product
This commit is contained in:
316
app/Exports/StockProductsExport.php
Normal file
316
app/Exports/StockProductsExport.php
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithColumnWidths;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class StockProductsExport implements FromCollection, WithHeadings, WithStyles, WithColumnWidths
|
||||||
|
{
|
||||||
|
protected $reportData;
|
||||||
|
|
||||||
|
public function __construct($reportData)
|
||||||
|
{
|
||||||
|
// Validate and sanitize report data
|
||||||
|
if (!is_array($reportData)) {
|
||||||
|
throw new \InvalidArgumentException('Report data must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($reportData['data']) || !isset($reportData['dealers'])) {
|
||||||
|
throw new \InvalidArgumentException('Report data must contain "data" and "dealers" keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure dealers is a collection
|
||||||
|
if (!($reportData['dealers'] instanceof Collection)) {
|
||||||
|
$reportData['dealers'] = collect($reportData['dealers']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data is an array
|
||||||
|
if (!is_array($reportData['data'])) {
|
||||||
|
$reportData['data'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/Http/Controllers/Reports/ReportStockProductsController.php
Normal file
102
app/Http/Controllers/Reports/ReportStockProductsController.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Reports;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\Menu;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Dealer;
|
||||||
|
use App\Models\Stock;
|
||||||
|
use App\Models\StockLog;
|
||||||
|
use App\Services\StockReportService;
|
||||||
|
use App\Exports\StockProductsExport;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Yajra\DataTables\DataTables;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
|
class ReportStockProductsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$menu = Menu::where('link','reports.stock-product.index')->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 "<strong>{$row['product_name']}</strong><br><small class='text-muted'>{$row['product_code']}</small>";
|
||||||
|
})
|
||||||
|
->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
Carbon::setLocale('id');
|
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();
|
$menuQuery = Menu::all();
|
||||||
$menus = [];
|
$menus = [];
|
||||||
foreach($menuQuery as $menu) {
|
foreach($menuQuery as $menu) {
|
||||||
|
|||||||
178
app/Services/StockReportService.php
Normal file
178
app/Services/StockReportService.php
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Dealer;
|
||||||
|
use App\Models\Stock;
|
||||||
|
use App\Models\StockLog;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class StockReportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get stock report data for all products and dealers on a specific date
|
||||||
|
*/
|
||||||
|
public function getStockReportData($targetDate = null)
|
||||||
|
{
|
||||||
|
$targetDate = $targetDate ? Carbon::parse($targetDate) : now();
|
||||||
|
|
||||||
|
// Get all dealers
|
||||||
|
$dealers = Dealer::orderBy('name')->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ class MenuSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'name' => 'Target',
|
'name' => 'Target',
|
||||||
'link' => 'kpi.targets.index'
|
'link' => 'kpi.targets.index'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Stock Produk',
|
||||||
|
'link' => 'reports.stock-product.index'
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ $(document).ready(function() {
|
|||||||
// Initialize DataTable
|
// Initialize DataTable
|
||||||
$('#kpiTargetsTable').DataTable({
|
$('#kpiTargetsTable').DataTable({
|
||||||
"pageLength": 25,
|
"pageLength": 25,
|
||||||
"order": [[0, "asc"]],
|
"order": [[0, "asc"]]
|
||||||
"language": {
|
|
||||||
"url": "//cdn.datatables.net/plug-ins/1.10.24/i18n/Indonesian.json"
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto hide alerts after 5 seconds
|
// Auto hide alerts after 5 seconds
|
||||||
|
|||||||
@@ -205,9 +205,9 @@
|
|||||||
</li>
|
</li>
|
||||||
@endcan
|
@endcan
|
||||||
|
|
||||||
@can('view', $menus['work.index'])
|
@can('view', $menus['reports.stock-product.index'])
|
||||||
<li class="kt-menu__item" aria-haspopup="true">
|
<li class="kt-menu__item" aria-haspopup="true">
|
||||||
<a href="{{ route('work.index') }}" class="kt-menu__link">
|
<a href="{{ route('reports.stock-product.index') }}" class="kt-menu__link">
|
||||||
<i class="fa fa-cubes" style="display: flex; align-items: center; margin-right: 10px;"></i>
|
<i class="fa fa-cubes" style="display: flex; align-items: center; margin-right: 10px;"></i>
|
||||||
<span class="kt-menu__link-text">Stok Produk</span>
|
<span class="kt-menu__link-text">Stok Produk</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
576
resources/views/reports/stock-products.blade.php
Normal file
576
resources/views/reports/stock-products.blade.php
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
@extends('layouts.backapp')
|
||||||
|
|
||||||
|
@section('styles')
|
||||||
|
<style>
|
||||||
|
/* Loading overlay */
|
||||||
|
#loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-overlay .spinner-border {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button loading state */
|
||||||
|
.btn.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styling */
|
||||||
|
.table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dealer column styling */
|
||||||
|
.dealer-column {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter section styling */
|
||||||
|
.form-label.font-weight-bold {
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-text {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #ced4da;
|
||||||
|
color: #6c757d;
|
||||||
|
border-right: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .form-control {
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 1px solid #ced4da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .form-control:focus {
|
||||||
|
border-color: #80bdff;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .form-control:focus + .input-group-append .input-group-text {
|
||||||
|
border-color: #80bdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .form-control:focus ~ .input-group-prepend .input-group-text {
|
||||||
|
border-color: #80bdff;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group:hover .form-control {
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group:hover .input-group-text {
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datepicker styling */
|
||||||
|
.datepicker {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker:focus {
|
||||||
|
border-color: #80bdff;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker::placeholder {
|
||||||
|
color: #6c757d;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker[readonly] {
|
||||||
|
background-color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker[readonly]:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styling */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #007bff;
|
||||||
|
border-color: #007bff;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
border-color: #0056b3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:focus {
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.row.mb-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-2 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
height: 42px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label.font-weight-bold {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.row.mb-4 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-prepend {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .form-control {
|
||||||
|
border-left: 1px solid #ced4da;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="kt-content" id="kt_content">
|
||||||
|
<div class="kt-portlet kt-portlet--mobile" id="kt_blockui_datatable">
|
||||||
|
<div class="kt-portlet__head kt-portlet__head--lg">
|
||||||
|
<div class="kt-portlet__head-label">
|
||||||
|
<h3 class="kt-portlet__head-title">
|
||||||
|
Laporan Stok Produk
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="kt-portlet__head-toolbar">
|
||||||
|
<div class="kt-portlet__head-actions">
|
||||||
|
<button type="button" class="btn btn-bold btn-success btn--sm" id="btn-export-excel">
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kt-portlet__body">
|
||||||
|
<!-- Filter Section -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-group mb-0">
|
||||||
|
<label class="form-label font-weight-bold">Filter Tanggal</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fa fa-calendar"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" class="form-control datepicker" id="filter-date" placeholder="Pilih tanggal" value="{{ date('Y-m-d') }}" autocomplete="off" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="form-group mb-0">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<button type="button" class="btn btn-primary btn-block btn-md" id="btn-filter">
|
||||||
|
Cari
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Table Section --}}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-bordered" id="report-stock-products-table">
|
||||||
|
<thead id="table-header">
|
||||||
|
<tr>
|
||||||
|
<th>No</th>
|
||||||
|
<th>Produk</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Satuan</th>
|
||||||
|
<th>Total Stok</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('javascripts')
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
let dealers = [];
|
||||||
|
let dataTable;
|
||||||
|
|
||||||
|
// Loading overlay functions
|
||||||
|
var showLoadingOverlay = function (message) {
|
||||||
|
if ($("#loading-overlay").length === 0) {
|
||||||
|
$("body").append(`
|
||||||
|
<div id="loading-overlay" style="
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
">
|
||||||
|
<div class="spinner-border text-light" role="status" style="width: 3rem; height: 3rem;">
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-light mt-3" style="font-size: 1.1rem; font-weight: 500;">${message}</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
$("#loading-overlay").show();
|
||||||
|
$("#loading-overlay .text-light:last").text(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var hideLoadingOverlay = function () {
|
||||||
|
$("#loading-overlay").hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize datepicker
|
||||||
|
$('#filter-date').datepicker({
|
||||||
|
format: 'yyyy-mm-dd',
|
||||||
|
autoclose: true,
|
||||||
|
todayHighlight: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show initial loading
|
||||||
|
showLoadingOverlay("Memuat data dealer...");
|
||||||
|
|
||||||
|
// Load dealers first
|
||||||
|
loadDealers();
|
||||||
|
|
||||||
|
function updateTableHeader() {
|
||||||
|
let headerRow = $('#table-header tr');
|
||||||
|
// Clear existing dealer columns (keep first 4 columns + total)
|
||||||
|
headerRow.find('th:gt(3):not(:last)').remove();
|
||||||
|
|
||||||
|
// Add dealer columns
|
||||||
|
dealers.forEach(function(dealer) {
|
||||||
|
headerRow.find('th:last').before('<th class="text-center" style="min-width: 120px; background-color: #f8f9fa;">' + dealer.name + '</th>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update dealer info display
|
||||||
|
if (dealers.length > 0) {
|
||||||
|
$('#dealer-names').text(dealers.map(d => d.name).join(', '));
|
||||||
|
$('#dealer-info').show();
|
||||||
|
} else {
|
||||||
|
$('#dealer-info').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDealers() {
|
||||||
|
$.ajax({
|
||||||
|
url: '{{ route("reports.stock-product.dealers") }}',
|
||||||
|
type: 'GET',
|
||||||
|
success: function(response) {
|
||||||
|
dealers = response || [];
|
||||||
|
showLoadingOverlay("Memuat data produk...");
|
||||||
|
initializeDataTable();
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error('Error loading dealers:', error);
|
||||||
|
dealers = [];
|
||||||
|
hideLoadingOverlay();
|
||||||
|
toastr.error('Gagal memuat data dealer. Silakan refresh halaman.');
|
||||||
|
// Still try to initialize with empty dealers
|
||||||
|
showLoadingOverlay("Memuat data produk...");
|
||||||
|
initializeDataTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeDataTable() {
|
||||||
|
// Destroy existing datatable if exists
|
||||||
|
if (dataTable) {
|
||||||
|
dataTable.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update table header with dealer columns
|
||||||
|
updateTableHeader();
|
||||||
|
|
||||||
|
// Build dealer columns dynamically
|
||||||
|
let dealerColumns = dealers.map(function(dealer) {
|
||||||
|
return {
|
||||||
|
data: `dealer_${dealer.id}`,
|
||||||
|
name: `dealer_${dealer.id}`,
|
||||||
|
title: dealer.name,
|
||||||
|
orderable: false,
|
||||||
|
searchable: false,
|
||||||
|
className: 'text-right',
|
||||||
|
render: function(data, type, row) {
|
||||||
|
if (type === 'display') {
|
||||||
|
return data ? number_format(data, 2) : '-';
|
||||||
|
}
|
||||||
|
return data || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define all columns
|
||||||
|
let columns = [
|
||||||
|
{
|
||||||
|
data: 'DT_RowIndex',
|
||||||
|
name: 'DT_RowIndex',
|
||||||
|
orderable: false,
|
||||||
|
searchable: false,
|
||||||
|
width: '50px',
|
||||||
|
className: 'text-center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'product_info',
|
||||||
|
name: 'product_name',
|
||||||
|
orderable: true,
|
||||||
|
searchable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'category_name',
|
||||||
|
name: 'category_name',
|
||||||
|
orderable: true,
|
||||||
|
searchable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: 'unit',
|
||||||
|
name: 'unit',
|
||||||
|
orderable: true,
|
||||||
|
searchable: false,
|
||||||
|
width: '80px',
|
||||||
|
className: 'text-center'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add dealer columns
|
||||||
|
columns = columns.concat(dealerColumns);
|
||||||
|
|
||||||
|
// Add total stock column
|
||||||
|
columns.push({
|
||||||
|
data: 'total_stock',
|
||||||
|
name: 'total_stock',
|
||||||
|
orderable: true,
|
||||||
|
searchable: false,
|
||||||
|
className: 'text-right font-weight-bold',
|
||||||
|
render: function(data, type, row) {
|
||||||
|
if (type === 'display') {
|
||||||
|
return number_format(data, 2);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize DataTable
|
||||||
|
dataTable = $('#report-stock-products-table').DataTable({
|
||||||
|
destroy: true,
|
||||||
|
processing: true,
|
||||||
|
serverSide: false, // We'll handle data loading manually
|
||||||
|
ajax: {
|
||||||
|
url: '{{ route("reports.stock-product.data") }}',
|
||||||
|
type: 'GET',
|
||||||
|
data: function(d) {
|
||||||
|
d.filter_date = $('#filter-date').val();
|
||||||
|
},
|
||||||
|
beforeSend: function() {
|
||||||
|
showLoadingOverlay("Memuat data stok produk...");
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
hideLoadingOverlay();
|
||||||
|
},
|
||||||
|
error: function(xhr, error, thrown) {
|
||||||
|
hideLoadingOverlay();
|
||||||
|
console.error('DataTable AJAX Error:', error, thrown);
|
||||||
|
toastr.error('Gagal memuat data. Silakan coba lagi.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
columns: columns,
|
||||||
|
order: [[1, 'asc']], // Sort by product name
|
||||||
|
pageLength: 25,
|
||||||
|
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
|
||||||
|
'<"row"<"col-sm-12"tr>>' +
|
||||||
|
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
|
||||||
|
initComplete: function() {
|
||||||
|
// DataTable initialization complete
|
||||||
|
hideLoadingOverlay();
|
||||||
|
|
||||||
|
// Check if data is empty
|
||||||
|
if (dataTable.data().length === 0) {
|
||||||
|
toastr.warning('Tidak ada data stok produk yang ditemukan untuk tanggal yang dipilih.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter button click
|
||||||
|
$('#btn-filter').click(function() {
|
||||||
|
if (dataTable) {
|
||||||
|
// Prevent multiple clicks
|
||||||
|
var filterButton = $('#btn-filter');
|
||||||
|
if (filterButton.hasClass('loading')) {
|
||||||
|
return; // Already processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
filterButton.addClass('loading').prop('disabled', true);
|
||||||
|
var originalHtml = filterButton.html();
|
||||||
|
filterButton.html('<i class="fa fa-spinner fa-spin"></i> Mencari...');
|
||||||
|
|
||||||
|
// Show loading overlay
|
||||||
|
showLoadingOverlay("Memuat data stok produk...");
|
||||||
|
|
||||||
|
// Reload data
|
||||||
|
dataTable.ajax.reload(function() {
|
||||||
|
// Reset button state
|
||||||
|
filterButton.removeClass('loading').prop('disabled', false).html(originalHtml);
|
||||||
|
hideLoadingOverlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export button click
|
||||||
|
$('#btn-export-excel').click(function() {
|
||||||
|
exportToExcel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter key on date input
|
||||||
|
$('#filter-date').keypress(function(e) {
|
||||||
|
if (e.which == 13) { // Enter key
|
||||||
|
$('#btn-filter').click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Number format helper
|
||||||
|
function number_format(number, decimals) {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals
|
||||||
|
}).format(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export to Excel function
|
||||||
|
function exportToExcel() {
|
||||||
|
// Prevent multiple clicks
|
||||||
|
var exportButton = $('#btn-export-excel');
|
||||||
|
if (exportButton.hasClass('loading')) {
|
||||||
|
return; // Already processing
|
||||||
|
}
|
||||||
|
|
||||||
|
let filterDate = $('#filter-date').val();
|
||||||
|
let url = '{{ route("reports.stock-product.export") }}';
|
||||||
|
if (filterDate) {
|
||||||
|
url += '?filter_date=' + filterDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
exportButton.addClass('loading').prop('disabled', true);
|
||||||
|
var originalHtml = exportButton.html();
|
||||||
|
exportButton.html('<i class="fa fa-spinner fa-spin"></i> Exporting...');
|
||||||
|
|
||||||
|
// Show loading overlay
|
||||||
|
showLoadingOverlay("Menyiapkan file Excel...");
|
||||||
|
|
||||||
|
// Open in new window
|
||||||
|
let newWindow = window.open(url, '_blank');
|
||||||
|
|
||||||
|
// Reset button and hide overlay after a delay
|
||||||
|
setTimeout(function() {
|
||||||
|
exportButton.removeClass('loading').prop('disabled', false).html(originalHtml);
|
||||||
|
hideLoadingOverlay();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make exportToExcel available globally
|
||||||
|
window.exportToExcel = exportToExcel;
|
||||||
|
|
||||||
|
// Cleanup loading overlay on page unload
|
||||||
|
$(window).on('beforeunload', function() {
|
||||||
|
hideLoadingOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback for toastr if not available
|
||||||
|
if (typeof toastr === 'undefined') {
|
||||||
|
window.toastr = {
|
||||||
|
success: function(msg) { console.log('SUCCESS:', msg); },
|
||||||
|
error: function(msg) { console.log('ERROR:', msg); },
|
||||||
|
warning: function(msg) { console.log('WARNING:', msg); },
|
||||||
|
info: function(msg) { console.log('INFO:', msg); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@@ -85,7 +85,7 @@ table.dataTable thead th.sorting:hover:before {
|
|||||||
<div class="kt-portlet__head-wrapper">
|
<div class="kt-portlet__head-wrapper">
|
||||||
<div class="kt-portlet__head-actions">
|
<div class="kt-portlet__head-actions">
|
||||||
<a href="{{ route('products.export.dealers_stock') }}" class="btn btn-bold btn-success btn--sm" style="margin-right: 8px;">
|
<a href="{{ route('products.export.dealers_stock') }}" class="btn btn-bold btn-success btn--sm" style="margin-right: 8px;">
|
||||||
<i class="flaticon2-download"></i>Export Stok Dealer
|
Export
|
||||||
</a>
|
</a>
|
||||||
@can('create', $menus['products.index'])
|
@can('create', $menus['products.index'])
|
||||||
<a href="{{ route('products.create') }}" class="btn btn-bold btn-label-brand btn--sm">Tambah</a>
|
<a href="{{ route('products.create') }}" class="btn btn-bold btn-label-brand btn--sm">Tambah</a>
|
||||||
|
|||||||
@@ -27,14 +27,13 @@ input.datepicker {
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="kt-portlet kt-portlet--mobile" id="kt_blockui_datatable">
|
<div class="kt-portlet kt-portlet--mobile" id="kt_blockui_datatable">
|
||||||
<div class="kt-portlet__head kt-portlet__head--lg">
|
<div class="kt-portlet__head kt-portlet__head--lg">
|
||||||
<div class="kt-portlet__head-label">
|
<div class="kt-portlet__head-label">
|
||||||
|
<h3 class="kt-portlet__head-title">
|
||||||
<h3 class="kt-portlet__head-title">
|
Histori Stock
|
||||||
Histori Stock
|
</h3>
|
||||||
</h3>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kt-portlet__body">
|
<div class="kt-portlet__body">
|
||||||
<!-- Filter Section -->
|
<!-- Filter Section -->
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Http\Controllers\WorkDealerPriceController;
|
|||||||
use App\Http\Controllers\WarehouseManagement\MutationsController;
|
use App\Http\Controllers\WarehouseManagement\MutationsController;
|
||||||
use App\Http\Controllers\WarehouseManagement\StockAuditController;
|
use App\Http\Controllers\WarehouseManagement\StockAuditController;
|
||||||
use App\Http\Controllers\KPI\TargetsController;
|
use App\Http\Controllers\KPI\TargetsController;
|
||||||
|
use App\Http\Controllers\Reports\ReportStockProductsController;
|
||||||
use App\Models\Menu;
|
use App\Models\Menu;
|
||||||
use App\Models\Privilege;
|
use App\Models\Privilege;
|
||||||
use App\Models\Role;
|
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::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::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();
|
Auth::routes();
|
||||||
|
|||||||
Reference in New Issue
Block a user