14 Commits

66 changed files with 13505 additions and 709 deletions

View 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;
}
}

View File

@@ -0,0 +1,435 @@
<?php
namespace App\Exports;
use App\Services\TechnicianReportService;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Events\AfterSheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Border;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class TechnicianReportExport implements FromCollection, WithHeadings, WithStyles, WithColumnWidths, WithEvents
{
protected $dealerId;
protected $startDate;
protected $endDate;
protected $technicianReportService;
protected $mechanics;
protected $headings;
protected $filterInfo;
public function __construct($dealerId = null, $startDate = null, $endDate = null)
{
$this->dealerId = $dealerId;
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->technicianReportService = new TechnicianReportService();
// Get mechanics and prepare headings
$this->prepareHeadings();
$this->prepareFilterInfo();
}
private function prepareHeadings()
{
try {
$reportData = $this->technicianReportService->getTechnicianReportData(
$this->dealerId,
$this->startDate,
$this->endDate
);
$this->mechanics = $reportData['mechanics'];
// Build headings - simplified structure
$this->headings = [
'No',
'Nama Pekerjaan',
'Kode Pekerjaan',
'Kategori'
];
// Add mechanic columns (only total, no completed/pending)
foreach ($this->mechanics as $mechanic) {
$mechanicName = $this->cleanName($mechanic->name);
$this->headings[] = $mechanicName;
}
// Add total column at the end
$this->headings[] = 'Total';
} catch (\Exception $e) {
Log::error('Error preparing headings: ' . $e->getMessage());
$this->headings = ['Error preparing data'];
$this->mechanics = collect();
}
}
private function prepareFilterInfo()
{
$this->filterInfo = [];
// Dealer filter
if ($this->dealerId) {
$dealer = \App\Models\Dealer::find($this->dealerId);
$dealerName = $dealer ? $dealer->name : 'Unknown Dealer';
$this->filterInfo[] = "Dealer: {$dealerName}";
} else {
// Check user access for "Semua Dealer"
$user = auth()->user();
if ($user && $user->role_id) {
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if ($role) {
$technicianReportService = new \App\Services\TechnicianReportService();
if ($technicianReportService->isAdminRole($role)) {
$this->filterInfo[] = "Dealer: Semua Dealer (Admin)";
} else if ($role->dealers->count() > 0) {
$dealerNames = $role->dealers->pluck('name')->implode(', ');
$this->filterInfo[] = "Dealer: Semua Dealer (Pivot: {$dealerNames})";
} else {
$this->filterInfo[] = "Dealer: Semua Dealer";
}
} else {
$this->filterInfo[] = "Dealer: Semua Dealer";
}
} else {
$this->filterInfo[] = "Dealer: Semua Dealer";
}
}
// Date range filter
if ($this->startDate && $this->endDate) {
$startDateFormatted = Carbon::parse($this->startDate)->format('d/m/Y');
$endDateFormatted = Carbon::parse($this->endDate)->format('d/m/Y');
$this->filterInfo[] = "Periode: {$startDateFormatted} - {$endDateFormatted}";
} elseif ($this->startDate) {
$startDateFormatted = Carbon::parse($this->startDate)->format('d/m/Y');
$this->filterInfo[] = "Tanggal Mulai: {$startDateFormatted}";
} elseif ($this->endDate) {
$endDateFormatted = Carbon::parse($this->endDate)->format('d/m/Y');
$this->filterInfo[] = "Tanggal Akhir: {$endDateFormatted}";
} else {
$this->filterInfo[] = "Periode: Semua Periode";
}
// Export date
$exportDate = Carbon::now()->format('d/m/Y H:i:s');
$this->filterInfo[] = "Tanggal Export: {$exportDate}";
}
/**
* Clean name for Excel compatibility
*/
private function cleanName($name)
{
// Remove special characters and limit length
$cleaned = preg_replace('/[^a-zA-Z0-9\s]/', '', $name);
$cleaned = trim($cleaned);
// Limit to 31 characters (Excel sheet name limit)
if (strlen($cleaned) > 31) {
$cleaned = substr($cleaned, 0, 31);
}
return $cleaned ?: 'Unknown';
}
public function collection()
{
try {
$reportData = $this->technicianReportService->getTechnicianReportData(
$this->dealerId,
$this->startDate,
$this->endDate
);
$data = [];
$no = 1;
$columnTotals = [];
foreach ($this->mechanics as $mechanic) {
$columnTotals["mechanic_{$mechanic->id}_total"] = 0;
}
$columnTotals['row_total'] = 0;
foreach ($reportData['data'] as $row) {
$rowTotal = 0;
$exportRow = [
$no++,
$row['work_name'],
$row['work_code'],
$row['category_name']
];
foreach ($this->mechanics as $mechanic) {
$mechanicTotal = $row["mechanic_{$mechanic->id}_total"] ?? 0;
$exportRow[] = $mechanicTotal;
$rowTotal += $mechanicTotal;
$columnTotals["mechanic_{$mechanic->id}_total"] += $mechanicTotal;
}
$exportRow[] = $rowTotal;
$columnTotals['row_total'] += $rowTotal;
$data[] = $exportRow;
}
// Add total row
$totalRow = ['', 'TOTAL', '', ''];
foreach ($this->mechanics as $mechanic) {
$totalRow[] = $columnTotals["mechanic_{$mechanic->id}_total"];
}
$totalRow[] = $columnTotals['row_total'];
$data[] = $totalRow;
return collect($data);
} catch (\Exception $e) {
Log::error('Error in collection: ' . $e->getMessage());
return collect([['Error loading data']]);
}
}
public function headings(): array
{
return $this->headings;
}
public function styles(Worksheet $sheet)
{
try {
$lastColumn = $sheet->getHighestColumn();
$lastRow = $sheet->getHighestRow();
// Calculate positions
$titleRow = 1;
$headerRow = 1; // Headers are now in row 2
$dataStartRow = 2; // Data starts in row 3
// Calculate total row position (after data)
$dataRows = count($this->technicianReportService->getTechnicianReportData($this->dealerId, $this->startDate, $this->endDate)['data']);
$totalRow = $dataStartRow + $dataRows;
$filterStartRow = $totalRow + 2; // After total row + empty row
// Style the title row (row 1)
$sheet->getStyle('A' . $titleRow . ':' . $lastColumn . $titleRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 16,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Header styling (row 2)
$sheet->getStyle('A' . $headerRow . ':' . $lastColumn . $headerRow)->applyFromArray([
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
'size' => 10,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '2E5BBA'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
// Data styling (starting from row 3)
if ($lastRow > $headerRow) {
$dataEndRow = $totalRow;
$sheet->getStyle('A' . $dataStartRow . ':' . $lastColumn . $dataEndRow)->applyFromArray([
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Left align text columns
$sheet->getStyle('B' . $dataStartRow . ':D' . $dataEndRow)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
// Style the total row
$sheet->getStyle('A' . $totalRow . ':' . $lastColumn . $totalRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 11,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F2F2F2'],
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
}
// Style the export information section
if ($filterStartRow <= $lastRow) {
$exportInfoRow = $totalRow + 2; // After total row + empty row
$filterEndRow = $lastRow;
// Style the "INFORMASI EXPORT" title
$sheet->getStyle('A' . $exportInfoRow . ':' . $lastColumn . $exportInfoRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 12,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E6E6E6'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Style the filter info rows
$filterInfoStartRow = $exportInfoRow + 3; // After title + empty + "Filter yang Digunakan:"
$sheet->getStyle('A' . $filterInfoStartRow . ':' . $lastColumn . $filterEndRow)->applyFromArray([
'font' => [
'size' => 10,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_TOP,
],
]);
}
// Auto-size columns
foreach (range('A', $lastColumn) as $column) {
if ($column === 'A') {
// Set specific width for column A (No) - don't auto-size
$sheet->getColumnDimension($column)->setWidth(5);
} else {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
} catch (\Exception $e) {
Log::error('Error applying styles: ' . $e->getMessage());
}
}
public function columnWidths(): array
{
$widths = [
'A' => 5, // No - reduced from 8 to 5
'B' => 30, // Nama Pekerjaan
'C' => 15, // Kode Pekerjaan
'D' => 20, // Kategori
];
// Add widths for mechanic columns
$currentColumn = 'E';
foreach ($this->mechanics as $mechanic) {
$widths[$currentColumn++] = 15; // Mechanic total
}
// Add width for total column
$widths[$currentColumn] = 15; // Total
return $widths;
}
public function registerEvents(): array
{
return [
AfterSheet::class => function(AfterSheet $event) {
$sheet = $event->sheet->getDelegate();
$highestColumn = $sheet->getHighestColumn();
$highestRow = $sheet->getHighestRow();
// Header styling ONLY for row 1
$sheet->getStyle('A1:' . $highestColumn . '1')->applyFromArray([
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
'size' => 10,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '2E5BBA'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
// Total row styling (only last row)
$sheet->getStyle('A' . $highestRow . ':' . $highestColumn . $highestRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 11,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F2F2F2'],
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
// Export info below table
$infoStartRow = $highestRow + 2;
$sheet->setCellValue('A' . $infoStartRow, 'INFORMASI EXPORT');
$sheet->getStyle('A' . $infoStartRow . ':' . $highestColumn . $infoStartRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 12,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E6E6E6'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
$sheet->setCellValue('A' . ($infoStartRow + 2), 'Filter yang Digunakan:');
$row = $infoStartRow + 3;
foreach ($this->filterInfo as $info) {
$sheet->setCellValue('A' . $row, $info);
$row++;
}
$sheet->getStyle('A' . ($infoStartRow + 2) . ':A' . ($row-1))->applyFromArray([
'font' => [ 'size' => 10 ],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_TOP,
],
]);
}
];
}
}

View File

@@ -4,10 +4,12 @@ namespace App\Http\Controllers;
use App\Models\Dealer;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Transaction;
use App\Models\User;
use App\Models\Work;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
@@ -37,7 +39,21 @@ class AdminController extends Controller
$month = $request->month;
$dealer = $request->dealer;
$year = $request->year;
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
$ajax_url = route('dashboard_data').'?month='.$month.'&year='.$year.'&dealer='.$dealer;
// dd($ajax_url);
return view('dashboard', compact('month','year', 'ajax_url', 'dealer', 'dealer_datas'));
@@ -72,16 +88,47 @@ class AdminController extends Controller
$dealer_work_trx = DB::statement("SET @sql = NULL");
$sql = "SELECT IF(work_id IS NOT NULL, GROUP_CONCAT(DISTINCT CONCAT('SUM(IF(work_id = \"', work_id,'\", qty,\"\")) AS \"',CONCAT(w.name, '|',w.id),'\"')), 's.work_id') INTO @sql FROM transactions t JOIN works w ON w.id = t.work_id WHERE month(t.date) = '". $month ."' and year(t.date) = '". $year ."' and t.deleted_at is null";
if(isset($request->dealer) && $request->dealer != 'all') {
$sql .= " and t.dealer_id = '". $dealer ."'";
}
$dealer_work_trx = DB::statement($sql);
// Get dealers based on user role - only change this part
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Validate that the requested dealer is allowed for this user
if(isset($request->dealer) && $request->dealer != 'all') {
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))");
}else{
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))");
if($dealer_datas->count() > 0) {
$allowedDealerIds = $dealer_datas->pluck('id')->toArray();
if(!in_array($dealer, $allowedDealerIds)) {
// If dealer is not allowed, reset to 'all'
$dealer = 'all';
}
} else {
// If no dealers are allowed, reset to 'all'
$dealer = 'all';
}
}
// Build dealer filter based on user role
$dealerFilter = '';
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$dealerFilter = " and s.dealer_id IN (" . implode(',', $dealerIds) . ")";
}
if(isset($request->dealer) && $request->dealer != 'all') {
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."'". $dealerFilter ." GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."'". $dealerFilter ." GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))");
} else {
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))");
}
$dealer_work_trx = DB::statement("PREPARE stmt FROM @sql");
@@ -128,10 +175,12 @@ class AdminController extends Controller
$prev_mth_start = date('Y-m-d', strtotime(date($year.'-'. $request->month .'-1')." -1 month"));
$prev_mth = explode('-', $prev_mth_start);
if($request->month == date('m')) {
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d');
if($request->month == date('m') && $year == date('Y')) {
// Jika bulan sekarang, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}else{
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t');
// Jika bulan lain, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}
$prev_month_trx = [];
@@ -143,6 +192,11 @@ class AdminController extends Controller
if(isset($request->dealer) && $request->dealer != 'all') {
$prev_month = $prev_month->where('dealer_id', $request->dealer);
$now_month = $now_month->where('dealer_id', $request->dealer);
} else if($dealer_datas->count() > 0) {
// Filter by allowed dealers based on user role
$dealerIds = $dealer_datas->pluck('id')->toArray();
$prev_month = $prev_month->whereIn('dealer_id', $dealerIds);
$now_month = $now_month->whereIn('dealer_id', $dealerIds);
}
$prev_month_trx[] = $prev_month->sum('qty');
@@ -160,6 +214,36 @@ class AdminController extends Controller
return view('dashboard_data', compact('theads', 'work_trx', 'month', 'year', 'dealer_names', 'dealer_trx', 'dealer', 'totals'));
}
/**
* Check if role is admin type
*/
private function isAdminRole($role)
{
if (!$role) {
return false;
}
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
return true;
}
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
return false;
}
return false;
}
public function dealer_work_trx(Request $request) {
$dealer_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request) {
if(isset($request->month)) {
@@ -227,10 +311,12 @@ class AdminController extends Controller
foreach($works as $work1) {
$prev_mth_start = date('Y-m-d', strtotime(date('Y-'. $request->month .'-1')." -1 month"));
$prev_mth = explode('-', $prev_mth_start);
if($request->month == date('m')) {
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d');
if($request->month == date('m') && date('Y') == date('Y')) {
// Jika bulan sekarang, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}else{
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t');
// Jika bulan lain, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}
// dd($prev_mth_end);
@@ -348,10 +434,12 @@ class AdminController extends Controller
foreach($works as $work1) {
$prev_mth_start = date('Y-m-d', strtotime(date($request->year.'-'. $request->month .'-1')." -1 month"));
$prev_mth = explode('-', $prev_mth_start);
if($request->month == date('m')) {
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d');
if($request->month == date('m') && $request->year == date('Y')) {
// Jika bulan sekarang, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}else{
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t');
// Jika bulan lain, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}
$yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');

View File

@@ -287,7 +287,11 @@ class ApiController extends Controller
public function logout()
{
Auth::user()->tokens()->delete();
/** @var \App\Models\User $user */
$user = auth('sanctum')->user();
if ($user) {
$user->tokens()->delete();
}
return response()->json([
'message' => 'Logout success',
'status' => true,

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Privilege;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
@@ -50,11 +51,39 @@ class LoginController extends Controller
*/
protected function authenticated(Request $request, $user)
{
$user = Privilege::where('menu_id', 10)->where('role_id', Auth::user()->role_id)->where('view', 1)->first();
// Get user's role_id
$roleId = Auth::user()->role_id;
if ($user != null) {
return redirect()->route('dashboard');
}else{
if (!$roleId) {
// User has no role, redirect to default
return redirect(RouteServiceProvider::HOME);
}
// Check if user has access to adminarea menu
if (!User::roleCanAccessMenu($roleId, 'adminarea')) {
// User doesn't have admin area access, redirect to default home
return redirect(RouteServiceProvider::HOME);
}
// User has admin area access, get first accessible menu (excluding adminarea and mechanicarea)
$firstMenu = Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $roleId)
->where('privileges.view', 1)
->whereNotIn('menus.link', ['adminarea', 'mechanicarea'])
->select('menus.*', 'privileges.view', 'privileges.create', 'privileges.update', 'privileges.delete')
->orderBy('menus.id')
->first();
if (!$firstMenu) {
// User has no accessible menus (excluding adminarea/mechanicarea), redirect to default
return redirect(RouteServiceProvider::HOME);
}
try {
// Try to redirect to the first accessible menu
return redirect()->route($firstMenu->link);
} catch (\Exception $e) {
// Route doesn't exist, fallback to default home
return redirect(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\KPI;
use App\Http\Controllers\Controller;
use App\Http\Requests\KPI\StoreKpiTargetRequest;
use App\Http\Requests\KPI\UpdateKpiTargetRequest;
use App\Models\KpiTarget;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
class TargetsController extends Controller
{
/**
* Display a listing of KPI targets
*/
public function index()
{
$targets = KpiTarget::with(['user', 'user.role'])
->orderBy('created_at', 'desc')
->paginate(15);
// Get mechanics using role_id 3 (mechanic) with dealer relationship
$mechanics = User::with('dealer')
->where('role_id', 3)
->orderBy('name', 'asc')
->limit(50)
->get();
// If no mechanics found, get all users as fallback
if ($mechanics->isEmpty()) {
$mechanics = User::with('dealer')
->orderBy('name', 'asc')
->limit(50)
->get();
}
return view('kpi.targets.index', compact('targets', 'mechanics'));
}
/**
* Show the form for creating a new KPI target
*/
public function create()
{
// Get mechanics using role_id 3 (mechanic) with dealer relationship
$mechanics = User::with('dealer')
->where('role_id', 3)
->orderBy('name', 'asc')
->limit(50)
->get();
// Debug: Log the mechanics found
Log::info('Mechanics found for KPI target creation:', [
'count' => $mechanics->count(),
'mechanics' => $mechanics->pluck('name', 'id')->toArray()
]);
// If no mechanics found, get all users as fallback
if ($mechanics->isEmpty()) {
$mechanics = User::with('dealer')
->orderBy('name', 'asc')
->limit(50)
->get();
Log::warning('No mechanics found, using all users as fallback', [
'count' => $mechanics->count()
]);
}
return view('kpi.targets.create', compact('mechanics'));
}
/**
* Store a newly created KPI target
*/
public function store(StoreKpiTargetRequest $request)
{
try {
// Log the validated data
Log::info('Creating KPI target with data:', $request->validated());
// Check if user already has an active target and deactivate it
$existingTarget = KpiTarget::where('user_id', $request->user_id)
->where('is_active', true)
->first();
if ($existingTarget) {
Log::info('Deactivating existing active KPI target', [
'user_id' => $request->user_id,
'existing_target_id' => $existingTarget->id
]);
// Deactivate the existing target
$existingTarget->update(['is_active' => false]);
}
$target = KpiTarget::create($request->validated());
Log::info('KPI target created successfully', [
'target_id' => $target->id,
'user_id' => $target->user_id
]);
return redirect()->route('kpi.targets.index')
->with('success', 'Target KPI berhasil ditambahkan');
} catch (\Exception $e) {
Log::error('Failed to create KPI target', [
'error' => $e->getMessage(),
'data' => $request->validated()
]);
return redirect()->back()
->withInput()
->with('error', 'Gagal menambahkan target KPI: ' . $e->getMessage());
}
}
/**
* Display the specified KPI target
*/
public function show(KpiTarget $target)
{
$target->load(['user.dealer', 'achievements']);
return view('kpi.targets.show', compact('target'));
}
/**
* Show the form for editing the specified KPI target
*/
public function edit(KpiTarget $target)
{
// Debug: Check if target is loaded correctly
if (!$target) {
abort(404, 'Target KPI tidak ditemukan');
}
// Load target with user relationship
$target->load('user');
// Get mechanics using role_id 3 (mechanic) with dealer relationship
$mechanics = User::with('dealer')
->where('role_id', 3)
->orderBy('name', 'asc')
->limit(50)
->get();
// If no mechanics found, get all users as fallback
if ($mechanics->isEmpty()) {
$mechanics = User::with('dealer')
->orderBy('name', 'asc')
->limit(50)
->get();
}
// Ensure data types are correct for comparison
$target->user_id = (int)$target->user_id;
return view('kpi.targets.edit', compact('target', 'mechanics'));
}
/**
* Update the specified KPI target
*/
public function update(UpdateKpiTargetRequest $request, KpiTarget $target)
{
try {
$target->update($request->validated());
return redirect()->route('kpi.targets.index')
->with('success', 'Target KPI berhasil diperbarui');
} catch (\Exception $e) {
return redirect()->back()
->withInput()
->with('error', 'Gagal memperbarui target KPI: ' . $e->getMessage());
}
}
/**
* Remove the specified KPI target
*/
public function destroy(KpiTarget $target)
{
try {
$target->delete();
return redirect()->route('kpi.targets.index')
->with('success', 'Target KPI berhasil dihapus');
} catch (\Exception $e) {
return redirect()->back()
->with('error', 'Gagal menghapus target KPI: ' . $e->getMessage());
}
}
/**
* Toggle active status of KPI target
*/
public function toggleStatus(KpiTarget $target)
{
try {
$target->update(['is_active' => !$target->is_active]);
$status = $target->is_active ? 'diaktifkan' : 'dinonaktifkan';
return response()->json([
'success' => true,
'message' => "Target KPI berhasil {$status}",
'is_active' => $target->is_active
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal mengubah status target KPI'
], 500);
}
}
/**
* Get KPI targets for specific user
*/
public function getUserTargets(User $user)
{
$targets = $user->kpiTargets()
->with('achievements')
->orderBy('year', 'desc')
->orderBy('month', 'desc')
->get();
return response()->json([
'success' => true,
'data' => $targets
]);
}
}

View File

@@ -16,6 +16,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Yajra\DataTables\Facades\DataTables;
use Maatwebsite\Excel\Facades\Excel;
use App\Models\Role;
class ReportController extends Controller
{
@@ -36,14 +37,42 @@ class ReportController extends Controller
$request['sa'] = 'all';
}
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request) {
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) {
$q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
}
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q = $q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q = $q->where('dealer_id', '=', $request->dealer);
}
} else {
$q = $q->where('dealer_id', '=', $request->dealer);
}
}
if(isset($request->sa) && $request->sa != 'all') {
$q = $q->where('user_sa_id', '=', $request->sa);
@@ -52,8 +81,27 @@ class ReportController extends Controller
return $q;
})->orderBy('id', 'ASC')->get();
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::orderBy('id', 'ASC')->get();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Get SA users based on dealer access
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$sa_datas = User::select('id', 'name')->where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sa_datas = User::select('id', 'name')->where('role_id', 4)->get();
}
$sa = $request->sa;
$dealer = $request->dealer;
$month = $request->month;
@@ -82,8 +130,27 @@ class ReportController extends Controller
$request['sa'] = 'all';
}
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::orderBy('id', 'ASC')->get();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Get SA users based on dealer access
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$sa_datas = User::select('id', 'name')->where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sa_datas = User::select('id', 'name')->where('role_id', 4)->get();
}
$sa = $request->sa;
$dealer = $request->dealer;
@@ -126,12 +193,41 @@ class ReportController extends Controller
$sa = $request->sa;
$year = $request->year;
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$dealer_work_trx = DB::statement("SET @sql = NULL");
$sql = "SELECT IF(work_id IS NOT NULL, GROUP_CONCAT(DISTINCT CONCAT('SUM(IF(work_id = \"', work_id,'\", qty,\"\")) AS \"',CONCAT(w.name, '|',w.id),'\"')), 's.work_id') INTO @sql FROM transactions t JOIN works w ON w.id = t.work_id WHERE month(t.date) = '". $month ."' and year(t.date) = '". $year ."' and t.deleted_at is null";
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$dealerIdsStr = implode(',', $dealerIds);
$sql .= " and t.dealer_id IN (". $dealerIdsStr .")";
}
if(isset($request->dealer) && $request->dealer != 'all') {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$sql .= " and t.dealer_id = '". $dealer ."'";
}
} else {
$sql .= " and t.dealer_id = '". $dealer ."'";
}
}
if(isset($request->sa) && $request->sa != 'all') {
$sql .= " and t.user_sa_id = '". $sa ."'";
@@ -139,17 +235,35 @@ class ReportController extends Controller
$sa_work_trx = DB::statement($sql);
// Validate dealer access before building the main query
$dealerFilter = "";
if(isset($request->dealer) && $request->dealer != 'all') {
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$dealerFilter = " and s.dealer_id = '". $dealer ."'";
}
} else {
$dealerFilter = " and s.dealer_id = '". $dealer ."'";
}
} else if($allowedDealers->count() > 0) {
// If no specific dealer requested, filter by allowed dealers
$dealerIds = $allowedDealers->pluck('id')->toArray();
$dealerIdsStr = implode(',', $dealerIds);
$dealerFilter = " and s.dealer_id IN (". $dealerIdsStr .")";
}
if(isset($request->dealer) && $request->dealer != 'all') {
if(isset($request->sa) && $request->sa != 'all') {
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
}else{
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
}
}else{
if(isset($request->sa) && $request->sa != 'all') {
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
}else{
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
}
}
@@ -218,21 +332,69 @@ class ReportController extends Controller
$request['month'] = date('m');
}
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request) {
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) {
$q->whereMonth('date', '=', $request->month);
}
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->where('dealer_id', '=', $request->dealer);
}
} else {
$q->where('dealer_id', '=', $request->dealer);
}
}
if(isset($request->sa) && $request->sa != 'all') {
$q->where('user_sa_id', '=', $request->sa);
}
})->get();
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Get SA users based on dealer access
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$sas = User::select('id', 'name')->where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sas = User::select('id', 'name')->where('role_id', 4)->get();
}
$trxs = [];
foreach($sas as $key => $sa) {
@@ -244,9 +406,23 @@ class ReportController extends Controller
$d = $d->whereMonth('date', '=', $request->month);
}
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$d = $d->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$d = $d->where('dealer_id', '=', $request->dealer);
}
} else {
$d = $d->where('dealer_id', '=', $request->dealer);
}
}
if(isset($request->sa) && $request->sa != 'all') {
$d = $d->where('user_sa_id', '=', $request->sa);
@@ -301,36 +477,77 @@ class ReportController extends Controller
$month = $request->month;
$dealer_id = $request->dealer;
$sa_id = $request->sa;
$dealers = Dealer::all();
$sas = User::where('role_id', 4)->get();
return view('back.report.transaction_sa', compact('sas', 'dealers', 'dealer_id', 'sa_id', 'month', 'trxs', 'works', 'work_count', 'sa_names', 'trx_data'));
return view('back.report.transaction_sa', compact('sas', 'dealer_datas', 'dealer_id', 'sa_id', 'month', 'trxs', 'works', 'work_count', 'sa_names', 'trx_data'));
}
public function sa_work_trx(Request $request) {
$sa_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request) {
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$sa_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) {
$q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
}
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') {
$q = $q->where('dealer_id', '=', $request->dealer);
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->where('dealer_id', '=', $request->dealer);
}
} else {
$q->where('dealer_id', '=', $request->dealer);
}
}
if(isset($request->sa_filter) && $request->sa_filter != 'all') {
$q = $q->where('user_sa_id', '=', $request->sa_filter);
$q->where('user_sa_id', '=', $request->sa_filter);
}
return $q;
})->leftJoin('transactions AS t', function($q) use($request) {
})->leftJoin('transactions AS t', function($q) use($request, $allowedDealers) {
$q->on('t.work_id', '=', 'works.id');
$q->on(DB::raw('MONTH(t.date)'), '=', DB::raw($request->month));
$q->on(DB::raw('YEAR(t.date)'), '=', DB::raw(date('Y')));
$q->on('t.user_sa_id', '=', DB::raw($request->sa));
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('t.dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->on('t.dealer_id', '=', DB::raw($request->dealer));
}
} else {
$q->on('t.dealer_id', '=', DB::raw($request->dealer));
}
}
if(isset($request->sa_filter) && $request->sa_filter != 'all') {
$q->on('t.user_sa_id', '=', DB::raw($request->sa_filter));
}
@@ -351,14 +568,42 @@ class ReportController extends Controller
$request['sa'] = 'all';
}
$sas = User::where('role_id', 4)->whereHas('sa_transactions', function($q) use($request) {
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$sas = User::where('role_id', 4)->whereHas('sa_transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) {
$q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
}
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->where('dealer_id', '=', $request->dealer);
}
} else {
$q->where('dealer_id', '=', $request->dealer);
}
}
});
if(isset($request->sa) && $request->sa != 'all') {
@@ -383,10 +628,22 @@ class ReportController extends Controller
$request['year'] = date('Y');
}
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
$year = $request->year;
$month = $request->month;
$dealer = $request->dealer;
$dealer_datas = Dealer::all();
$ajax_url = route('dashboard_data').'?month='.$month.'&year='.$year.'&dealer='.$dealer;
return view('dashboard', compact('month', 'ajax_url', 'dealer', 'dealer_datas', 'year'));
}
@@ -396,9 +653,30 @@ class ReportController extends Controller
$menu = Menu::where('link', 'report.transaction')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$current_user = Auth::user();
$current_role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($current_user->role_id);
// Get dealers based on user role
if($current_role && $this->isAdminRole($current_role) && $current_role->dealers->count() == 0) {
$dealers = Dealer::all();
} else if($current_role) {
$dealers = $current_role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealers = collect();
}
// Get SA users based on dealer access
if($dealers->count() > 0) {
$dealerIds = $dealers->pluck('id')->toArray();
$sas = User::where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
$mechanics = User::where('role_id', 3)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sas = User::where('role_id', 4)->get();
$mechanics = User::where('role_id', 3)->get();
$dealers = Dealer::all();
}
$works = Work::all();
return view('back.report.transaction', compact('sas', 'mechanics', 'dealers', 'works'));
@@ -410,6 +688,20 @@ class ReportController extends Controller
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
if ($request->ajax()) {
// Get dealers based on user role
$current_user = Auth::user();
$current_role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($current_user->role_id);
if($current_role && $this->isAdminRole($current_role) && $current_role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($current_role) {
$allowedDealers = $current_role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$data = Transaction::leftJoin('users', 'users.id', '=', 'transactions.user_id')
->leftJoin('users as sa', 'sa.id', '=', 'transactions.user_sa_id')
->leftJoin('works as w', 'w.id', '=', 'transactions.work_id')
@@ -417,6 +709,13 @@ class ReportController extends Controller
->leftJoin('dealers as d', 'd.id', '=', 'transactions.dealer_id')
->select('transactions.id', 'transactions.status', 'transactions.user_id as user_id', 'transactions.user_sa_id as user_sa_id', 'users.name as username', 'sa.name as sa_name', 'cat.name as category_name', 'w.name as workname', 'transactions.qty as qty', 'transactions.date as date', 'transactions.police_number as police_number', 'transactions.warranty as warranty', 'transactions.spk as spk', 'transactions.dealer_id', 'd.name as dealer_name');
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$data->whereIn('transactions.dealer_id', $dealerIds);
}
if(isset($request->date_start)) {
$data->where('transactions.date', '>=', $request->date_start);
}
@@ -434,8 +733,16 @@ class ReportController extends Controller
}
if(isset($request->dealer)) {
// Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$data->where('transactions.dealer_id', $request->dealer);
}
} else {
$data->where('transactions.dealer_id', $request->dealer);
}
}
$data->orderBy('date', 'DESC');
return DataTables::of($data)->addIndexColumn()
@@ -565,4 +872,34 @@ class ReportController extends Controller
return response()->json($response);
}
/**
* Check if role is admin type
*/
private function isAdminRole($role)
{
if (!$role) {
return false;
}
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
return true;
}
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
return false;
}
return false;
}
}

View File

@@ -0,0 +1,103 @@
<?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()
{
$stockService = new StockReportService();
$dealers = $stockService->getDealersBasedOnUserRole();
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());
}
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Http\Controllers\Reports;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Menu;
use App\Models\Role;
use App\Services\TechnicianReportService;
use App\Exports\TechnicianReportExport;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Support\Facades\DB; // Added DB facade
use App\Models\Dealer; // Added Dealer model
class ReportTechniciansController extends Controller
{
protected $technicianReportService;
public function __construct(TechnicianReportService $technicianReportService)
{
$this->technicianReportService = $technicianReportService;
}
public function index(Request $request)
{
$menu = Menu::where('link','reports.technician.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
return view('reports.technician');
}
/**
* Get dealers for filter dropdown
*/
public function getDealers()
{
try {
// Get current authenticated user
$user = auth()->user();
if (!$user) {
Log::info('Controller: No authenticated user found');
return response()->json([
'status' => 'error',
'message' => 'User tidak terautentikasi'
], 401);
}
Log::info('Controller: Getting dealers for user:', [
'user_id' => $user->id,
'user_name' => $user->name,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
$dealers = $this->technicianReportService->getDealers();
$defaultDealer = $this->technicianReportService->getDefaultDealer();
Log::info('Controller: Service returned dealers:', [
'dealers_count' => $dealers->count(),
'dealers' => $dealers->toArray(),
'default_dealer' => $defaultDealer ? $defaultDealer->toArray() : null,
'default_dealer_id' => $defaultDealer ? $defaultDealer->id : null
]);
// Check if default dealer exists in dealers list
if ($defaultDealer && $dealers->count() > 0) {
$defaultDealerExists = $dealers->contains('id', $defaultDealer->id);
Log::info('Controller: Default dealer validation:', [
'default_dealer_id' => $defaultDealer->id,
'default_dealer_exists_in_list' => $defaultDealerExists,
'available_dealer_ids' => $dealers->pluck('id')->toArray()
]);
// If default dealer doesn't exist in list, use first dealer from list
if (!$defaultDealerExists) {
Log::info('Controller: Default dealer not in list, using first dealer from list');
$defaultDealer = $dealers->first();
Log::info('Controller: New default dealer:', $defaultDealer ? $defaultDealer->toArray() : null);
}
} else if ($defaultDealer === null && $dealers->count() > 0) {
// Admin without default dealer - no need to set default
Log::info('Controller: Admin without default dealer, no default will be set');
}
return response()->json([
'status' => 'success',
'data' => $dealers,
'default_dealer' => $defaultDealer ? $defaultDealer->id : null
]);
} catch (\Exception $e) {
Log::error('Controller: Error getting dealers: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil data dealer: ' . $e->getMessage()
], 500);
}
}
/**
* Get technician report data for DataTable
*/
public function getData(Request $request)
{
try {
$dealerId = $request->input('dealer_id');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'status' => 'error',
'message' => 'User tidak terautentikasi'
], 401);
}
Log::info('Requesting technician report data:', [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
$reportData = $this->technicianReportService->getTechnicianReportData(
$dealerId,
$startDate,
$endDate
);
Log::info('Technician report data response:', [
'data_count' => count($reportData['data']),
'mechanics_count' => $reportData['mechanics']->count(),
'works_count' => $reportData['works']->count(),
'mechanics' => $reportData['mechanics']->map(function($mechanic) {
return [
'id' => $mechanic->id,
'name' => $mechanic->name,
'role_id' => $mechanic->role_id
];
})
]);
return response()->json([
'status' => 'success',
'data' => $reportData['data'],
'mechanics' => $reportData['mechanics'],
'works' => $reportData['works']
]);
} catch (\Exception $e) {
Log::error('Error getting technician report data: ' . $e->getMessage(), [
'dealer_id' => $request->input('dealer_id'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date'),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil data laporan teknisi: ' . $e->getMessage()
], 500);
}
}
/**
* Get technician report data for Yajra DataTable
*/
public function getDataTable(Request $request)
{
try {
$dealerId = $request->input('dealer_id');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'error' => 'User tidak terautentikasi'
], 401);
}
Log::info('Requesting technician report data for DataTable:', [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
$reportData = $this->technicianReportService->getTechnicianReportDataForDataTable(
$dealerId,
$startDate,
$endDate
);
return $reportData;
} catch (\Exception $e) {
Log::error('Error getting technician report data for DataTable: ' . $e->getMessage(), [
'dealer_id' => $request->input('dealer_id'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date'),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'error' => 'Gagal mengambil data laporan teknisi: ' . $e->getMessage()
], 500);
}
}
/**
* Export technician report to Excel
*/
public function export(Request $request)
{
try {
$dealerId = $request->input('dealer_id');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'status' => 'error',
'message' => 'User tidak terautentikasi'
], 401);
}
Log::info('Exporting technician report', [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// Validate dealer access for export
if ($dealerId) {
// User is trying to export specific dealer
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data dealer ini'
], 403);
}
} else {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data dealer ini'
], 403);
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data dealer ini'
], 403);
}
}
} else {
// User is trying to export "Semua Dealer" - check if they have permission
if ($user->role_id) {
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if ($role) {
// Check if role is admin type
$technicianReportService = new \App\Services\TechnicianReportService();
if ($technicianReportService->isAdminRole($role)) {
// Admin can export all dealers
Log::info('Admin user exporting all dealers');
} else {
// Non-admin with pivot dealers - can only export pivot dealers
if ($role->dealers->count() > 0) {
Log::info('User with pivot dealers exporting pivot dealers only');
} else {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data semua dealer'
], 403);
}
}
}
} else if ($user->dealer_id) {
// User with specific dealer_id cannot export all dealers
return response()->json([
'status' => 'error',
'message' => 'Anda hanya dapat export data dealer Anda sendiri'
], 403);
}
}
return Excel::download(new TechnicianReportExport($dealerId, $startDate, $endDate), 'laporan_teknisi_' . date('Y-m-d') . '.xlsx');
} catch (\Exception $e) {
Log::error('Error exporting technician report: ' . $e->getMessage(), [
'dealer_id' => $request->input('dealer_id'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date')
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal export laporan: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\Menu;
use App\Models\Privilege;
use App\Models\Role;
use App\Models\User;
use App\Models\Dealer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
@@ -14,10 +15,11 @@ class RolePrivilegeController extends Controller
public function index() {
$menu = Menu::where('link', 'roleprivileges.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$roles = Role::all();
$roles = Role::with('dealers')->get();
$menus = Menu::all();
$users = User::all();
return view('back.roleprivileges', compact('roles', 'users', 'menus'));
$dealers = Dealer::all();
return view('back.roleprivileges', compact('roles', 'users', 'menus', 'dealers'));
}
public function store(Request $request) {
@@ -117,4 +119,36 @@ class RolePrivilegeController extends Controller
User::where('role_id', $id)->update(['role_id' => 0]);
return redirect()->back()->with('success', 'Berhasil Hapus Role');
}
public function assignDealer(Request $request, $id) {
$menu = Menu::where('link', 'roleprivileges.index')->first();
abort_if(Gate::denies('create', $menu), 403, 'Unauthorized User');
$request->validate([
'dealers' => 'required|array',
'dealers.*' => 'exists:dealers,id'
]);
$role = Role::findOrFail($id);
// Sync dealers (this will replace existing assignments)
$role->dealers()->sync($request->dealers);
return response()->json([
'success' => true,
'message' => 'Berhasil assign dealer ke role'
]);
}
public function getAssignedDealers($id) {
$menu = Menu::where('link', 'roleprivileges.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$role = Role::findOrFail($id);
$assignedDealers = $role->dealers()->pluck('dealers.id')->toArray();
return response()->json([
'assignedDealers' => $assignedDealers
]);
}
}

View File

@@ -49,7 +49,28 @@ class TransactionController extends Controller
->where('active', true)
->get();
return view('transaction.index', compact('now', 'wash_work', 'work_works', 'user_sas', 'count_transaction_users', 'count_transaction_dealers', 'mechanic', 'products'));
// Get KPI data for current user using KPI service
$kpiService = app(\App\Services\KpiService::class);
// Auto-calculate current month KPI achievement including claimed transactions
$kpiService->calculateKpiAchievementWithClaims(Auth::user());
$kpiSummary = $kpiService->getKpiSummaryWithClaims(Auth::user());
// Get current month period name
$currentMonthName = now()->translatedFormat('F Y');
$kpiData = [
'target' => $kpiSummary['current_target'] ? $kpiSummary['current_target']->target_value : 0,
'actual' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->actual_value : 0,
'percentage' => $kpiSummary['current_percentage'],
'status' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status : 'pending',
'status_color' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status_color : 'secondary',
'period' => $currentMonthName,
'has_target' => $kpiSummary['current_target'] ? true : false
];
return view('transaction.index', compact('now', 'wash_work', 'work_works', 'user_sas', 'count_transaction_users', 'count_transaction_dealers', 'mechanic', 'products', 'kpiData'));
}
public function workcategory($category_id)
@@ -81,37 +102,60 @@ class TransactionController extends Controller
->leftJoin('works as w', 'w.id', '=', 'transactions.work_id')
->leftJoin('categories as cat', 'cat.id', '=', 'w.category_id')
->select('transactions.id as transaction_id', 'transactions.status', 'transactions.user_id as user_id', 'transactions.user_sa_id as user_sa_id', 'users.name as username', 'sa.name as sa_name', 'cat.name as category_name', 'w.name as workname', 'transactions.qty as qty', 'transactions.date as date', 'transactions.police_number as police_number', 'transactions.warranty as warranty', 'transactions.spk as spk')
->whereNull('transactions.deleted_at')
->where('users.dealer_id', Auth::user()->dealer_id);
$transaction_works = Work::select('id', 'name', 'shortname')->whereHas('transactions', function($q) {
return $q->whereDate('date', '=', date('Y-m-d'))->where('dealer_id', Auth::user()->dealer_id);
return $q->whereNull('deleted_at')->whereDate('date', '=', date('Y-m-d'))->where('dealer_id', Auth::user()->dealer_id);
})->get();
$tm1 = [];
foreach($transaction_works as $index => $work) {
$transaction_sas = Transaction::leftJoin('users as sa', 'sa.id', '=', 'transactions.user_sa_id')
->select(DB::raw('SUM(transactions.qty) as qty'), 'sa.name as sa_name')
->whereNull('transactions.deleted_at')
->where('sa.dealer_id', Auth::user()->dealer_id)
->where('work_id', $work->id)
->whereDate('transactions.date', '=', date('Y-m-d'))->groupBy('transactions.user_sa_id')->get();
// Initialize data array for this work
$tm1[$work['shortname']]['data'] = [];
$daily_total = 0;
foreach($transaction_sas as $sa) {
$tm1[$work['shortname']]['data'][] = $sa['sa_name'].":".$sa['qty'];
$daily_total += $sa['qty'];
}
$month_share_data = Transaction::select(DB::raw('SUM(qty) as qty'), 'u.name')->join('users AS u', 'u.id', '=', 'transactions.user_sa_id')->where('transactions.dealer_id', Auth::user()->dealer->id)->whereMonth('date', date('m'))->whereYear('date', date('Y'))->where('work_id', $work->id)->groupBy('user_sa_id')->get();
$tm1[$work['shortname']]['total_title'] = "*[PERIODE 1 - ". Carbon::now()->translatedFormat('d F Y') ."]*\n\n";
$sum_month_share_trx = 0;
$tm_month = [];
foreach($month_share_data as $m_trx) {
$tm_month[] = $m_trx->name.":".$m_trx->qty." Unit\n";
$sum_month_share_trx += $m_trx->qty;
// Add daily total even if no data
if (empty($tm1[$work['shortname']]['data'])) {
$tm1[$work['shortname']]['data'][] = "Tidak ada data:0";
}
$tm1[$work['shortname']]['total_body'] = $tm_month;
$tm1[$work['shortname']]['total_total'] = "*TOTAL : ". $sum_month_share_trx." Unit*";
// Remove monthly data section since this is daily report
// $month_share_data = Transaction::select(DB::raw('SUM(qty) as qty'), 'u.name')
// ->join('users AS u', 'u.id', '=', 'transactions.user_sa_id')
// ->where('transactions.dealer_id', Auth::user()->dealer->id)
// ->whereMonth('date', date('m'))
// ->whereYear('date', date('Y'))
// ->where('work_id', $work->id)
// ->groupBy('user_sa_id')
// ->get();
// Remove the period title since this is for daily report, not monthly
// $tm1[$work['shortname']]['total_title'] = "*[PERIODE 1 - ". Carbon::now()->translatedFormat('d F Y') ."]*\n\n";
// $sum_month_share_trx = 0;
// $tm_month = [];
// foreach($month_share_data as $m_trx) {
// $tm_month[] = $m_trx->name.":".$m_trx->qty." Unit\n";
// $sum_month_share_trx += $m_trx->qty;
// }
// $tm1[$work['shortname']]['total_body'] = $tm_month;
// $tm1[$work['shortname']]['total_total'] = "*TOTAL : ". $sum_month_share_trx." Unit*";
}
if(isset($request->date_start)) {
@@ -135,7 +179,29 @@ class TransactionController extends Controller
$sas = User::where('role_id', 4)->get();
$dealers = Dealer::all();
$works = Work::all();
return view('transaction.lists', compact('transaction_dealers', 'transaction_mechanics', 'mechanic', 'sas', 'dealers', 'works', 'date_start', 'date_end'));
// Get KPI data for current user using KPI service
$kpiService = app(\App\Services\KpiService::class);
// Auto-calculate current month KPI achievement including claimed transactions
$kpiService->calculateKpiAchievementWithClaims(Auth::user());
$kpiSummary = $kpiService->getKpiSummaryWithClaims(Auth::user());
// Get current month period name
$currentMonthName = now()->translatedFormat('F Y');
$kpiData = [
'target' => $kpiSummary['current_target'] ? $kpiSummary['current_target']->target_value : 0,
'actual' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->actual_value : 0,
'percentage' => $kpiSummary['current_percentage'],
'status' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status : 'pending',
'status_color' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status_color : 'secondary',
'period' => $currentMonthName,
'has_target' => $kpiSummary['current_target'] ? true : false
];
return view('transaction.lists', compact('transaction_dealers', 'transaction_mechanics', 'mechanic', 'sas', 'dealers', 'works', 'date_start', 'date_end', 'kpiData'));
}
public function cmp($a, $b){
@@ -169,7 +235,7 @@ class TransactionController extends Controller
$mechanic = User::leftJoin('dealers as d', 'd.id', '=', 'users.dealer_id')
->select('d.name as dealer_name', 'users.name', 'users.id', 'users.role', 'users.email', 'd.dealer_code', 'd.address')
->where('users.id', Auth::user()->id)->first();
$d = Transaction::leftJoin('works as w', 'w.id', '=', 'transactions.work_id')->select('transactions.*', 'w.name as work_name', 'w.shortname as shortname')->where('dealer_id', $id);
$d = Transaction::leftJoin('works as w', 'w.id', '=', 'transactions.work_id')->select('transactions.*', 'w.name as work_name', 'w.shortname as shortname')->whereNull('transactions.deleted_at')->where('dealer_id', $id);
if(isset($request->date_start)) {
@@ -316,13 +382,13 @@ class TransactionController extends Controller
$id = Auth::user()->dealer_id;
$works = Work::select('id', 'name', 'shortname')->whereHas('transactions', function($q) use($request, $id) {
if(isset($request->month)) {
return $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'))->where('dealer_id', $id);
return $q->whereNull('deleted_at')->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'))->where('dealer_id', $id);
}
})->get();
$sas = User::select('id', 'name')->whereHas('sa_transactions', function($q) use($request, $id) {
if(isset($request->month)) {
return $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'))->where('dealer_id', $id);
return $q->whereNull('deleted_at')->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'))->where('dealer_id', $id);
}
})->get();
@@ -331,7 +397,7 @@ class TransactionController extends Controller
->select('d.name as dealer_name', 'users.name', 'users.id', 'users.role', 'users.email', 'd.dealer_code', 'd.address')
->where('users.id', Auth::user()->id)->first();
$dates = Transaction::select(DB::raw('DATE(`date`) as date'))->where('dealer_id', $id)->whereMonth('date', $request->month)->whereYear('date', date('Y'))->groupBy(DB::raw('DATE(`date`)'))->get()->toArray();
$dates = Transaction::select(DB::raw('DATE(`date`) as date'))->whereNull('deleted_at')->where('dealer_id', $id)->whereMonth('date', $request->month)->whereYear('date', date('Y'))->groupBy(DB::raw('DATE(`date`)'))->get()->toArray();
$dates = $this->array_value_recursive('date', $dates);
$month_trxs = [];
@@ -342,7 +408,7 @@ class TransactionController extends Controller
$prev_mth = explode('-', $prev_mth_start);
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d');
$yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
$yesterday_month_trx = Transaction::whereNull('deleted_at')->where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
if(array_key_exists($work1->id, $yesterday_month_trxs_total)) {
$yesterday_month_trxs_total[$work1->id] += $yesterday_month_trx;
@@ -356,7 +422,7 @@ class TransactionController extends Controller
$date_works = [];
$share_works = [];
foreach ($works as $key2 => $work) {
$d = Transaction::where('work_id', $work->id)->where('dealer_id', $id)->whereDate('date', $date);
$d = Transaction::whereNull('deleted_at')->where('work_id', $work->id)->where('dealer_id', $id)->whereDate('date', $date);
if(isset($request->month)) {
$d = $d->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
@@ -399,7 +465,7 @@ class TransactionController extends Controller
foreach($sas as $key => $sa) {
$sa_works = [];
foreach ($works as $key2 => $work) {
$d = Transaction::where('user_sa_id', $sa->id)->where('work_id', $work->id)->where('dealer_id', $id);
$d = Transaction::whereNull('deleted_at')->where('user_sa_id', $sa->id)->where('work_id', $work->id)->where('dealer_id', $id);
if(isset($request->month)) {
$d = $d->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
@@ -471,13 +537,13 @@ class TransactionController extends Controller
$id = Auth::user()->dealer_id;
$works = Work::select('id', 'name', 'shortname')->whereHas('transactions', function($q) use($request, $id) {
if(isset($request->month)) {
return $q->whereMonth('date', '=', $request->month)->whereYear('date', $request->year)->where('dealer_id', $id);
return $q->whereNull('deleted_at')->whereMonth('date', '=', $request->month)->whereYear('date', $request->year)->where('dealer_id', $id);
}
})->get();
$sas = User::select('id', 'name')->whereHas('sa_transactions', function($q) use($request, $id) {
if(isset($request->month)) {
return $q->whereMonth('date', '=', $request->month)->whereYear('date', $request->year)->where('dealer_id', $id);
return $q->whereNull('deleted_at')->whereMonth('date', '=', $request->month)->whereYear('date', $request->year)->where('dealer_id', $id);
}
})->get();
@@ -486,7 +552,7 @@ class TransactionController extends Controller
->select('d.name as dealer_name', 'users.name', 'users.id', 'users.role', 'users.email', 'd.dealer_code', 'd.address')
->where('users.id', Auth::user()->id)->first();
$dates = Transaction::select(DB::raw('DATE(`date`) as date'))->where('dealer_id', $id)->whereMonth('date', $request->month)->whereYear('date', $request->year)->groupBy(DB::raw('DATE(`date`)'))->get()->toArray();
$dates = Transaction::select(DB::raw('DATE(`date`) as date'))->whereNull('deleted_at')->where('dealer_id', $id)->whereMonth('date', $request->month)->whereYear('date', $request->year)->groupBy(DB::raw('DATE(`date`)'))->get()->toArray();
// print_r($dates);die;
$dates = $this->array_value_recursive('date', $dates);
@@ -503,7 +569,7 @@ class TransactionController extends Controller
}
// dd($prev_mth_end);
$yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
$yesterday_month_trx = Transaction::whereNull('deleted_at')->where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
if(array_key_exists($work1->id, $yesterday_month_trxs_total)) {
$yesterday_month_trxs_total[$work1->id] += $yesterday_month_trx;
@@ -516,7 +582,7 @@ class TransactionController extends Controller
$date_works = [];
$share_works = [];
foreach ($works as $key2 => $work) {
$d = Transaction::where('work_id', $work->id)->where('dealer_id', $id)->whereDate('date', $date);
$d = Transaction::whereNull('deleted_at')->where('work_id', $work->id)->where('dealer_id', $id)->whereDate('date', $date);
if(isset($request->month)) {
$d = $d->whereMonth('date', '=', $request->month)->whereYear('date', $request->year);
@@ -556,14 +622,14 @@ class TransactionController extends Controller
}
}
$this_month_trxs = Transaction::select(DB::raw('SUM(qty) as qty'), 'u.name')->join('users AS u', 'u.id', '=', 'transactions.user_sa_id')->where('transactions.dealer_id', $id)->whereMonth('date', date('m'))->whereYear('date', $request->year)->groupBy('user_sa_id')->get();
$today_trxs = Transaction::select(DB::raw('SUM(qty) as qty'), 'u.name')->join('users AS u', 'u.id', '=', 'transactions.user_sa_id')->where('transactions.dealer_id', $id)->whereDate('date', date('Y-m-d'))->groupBy('user_sa_id')->get();
$this_month_trxs = Transaction::select(DB::raw('SUM(qty) as qty'), 'u.name')->join('users AS u', 'u.id', '=', 'transactions.user_sa_id')->whereNull('transactions.deleted_at')->where('transactions.dealer_id', $id)->whereMonth('date', date('m'))->whereYear('date', $request->year)->groupBy('user_sa_id')->get();
$today_trxs = Transaction::select(DB::raw('SUM(qty) as qty'), 'u.name')->join('users AS u', 'u.id', '=', 'transactions.user_sa_id')->whereNull('transactions.deleted_at')->where('transactions.dealer_id', $id)->whereDate('date', date('Y-m-d'))->groupBy('user_sa_id')->get();
$trxs = [];
foreach($sas as $key => $sa) {
$sa_works = [];
foreach ($works as $key2 => $work) {
$d = Transaction::where('user_sa_id', $sa->id)->where('work_id', $work->id)->where('dealer_id', $id);
$d = Transaction::whereNull('deleted_at')->where('user_sa_id', $sa->id)->where('work_id', $work->id)->where('dealer_id', $id);
if(isset($request->month)) {
$d = $d->whereMonth('date', '=', $request->month)->whereYear('date', $request->year);
@@ -875,7 +941,7 @@ class TransactionController extends Controller
"warranty" => $request->warranty,
"user_sa_id" => $request->user_sa_id,
"date" => $request->date,
"status" => 'completed', // Mark as completed to trigger stock reduction
"status" => 0, // pending (0) - Mark as pending initially
"created_at" => date('Y-m-d H:i:s'),
"updated_at" => date('Y-m-d H:i:s')
];
@@ -898,6 +964,10 @@ class TransactionController extends Controller
$this->stockService->reduceStockForTransaction($transaction);
}
// Recalculate KPI achievement after creating transactions
$kpiService = app(\App\Services\KpiService::class);
$kpiService->calculateKpiAchievementWithClaims(Auth::user());
DB::commit();
return redirect()->back()->with('success', 'Berhasil input pekerjaan dan stock telah dikurangi otomatis');
@@ -997,4 +1067,270 @@ class TransactionController extends Controller
], 500);
}
}
/**
* Get claim transactions for DataTable - Only for mechanics
*/
public function getClaimTransactions(Request $request)
{
// Only allow mechanics to access this endpoint
if (Auth::user()->role_id != 3) {
return response()->json([
'draw' => intval($request->input('draw')),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => []
]);
}
$request->validate([
'dealer_id' => 'required|exists:dealers,id'
]);
try {
$query = Transaction::leftJoin('users', 'users.id', '=', 'transactions.user_id')
->leftJoin('users as sa', 'sa.id', '=', 'transactions.user_sa_id')
->leftJoin('works as w', 'w.id', '=', 'transactions.work_id')
->select([
'transactions.id',
'transactions.date',
'transactions.spk',
'transactions.police_number',
'transactions.qty',
'transactions.status',
'transactions.claimed_at',
'transactions.claimed_by',
'w.name as work_name',
'sa.name as sa_name',
'users.name as mechanic_name'
])
->whereNull('transactions.deleted_at')
->where('transactions.dealer_id', $request->dealer_id)
->whereIn('transactions.status', [0, 1]) // Only pending and completed transactions
->orderBy('transactions.date', 'desc');
// Handle DataTables server-side processing
$total = $query->count();
// Search functionality
if ($request->has('search') && !empty($request->search['value'])) {
$searchValue = $request->search['value'];
$query->where(function($q) use ($searchValue) {
$q->where('transactions.spk', 'like', "%{$searchValue}%")
->orWhere('transactions.police_number', 'like', "%{$searchValue}%")
->orWhere('w.name', 'like', "%{$searchValue}%")
->orWhere('sa.name', 'like', "%{$searchValue}%")
->orWhere('users.name', 'like', "%{$searchValue}%");
});
}
$filteredTotal = $query->count();
// Pagination
$start = $request->input('start', 0);
$length = $request->input('length', 15);
$query->skip($start)->take($length);
$transactions = $query->get();
$data = [];
foreach ($transactions as $transaction) {
$data[] = [
'id' => $transaction->id,
'date' => date('d/m/Y', strtotime($transaction->date)),
'spk' => $transaction->spk,
'police_number' => $transaction->police_number,
'work_name' => $transaction->work_name,
'qty' => number_format($transaction->qty),
'sa_name' => $transaction->sa_name,
'status' => $this->getStatusBadge($transaction->status),
'action' => $this->getActionButtons($transaction),
'claimed_at' => $transaction->claimed_at,
'claimed_by' => $transaction->claimed_by
];
}
return response()->json([
'draw' => intval($request->input('draw')),
'recordsTotal' => $total,
'recordsFiltered' => $filteredTotal,
'data' => $data
]);
} catch (Exception $e) {
return response()->json([
'error' => 'Error fetching claim transactions: ' . $e->getMessage()
], 500);
}
}
/**
* Get status badge HTML
*/
private function getStatusBadge($status)
{
switch ($status) {
case 0: // pending
return '<span class="badge badge-warning">Menunggu</span>';
case 1: // completed
return '<span class="badge badge-success">Closed</span>';
default:
return '<span class="badge badge-secondary">Tidak Diketahui</span>';
}
}
/**
* Claim a transaction - Only for mechanics
*/
public function claim($id)
{
// Only allow mechanics to claim transactions
if (Auth::user()->role_id != 3) {
return response()->json([
'status' => 403,
'message' => 'Hanya mekanik yang dapat mengklaim pekerjaan'
], 403);
}
try {
$transaction = Transaction::whereNull('deleted_at')->find($id);
if (!$transaction) {
return response()->json([
'status' => 404,
'message' => 'Transaksi tidak ditemukan'
], 404);
}
// Check if transaction belongs to current user's dealer
if ($transaction->dealer_id !== Auth::user()->dealer_id) {
return response()->json([
'status' => 403,
'message' => 'Anda tidak memiliki akses ke transaksi ini'
], 403);
}
// Check if transaction can be claimed (pending or completed)
if (!in_array($transaction->status, [0, 1])) { // pending (0) and completed (1)
return response()->json([
'status' => 400,
'message' => 'Hanya transaksi yang menunggu atau sudah selesai yang dapat diklaim'
], 400);
}
// Check if transaction is already claimed
if (!empty($transaction->claimed_at) || !empty($transaction->claimed_by)) {
return response()->json([
'status' => 400,
'message' => 'Transaksi ini sudah diklaim sebelumnya'
], 400);
}
// Check if transaction was created by SA (role_id = 4)
$creator = User::find($transaction->user_id);
if (!$creator || $creator->role_id != 4) {
return response()->json([
'status' => 400,
'message' => 'Hanya transaksi yang dibuat oleh Service Advisor yang dapat diklaim'
], 400);
}
// Update transaction with claim information
$transaction->update([
'claimed_at' => now(),
'claimed_by' => Auth::user()->id
]);
// Recalculate KPI achievement after claiming
$kpiService = app(\App\Services\KpiService::class);
$kpiService->calculateKpiAchievementWithClaims(Auth::user());
return response()->json([
'status' => 200,
'message' => 'Pekerjaan berhasil diklaim'
]);
} catch (Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Gagal mengklaim pekerjaan: ' . $e->getMessage()
], 500);
}
}
/**
* Get action buttons HTML for claim transactions - Only for mechanics
*/
private function getActionButtons($transaction)
{
$buttons = '';
// Only show buttons for mechanics
if (Auth::user()->role_id == 3) {
// Claim button - show only if not claimed yet
if (empty($transaction->claimed_at) && empty($transaction->claimed_by)) {
$buttons .= '<button class="btn btn-sm btn-success mr-1" onclick="claimTransaction(' . $transaction->id . ')" title="Klaim Pekerjaan">';
$buttons .= 'Klaim';
$buttons .= '</button>';
} else {
if($transaction->claimed_by == Auth::user()->id) {
// Check if precheck exists
$precheck = \App\Models\Precheck::where('transaction_id', $transaction->id)->first();
if (!$precheck) {
$buttons .= '<a href="/transaction/prechecks/' . $transaction->id . '" class="btn btn-sm btn-warning mr-1" title="Precheck">';
$buttons .= 'Precheck';
$buttons .= '</a>';
} else {
// Check if postcheck exists
$postcheck = \App\Models\Postcheck::where('transaction_id', $transaction->id)->first();
if (!$postcheck) {
$buttons .= '<a href="/transaction/postchecks/' . $transaction->id . '" class="btn btn-sm btn-info mr-1" title="Postcheck">';
$buttons .= 'Postcheck';
$buttons .= '</a>';
} else {
$buttons .= '<span class="badge badge-success">Selesai</span>';
}
}
}
$buttons .= '<span class="badge badge-info">Sudah Diklaim</span>';
}
}
return $buttons;
}
/**
* Get KPI data for AJAX refresh
*/
public function getKpiData()
{
try {
$kpiService = app(\App\Services\KpiService::class);
$kpiSummary = $kpiService->getKpiSummaryWithClaims(Auth::user());
$currentMonthName = now()->translatedFormat('F Y');
$kpiData = [
'target' => $kpiSummary['current_target'] ? $kpiSummary['current_target']->target_value : 0,
'actual' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->actual_value : 0,
'percentage' => $kpiSummary['current_percentage'],
'status' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status : 'pending',
'status_color' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status_color : 'secondary',
'period' => $currentMonthName,
'has_target' => $kpiSummary['current_target'] ? true : false
];
return response()->json([
'success' => true,
'data' => $kpiData
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => 'Error fetching KPI data: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Postcheck;
use App\Models\Transaction;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class PostchecksController extends Controller
{
public function index(Transaction $transaction)
{
$acConditions = Postcheck::getAcConditionOptions();
$blowerConditions = Postcheck::getBlowerConditionOptions();
$evaporatorConditions = Postcheck::getEvaporatorConditionOptions();
$compressorConditions = Postcheck::getCompressorConditionOptions();
return view('transaction.postchecks', compact(
'transaction',
'acConditions',
'blowerConditions',
'evaporatorConditions',
'compressorConditions'
));
}
public function store(Request $request, Transaction $transaction)
{
$request->validate([
'kilometer' => 'required|numeric|min:0',
'pressure_high' => 'required|numeric|min:0',
'pressure_low' => 'nullable|numeric|min:0',
'cabin_temperature' => 'nullable|numeric',
'cabin_temperature_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'ac_condition' => 'nullable|in:' . implode(',', Postcheck::getAcConditionOptions()),
'ac_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'blower_condition' => 'nullable|in:' . implode(',', Postcheck::getBlowerConditionOptions()),
'blower_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'evaporator_condition' => 'nullable|in:' . implode(',', Postcheck::getEvaporatorConditionOptions()),
'evaporator_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'compressor_condition' => 'nullable|in:' . implode(',', Postcheck::getCompressorConditionOptions()),
'postcheck_notes' => 'nullable|string',
'front_image' => 'required|image|mimes:jpeg,png,jpg|max:2048',
]);
$data = [
'transaction_id' => $transaction->id,
'postcheck_by' => auth()->id(),
'postcheck_at' => now(),
'police_number' => $transaction->police_number,
'spk_number' => $transaction->spk,
'kilometer' => $request->kilometer,
'pressure_high' => $request->pressure_high,
'pressure_low' => $request->pressure_low,
'cabin_temperature' => $request->cabin_temperature,
'ac_condition' => $request->ac_condition,
'blower_condition' => $request->blower_condition,
'evaporator_condition' => $request->evaporator_condition,
'compressor_condition' => $request->compressor_condition,
'postcheck_notes' => $request->postcheck_notes,
];
// Handle file uploads
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($request->hasFile($field) && $request->file($field)->isValid()) {
try {
$file = $request->file($field);
// Generate unique filename with transaction ID
$filename = time() . '_' . uniqid() . '_' . $transaction->id . '_' . $field . '.' . $file->getClientOriginalExtension();
// Create directory path: transactions/{transaction_id}/postcheck/
$directory = 'transactions/' . $transaction->id . '/postcheck';
// Ensure base storage directory exists
$this->ensureStorageDirectoryExists();
// Ensure transactions directory exists
if (!Storage::disk('public')->exists('transactions')) {
Storage::disk('public')->makeDirectory('transactions', 0755, true);
Log::info('Created transactions directory');
}
// Ensure transaction ID directory exists
$transactionDir = 'transactions/' . $transaction->id;
if (!Storage::disk('public')->exists($transactionDir)) {
Storage::disk('public')->makeDirectory($transactionDir, 0755, true);
Log::info('Created transaction directory: ' . $transactionDir);
}
// Ensure postcheck directory exists
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory, 0755, true);
Log::info('Created postcheck directory: ' . $directory);
}
// Store file in organized directory structure
$path = $file->storeAs($directory, $filename, 'public');
// Store file path
$data[$field] = $path;
// Store metadata
$data[$field . '_metadata'] = [
'original_name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_at' => now()->toISOString(),
'transaction_id' => $transaction->id,
'filename' => $filename,
];
Log::info('File uploaded successfully: ' . $path);
} catch (\Exception $e) {
// Log error for debugging
Log::error('File upload failed: ' . $e->getMessage(), [
'field' => $field,
'file' => $file->getClientOriginalName(),
'transaction_id' => $transaction->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return back()->withErrors(['error' => 'Gagal mengupload file: ' . $field . '. Error: ' . $e->getMessage()]);
}
}
}
try {
Postcheck::create($data);
return redirect()->route('transaction')->with('success', 'Postcheck berhasil disimpan');
} catch (\Exception $e) {
Log::error('Postcheck creation failed: ' . $e->getMessage());
return back()->withErrors(['error' => 'Gagal menyimpan data postcheck. Silakan coba lagi.']);
}
}
/**
* Ensure the base storage directory exists
*/
private function ensureStorageDirectoryExists()
{
$storagePath = storage_path('app/public');
if (!is_dir($storagePath)) {
if (!mkdir($storagePath, 0755, true)) {
Log::error('Failed to create storage directory: ' . $storagePath);
throw new \Exception('Cannot create storage directory: ' . $storagePath . '. Please run: php fix_permissions.php or manually create the directory.');
}
Log::info('Created storage directory: ' . $storagePath);
}
// Check if directory is writable
if (!is_writable($storagePath)) {
Log::error('Storage directory is not writable: ' . $storagePath);
throw new \Exception(
'Storage directory is not writable: ' . $storagePath . '. ' .
'Please run one of these commands from your project root: ' .
'1) php fix_permissions.php ' .
'2) chmod -R 775 storage/ ' .
'3) mkdir -p storage/app/public/transactions/{transaction_id}/postcheck'
);
}
// Check if we can create subdirectories
$testDir = $storagePath . '/test_' . time();
if (!mkdir($testDir, 0755, true)) {
Log::error('Cannot create subdirectories in storage: ' . $storagePath);
throw new \Exception(
'Cannot create subdirectories in storage. ' .
'Please check permissions and run: php fix_permissions.php'
);
}
// Clean up test directory
rmdir($testDir);
Log::info('Storage directory is properly configured: ' . $storagePath);
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Precheck;
use App\Models\Transaction;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class PrechecksController extends Controller
{
public function index(Transaction $transaction)
{
$acConditions = Precheck::getAcConditionOptions();
$blowerConditions = Precheck::getBlowerConditionOptions();
$evaporatorConditions = Precheck::getEvaporatorConditionOptions();
$compressorConditions = Precheck::getCompressorConditionOptions();
return view('transaction.prechecks', compact(
'transaction',
'acConditions',
'blowerConditions',
'evaporatorConditions',
'compressorConditions'
));
}
public function store(Request $request, Transaction $transaction)
{
$request->validate([
'kilometer' => 'required|numeric|min:0',
'pressure_high' => 'required|numeric|min:0',
'pressure_low' => 'nullable|numeric|min:0',
'cabin_temperature' => 'nullable|numeric',
'cabin_temperature_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'ac_condition' => 'nullable|in:' . implode(',', Precheck::getAcConditionOptions()),
'ac_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'blower_condition' => 'nullable|in:' . implode(',', Precheck::getBlowerConditionOptions()),
'blower_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'evaporator_condition' => 'nullable|in:' . implode(',', Precheck::getEvaporatorConditionOptions()),
'evaporator_image' => 'nullable|image|mimes:jpeg,png,jpg|max:2048',
'compressor_condition' => 'nullable|in:' . implode(',', Precheck::getCompressorConditionOptions()),
'precheck_notes' => 'nullable|string',
'front_image' => 'required|image|mimes:jpeg,png,jpg|max:2048',
]);
$data = [
'transaction_id' => $transaction->id,
'precheck_by' => auth()->id(),
'precheck_at' => now(),
'police_number' => $transaction->police_number,
'spk_number' => $transaction->spk,
'kilometer' => $request->kilometer,
'pressure_high' => $request->pressure_high,
'pressure_low' => $request->pressure_low,
'cabin_temperature' => $request->cabin_temperature,
'ac_condition' => $request->ac_condition,
'blower_condition' => $request->blower_condition,
'evaporator_condition' => $request->evaporator_condition,
'compressor_condition' => $request->compressor_condition,
'precheck_notes' => $request->precheck_notes,
];
// Handle file uploads
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($request->hasFile($field) && $request->file($field)->isValid()) {
try {
$file = $request->file($field);
// Generate unique filename with transaction ID
$filename = time() . '_' . uniqid() . '_' . $transaction->id . '_' . $field . '.' . $file->getClientOriginalExtension();
// Create directory path: transactions/{transaction_id}/precheck/
$directory = 'transactions/' . $transaction->id . '/precheck';
// Ensure base storage directory exists
$this->ensureStorageDirectoryExists();
// Ensure transactions directory exists
if (!Storage::disk('public')->exists('transactions')) {
Storage::disk('public')->makeDirectory('transactions', 0755, true);
Log::info('Created transactions directory');
}
// Ensure transaction ID directory exists
$transactionDir = 'transactions/' . $transaction->id;
if (!Storage::disk('public')->exists($transactionDir)) {
Storage::disk('public')->makeDirectory($transactionDir, 0755, true);
Log::info('Created transaction directory: ' . $transactionDir);
}
// Ensure precheck directory exists
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory, 0755, true);
Log::info('Created precheck directory: ' . $directory);
}
// Store file in organized directory structure
$path = $file->storeAs($directory, $filename, 'public');
// Store file path
$data[$field] = $path;
// Store metadata
$data[$field . '_metadata'] = [
'original_name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_at' => now()->toISOString(),
'transaction_id' => $transaction->id,
'filename' => $filename,
];
Log::info('File uploaded successfully: ' . $path);
} catch (\Exception $e) {
// Log error for debugging
Log::error('File upload failed: ' . $e->getMessage(), [
'field' => $field,
'file' => $file->getClientOriginalName(),
'transaction_id' => $transaction->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return back()->withErrors(['error' => 'Gagal mengupload file: ' . $field . '. Error: ' . $e->getMessage()]);
}
}
}
try {
Precheck::create($data);
return redirect()->route('transaction')->with('success', 'Precheck berhasil disimpan');
} catch (\Exception $e) {
Log::error('Precheck creation failed: ' . $e->getMessage());
return back()->withErrors(['error' => 'Gagal menyimpan data precheck. Silakan coba lagi.']);
}
}
/**
* Ensure the base storage directory exists
*/
private function ensureStorageDirectoryExists()
{
$storagePath = storage_path('app/public');
if (!is_dir($storagePath)) {
if (!mkdir($storagePath, 0755, true)) {
Log::error('Failed to create storage directory: ' . $storagePath);
throw new \Exception('Cannot create storage directory: ' . $storagePath . '. Please run: php fix_permissions.php or manually create the directory.');
}
Log::info('Created storage directory: ' . $storagePath);
}
// Check if directory is writable
if (!is_writable($storagePath)) {
Log::error('Storage directory is not writable: ' . $storagePath);
throw new \Exception(
'Storage directory is not writable: ' . $storagePath . '. ' .
'Please run one of these commands from your project root: ' .
'1) php fix_permissions.php ' .
'2) chmod -R 775 storage/ ' .
'3) mkdir -p storage/app/public/transactions/{transaction_id}/precheck'
);
}
// Check if we can create subdirectories
$testDir = $storagePath . '/test_' . time();
if (!mkdir($testDir, 0755, true)) {
Log::error('Cannot create subdirectories in storage: ' . $storagePath);
throw new \Exception(
'Cannot create subdirectories in storage. ' .
'Please check permissions and run: php fix_permissions.php'
);
}
// Clean up test directory
rmdir($testDir);
Log::info('Storage directory is properly configured: ' . $storagePath);
}
}

View File

@@ -27,7 +27,7 @@ class OpnamesController extends Controller
$dealers = Dealer::all();
if($request->ajax()){
$data = Opname::query()
->with('user','dealer')
->with(['user','dealer', 'details.product'])
->orderBy('created_at', 'desc');
// Filter berdasarkan dealer yang dipilih
@@ -76,6 +76,46 @@ class OpnamesController extends Controller
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('stock_info', function ($row) {
// Use eager loaded details
$details = $row->details;
if ($details->isEmpty()) {
return '<span class="text-muted">Tidak ada data</span>';
}
$totalProducts = $details->count();
$matchingProducts = $details->where('difference', 0)->count();
$differentProducts = $totalProducts - $matchingProducts;
$info = [];
if ($matchingProducts > 0) {
$info[] = "<span class='text-success'><i class='fa fa-check-circle'></i> {$matchingProducts} sesuai</span>";
}
if ($differentProducts > 0) {
// Get more details about differences
$positiveDiff = $details->where('difference', '>', 0)->count();
$negativeDiff = $details->where('difference', '<', 0)->count();
$diffInfo = [];
if ($positiveDiff > 0) {
$diffInfo[] = "+{$positiveDiff}";
}
if ($negativeDiff > 0) {
$diffInfo[] = "-{$negativeDiff}";
}
$diffText = implode(', ', $diffInfo);
$info[] = "<span class='text-danger'><i class='fa fa-exclamation-triangle'></i> {$differentProducts} selisih ({$diffText})</span>";
}
// Add total products info
$info[] = "<small class='text-muted'>(Total: {$totalProducts} produk)</small>";
return '<div class="stock-info-cell">' . implode('<br>', $info) . '</div>';
})
->addColumn('action', function ($row) use ($menu) {
$btn = '<div class="d-flex">';
@@ -86,7 +126,7 @@ class OpnamesController extends Controller
return $btn;
})
->rawColumns(['action', 'status'])
->rawColumns(['action', 'status', 'stock_info'])
->make(true);
}
@@ -124,7 +164,7 @@ class OpnamesController extends Controller
$isTransactionForm = $request->has('form') && $request->form === 'opname';
if ($isTransactionForm) {
// Custom validation for transaction form
// Simplified validation for transaction form
$request->validate([
'dealer_id' => 'required|exists:dealers,id',
'user_id' => 'required|exists:users,id',
@@ -140,7 +180,7 @@ class OpnamesController extends Controller
'system_stock' => 'required|array',
'system_stock.*' => 'required|numeric|min:0',
'physical_stock' => 'required|array',
'physical_stock.*' => 'required|numeric|min:0'
'physical_stock.*' => 'nullable|numeric|min:0'
]);
// Process transaction form data with proper date parsing
@@ -199,19 +239,11 @@ class OpnamesController extends Controller
$physicalStocks = $request->physical_quantity;
}
// 2. Validasi minimal ada produk yang diisi (termasuk nilai 0)
// 2. Simplified validation - all products are valid, set defaults for empty physical stocks
$validProductIds = array_filter($productIds);
$validSystemStocks = array_filter($systemStocks, function($value) { return $value !== null && $value !== ''; });
$validPhysicalStocks = array_filter($physicalStocks, function($value) {
return $value !== null && $value !== '' && is_numeric($value);
});
if (empty($validProductIds) || count($validProductIds) === 0) {
throw new \Exception('Minimal harus ada satu produk yang diisi untuk opname.');
}
if (count($validPhysicalStocks) === 0) {
throw new \Exception('Minimal harus ada satu stock fisik yang diisi (termasuk nilai 0).');
throw new \Exception('Minimal harus ada satu produk untuk opname.');
}
// 3. Validasi duplikasi produk
@@ -283,19 +315,14 @@ class OpnamesController extends Controller
foreach ($productIds as $index => $productId) {
if (!$productId) continue;
// Skip only if physical stock is truly not provided (empty string or null)
// Accept 0 as valid input
if (!isset($physicalStocks[$index]) || $physicalStocks[$index] === '' || $physicalStocks[$index] === null) {
continue;
}
// Validate that physical stock is numeric (including 0)
if (!is_numeric($physicalStocks[$index])) {
continue;
// Set default value to 0 if physical stock is empty or invalid
$physicalStockValue = $physicalStocks[$index] ?? null;
if ($physicalStockValue === '' || $physicalStockValue === null || !is_numeric($physicalStockValue)) {
$physicalStockValue = 0;
}
$systemStock = floatval($systemStocks[$index] ?? 0);
$physicalStock = floatval($physicalStocks[$index]);
$physicalStock = floatval($physicalStockValue);
$difference = $physicalStock - $systemStock;
$processedCount++;
@@ -337,7 +364,7 @@ class OpnamesController extends Controller
// Validate we have at least one detail to insert
if (empty($details)) {
throw new \Exception('Tidak ada data stock fisik yang valid untuk diproses.');
throw new \Exception('Tidak ada data produk yang valid untuk diproses.');
}
// Bulk insert untuk performa lebih baik
@@ -371,13 +398,13 @@ class OpnamesController extends Controller
// Redirect back to transaction page with success message and tab indicator
return redirect()
->route('transaction')
->with('success', "Opname berhasil disimpan dan disetujui. {$processedCount} produk telah diproses.")
->with('success', "Opname berhasil disimpan. {$processedCount} produk telah diproses.")
->with('active_tab', 'opname');
} else {
// Redirect to opname index for regular form
return redirect()
->route('opnames.index')
->with('success', "Opname berhasil disimpan dan disetujui. {$processedCount} produk telah diproses.");
->with('success', "Opname berhasil disimpan. {$processedCount} produk telah diproses.");
}
} catch (\Illuminate\Validation\ValidationException $e) {

View File

@@ -35,6 +35,13 @@ class WorkController extends Controller
</a>';
}
// Set Prices Button
if(Gate::allows('view', $menu)) {
$btn .= '<a href="'. route('work.set-prices', ['work' => $row->work_id]) .'" class="btn btn-primary btn-sm" title="Set Harga per Dealer">
Harga
</a>';
}
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm" id="editWork'. $row->work_id .'" data-url="'. route('work.edit', $row->work_id) .'" data-action="'. route('work.update', $row->work_id) .'" onclick="editWork('. $row->work_id .')">
Edit
@@ -157,4 +164,20 @@ class WorkController extends Controller
return response()->json($response);
}
/**
* Show the form for setting prices per dealer for a specific work.
*
* @param \App\Models\Work $work
* @return \Illuminate\Http\Response
*/
public function showPrices(Work $work)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$dealers = \App\Models\Dealer::all();
return view('back.master.work_prices', compact('work', 'dealers'));
}
}

View File

@@ -0,0 +1,363 @@
<?php
namespace App\Http\Controllers;
use App\Models\Work;
use App\Models\Dealer;
use App\Models\WorkDealerPrice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Yajra\DataTables\DataTables;
class WorkDealerPriceController extends Controller
{
/**
* Display a listing of work prices for a specific work
*/
public function index(Request $request, Work $work)
{
if ($request->ajax()) {
$data = WorkDealerPrice::with(['dealer'])
->where('work_id', $work->id)
->select('work_dealer_prices.*');
return DataTables::of($data)
->addIndexColumn()
->addColumn('dealer_name', function($row) {
return $row->dealer->name;
})
->addColumn('formatted_price', function($row) {
return $row->formatted_price;
})
->addColumn('action', function($row) {
$btn = '<div class="d-flex flex-row gap-1">';
$btn .= '<button class="btn btn-warning btn-sm" onclick="editPrice(' . $row->id . ')" title="Edit Harga">
<i class="fa fa-edit"></i>
</button>';
$btn .= '<button class="btn btn-danger btn-sm" onclick="deletePrice(' . $row->id . ')" title="Hapus Harga">
<i class="fa fa-trash"></i>
</button>';
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])
->make(true);
}
$dealers = Dealer::all();
return view('back.master.work_prices', compact('work', 'dealers'));
}
/**
* Store a newly created price
*/
public function store(Request $request)
{
try {
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:3',
'is_active' => 'nullable|in:0,1',
], [
'work_id.required' => 'ID pekerjaan harus diisi',
'work_id.exists' => 'Pekerjaan tidak ditemukan',
'dealer_id.required' => 'ID dealer harus diisi',
'dealer_id.exists' => 'Dealer tidak ditemukan',
'price.required' => 'Harga harus diisi',
'price.numeric' => 'Harga harus berupa angka',
'price.min' => 'Harga minimal 0',
'currency.required' => 'Mata uang harus diisi',
'currency.max' => 'Mata uang maksimal 3 karakter',
'is_active.in' => 'Status aktif harus 0 atau 1',
]);
// Check if price already exists for this work-dealer combination (including soft deleted)
$existingPrice = WorkDealerPrice::withTrashed()
->where('work_id', $request->work_id)
->where('dealer_id', $request->dealer_id)
->first();
// Also check for active records to prevent duplicates
$activePrice = WorkDealerPrice::where('work_id', $request->work_id)
->where('dealer_id', $request->dealer_id)
->where('id', '!=', $existingPrice ? $existingPrice->id : 0)
->first();
if ($activePrice) {
return response()->json([
'status' => 422,
'message' => 'Harga untuk dealer ini sudah ada. Silakan edit harga yang sudah ada.'
], 422);
}
// Use database transaction to prevent race conditions
DB::beginTransaction();
try {
if ($existingPrice) {
if ($existingPrice->trashed()) {
// Restore soft deleted record and update
$existingPrice->restore();
}
// Update existing price
$existingPrice->update([
'price' => $request->price,
'currency' => $request->currency,
'is_active' => $request->has('is_active') ? (bool)$request->is_active : true,
]);
$price = $existingPrice;
$message = 'Harga berhasil diperbarui';
} else {
// Create new price
$price = WorkDealerPrice::create([
'work_id' => $request->work_id,
'dealer_id' => $request->dealer_id,
'price' => $request->price,
'currency' => $request->currency,
'is_active' => $request->has('is_active') ? (bool)$request->is_active : true,
]);
$message = 'Harga berhasil disimpan';
}
DB::commit();
return response()->json([
'status' => 200,
'data' => $price,
'message' => $message
]);
} catch (\Exception $e) {
DB::rollback();
throw $e;
}
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'status' => 422,
'message' => 'Validasi gagal',
'errors' => $e->errors()
], 422);
} catch (\Illuminate\Database\QueryException $e) {
// Handle unique constraint violation
if ($e->getCode() == 23000) {
return response()->json([
'status' => 422,
'message' => 'Harga untuk dealer ini sudah ada. Silakan edit harga yang sudah ada.'
], 422);
}
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan database: ' . $e->getMessage()
], 500);
} catch (\Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
/**
* Show the form for editing the specified price
*/
public function edit($id)
{
$price = WorkDealerPrice::with(['work', 'dealer'])->findOrFail($id);
return response()->json([
'status' => 200,
'data' => $price,
'message' => 'Data harga berhasil diambil'
]);
}
/**
* Update the specified price
*/
public function update(Request $request, $id)
{
$request->validate([
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:3',
'is_active' => 'boolean',
]);
$price = WorkDealerPrice::findOrFail($id);
$price->update($request->all());
return response()->json([
'status' => 200,
'message' => 'Harga berhasil diperbarui'
]);
}
/**
* Remove the specified price
*/
public function destroy($id)
{
try {
$price = WorkDealerPrice::findOrFail($id);
$price->delete(); // Soft delete
return response()->json([
'status' => 200,
'message' => 'Harga berhasil dihapus'
]);
} catch (\Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan saat menghapus harga: ' . $e->getMessage()
], 500);
}
}
/**
* Get price for specific work and dealer
*/
public function getPrice(Request $request)
{
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
]);
$price = WorkDealerPrice::getPriceForWorkAndDealer(
$request->work_id,
$request->dealer_id
);
return response()->json([
'status' => 200,
'data' => $price,
'message' => $price ? 'Harga ditemukan' : 'Harga tidak ditemukan'
]);
}
/**
* Toggle status of a price
*/
public function toggleStatus(Request $request, Work $work)
{
try {
$request->validate([
'dealer_id' => 'required|exists:dealers,id',
'is_active' => 'required|in:0,1,true,false',
], [
'dealer_id.required' => 'ID dealer harus diisi',
'dealer_id.exists' => 'Dealer tidak ditemukan',
'is_active.required' => 'Status aktif harus diisi',
'is_active.in' => 'Status aktif harus 0, 1, true, atau false',
]);
// Convert string values to boolean
$isActive = filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN);
// Find existing price (including soft deleted)
$existingPrice = WorkDealerPrice::withTrashed()
->where('work_id', $work->id)
->where('dealer_id', $request->dealer_id)
->first();
if (!$existingPrice) {
// Create new record with default price 0 if no record exists
$existingPrice = WorkDealerPrice::create([
'work_id' => $work->id,
'dealer_id' => $request->dealer_id,
'price' => 0,
'currency' => 'IDR',
'is_active' => $isActive,
]);
} else {
// Restore if soft deleted
if ($existingPrice->trashed()) {
$existingPrice->restore();
}
// Update status
$existingPrice->update([
'is_active' => $isActive
]);
}
return response()->json([
'status' => 200,
'data' => $existingPrice,
'message' => 'Status berhasil diubah menjadi ' . ($isActive ? 'Aktif' : 'Nonaktif')
]);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'status' => 422,
'message' => 'Validasi gagal',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
/**
* Bulk create prices for a work
*/
public function bulkCreate(Request $request, Work $work)
{
$request->validate([
'prices' => 'required|array',
'prices.*.dealer_id' => 'required|exists:dealers,id',
'prices.*.price' => 'required|numeric|min:0',
'prices.*.currency' => 'required|string|max:3',
]);
DB::beginTransaction();
try {
foreach ($request->prices as $priceData) {
// Check if price already exists
$existingPrice = WorkDealerPrice::where('work_id', $work->id)
->where('dealer_id', $priceData['dealer_id'])
->first();
if ($existingPrice) {
// Update existing price
$existingPrice->update([
'price' => $priceData['price'],
'currency' => $priceData['currency'],
'is_active' => true,
]);
} else {
// Create new price
WorkDealerPrice::create([
'work_id' => $work->id,
'dealer_id' => $priceData['dealer_id'],
'price' => $priceData['price'],
'currency' => $priceData['currency'],
'is_active' => true,
]);
}
}
DB::commit();
return response()->json([
'status' => 200,
'message' => 'Harga berhasil disimpan'
]);
} catch (\Exception $e) {
DB::rollback();
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\KPI;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class StoreKpiTargetRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => 'required|exists:users,id',
'target_value' => 'required|integer|min:1',
'description' => 'nullable|string|max:1000',
'is_active' => 'boolean'
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'user_id.required' => 'Mekanik harus dipilih',
'user_id.exists' => 'Mekanik yang dipilih tidak valid',
'target_value.required' => 'Target nilai harus diisi',
'target_value.integer' => 'Target nilai harus berupa angka',
'target_value.min' => 'Target nilai minimal 1',
'description.max' => 'Deskripsi maksimal 1000 karakter',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true)
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\KPI;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class UpdateKpiTargetRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => 'required|exists:users,id',
'target_value' => 'required|integer|min:1',
'description' => 'nullable|string|max:1000',
'is_active' => 'boolean'
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'user_id.required' => 'Mekanik harus dipilih',
'user_id.exists' => 'Mekanik yang dipilih tidak valid',
'target_value.required' => 'Target nilai harus diisi',
'target_value.integer' => 'Target nilai harus berupa angka',
'target_value.min' => 'Target nilai minimal 1',
'description.max' => 'Deskripsi maksimal 1000 karakter',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true)
]);
}
}

View File

@@ -48,4 +48,43 @@ class Dealer extends Model
->withPivot('quantity')
->withTimestamps();
}
/**
* Get all work prices for this dealer
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function workPrices()
{
return $this->hasMany(WorkDealerPrice::class);
}
/**
* Get price for specific work
*
* @param int $workId
* @return WorkDealerPrice|null
*/
public function getPriceForWork($workId)
{
return $this->workPrices()
->where('work_id', $workId)
->active()
->first();
}
/**
* Get all active work prices for this dealer
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activeWorkPrices()
{
return $this->hasMany(WorkDealerPrice::class)->active();
}
public function roles()
{
return $this->belongsToMany(Role::class, 'role_dealer');
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class KpiAchievement extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'kpi_target_id',
'target_value',
'actual_value',
'achievement_percentage',
'year',
'month',
'notes'
];
protected $casts = [
'achievement_percentage' => 'decimal:2',
'year' => 'integer',
'month' => 'integer'
];
protected $attributes = [
'actual_value' => 0,
'achievement_percentage' => 0
];
/**
* Get the user that owns the achievement
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the KPI target for this achievement
*/
public function kpiTarget(): BelongsTo
{
return $this->belongsTo(KpiTarget::class);
}
/**
* Scope to get achievements for specific year and month
*/
public function scopeForPeriod($query, $year, $month)
{
return $query->where('year', $year)->where('month', $month);
}
/**
* Scope to get achievements for current month
*/
public function scopeCurrentMonth($query)
{
return $query->where('year', now()->year)->where('month', now()->month);
}
/**
* Scope to get achievements within year range
*/
public function scopeWithinYearRange($query, $startYear, $endYear)
{
return $query->whereBetween('year', [$startYear, $endYear]);
}
/**
* Scope to get achievements for specific user
*/
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Get achievement status
*/
public function getStatusAttribute(): string
{
if ($this->achievement_percentage >= 100) {
return 'exceeded';
} elseif ($this->achievement_percentage >= 80) {
return 'good';
} elseif ($this->achievement_percentage >= 60) {
return 'fair';
} else {
return 'poor';
}
}
/**
* Get status color for display
*/
public function getStatusColorAttribute(): string
{
return match($this->status) {
'exceeded' => 'success',
'good' => 'info',
'fair' => 'warning',
'poor' => 'danger',
default => 'secondary'
};
}
/**
* Get period display name (e.g., "Januari 2024")
*/
public function getPeriodDisplayName(): string
{
$monthNames = [
1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April',
5 => 'Mei', 6 => 'Juni', 7 => 'Juli', 8 => 'Agustus',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
];
return $monthNames[$this->month] . ' ' . $this->year;
}
/**
* Get period start date
*/
public function getPeriodStartDate(): Carbon
{
return Carbon::createFromDate($this->year, $this->month, 1);
}
/**
* Get period end date
*/
public function getPeriodEndDate(): Carbon
{
return Carbon::createFromDate($this->year, $this->month, 1)->endOfMonth();
}
/**
* Get target value (from stored value or from relation)
*/
public function getTargetValueAttribute(): int
{
// Return stored target value if available, otherwise get from relation
return $this->target_value ?? $this->kpiTarget?->target_value ?? 0;
}
/**
* Get current target value from relation (for comparison)
*/
public function getCurrentTargetValueAttribute(): int
{
return $this->kpiTarget?->target_value ?? 0;
}
/**
* Check if stored target value differs from current target value
*/
public function hasTargetValueChanged(): bool
{
return $this->target_value !== $this->current_target_value;
}
}

61
app/Models/KpiTarget.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class KpiTarget extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'target_value',
'is_active',
'description'
];
protected $casts = [
'is_active' => 'boolean'
];
protected $attributes = [
'is_active' => true
];
/**
* Get the user that owns the KPI target
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the achievements for this target
*/
public function achievements(): HasMany
{
return $this->hasMany(KpiAchievement::class);
}
/**
* Scope to get active targets
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Check if target is currently active
*/
public function isCurrentlyActive(): bool
{
return $this->is_active;
}
}

210
app/Models/Postcheck.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Postcheck extends Model
{
use HasFactory;
protected $fillable = [
'transaction_id',
'postcheck_by',
'postcheck_at',
'police_number',
'spk_number',
'front_image',
'front_image_metadata',
'kilometer',
'pressure_high',
'pressure_low',
'cabin_temperature',
'cabin_temperature_image',
'cabin_temperature_image_metadata',
'ac_condition',
'ac_image',
'ac_image_metadata',
'blower_condition',
'blower_image',
'blower_image_metadata',
'evaporator_condition',
'evaporator_image',
'evaporator_image_metadata',
'compressor_condition',
'postcheck_notes'
];
protected $casts = [
'postcheck_at' => 'datetime',
'kilometer' => 'decimal:2',
'pressure_high' => 'decimal:2',
'pressure_low' => 'decimal:2',
'cabin_temperature' => 'decimal:2',
'front_image_metadata' => 'array',
'cabin_temperature_image_metadata' => 'array',
'ac_image_metadata' => 'array',
'blower_image_metadata' => 'array',
'evaporator_image_metadata' => 'array',
];
/**
* Get the transaction associated with the Postcheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
/**
* Get the user who performed the postcheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function postcheckBy()
{
return $this->belongsTo(User::class, 'postcheck_by');
}
/**
* Get front image URL
*/
public function getFrontImageUrlAttribute()
{
return $this->front_image ? Storage::disk('public')->url($this->front_image) : null;
}
/**
* Get cabin temperature image URL
*/
public function getCabinTemperatureImageUrlAttribute()
{
return $this->cabin_temperature_image ? Storage::disk('public')->url($this->cabin_temperature_image) : null;
}
/**
* Get AC image URL
*/
public function getAcImageUrlAttribute()
{
return $this->ac_image ? Storage::disk('public')->url($this->ac_image) : null;
}
/**
* Get blower image URL
*/
public function getBlowerImageUrlAttribute()
{
return $this->blower_image ? Storage::disk('public')->url($this->blower_image) : null;
}
/**
* Get evaporator image URL
*/
public function getEvaporatorImageUrlAttribute()
{
return $this->evaporator_image ? Storage::disk('public')->url($this->evaporator_image) : null;
}
/**
* Delete associated files when model is deleted
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($postcheck) {
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($postcheck->$field && Storage::disk('public')->exists($postcheck->$field)) {
Storage::disk('public')->delete($postcheck->$field);
}
}
});
}
/**
* Get the AC condition options
*
* @return array
*/
public static function getAcConditionOptions()
{
return ['sudah dikerjakan', 'sudah diganti'];
}
/**
* Get the blower condition options
*
* @return array
*/
public static function getBlowerConditionOptions()
{
return ['sudah dibersihkan atau dicuci', 'sudah diganti'];
}
/**
* Get the evaporator condition options
*
* @return array
*/
public static function getEvaporatorConditionOptions()
{
return ['sudah dikerjakan', 'sudah diganti'];
}
/**
* Get the compressor condition options
*
* @return array
*/
public static function getCompressorConditionOptions()
{
return ['sudah dikerjakan', 'sudah diganti'];
}
/**
* Scope to filter by transaction
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $transactionId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByTransaction($query, $transactionId)
{
return $query->where('transaction_id', $transactionId);
}
/**
* Scope to filter by user who performed postcheck
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByUser($query, $userId)
{
return $query->where('postcheck_by', $userId);
}
/**
* Scope to filter by date range
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $startDate
* @param string $endDate
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('postcheck_at', [$startDate, $endDate]);
}
}

210
app/Models/Precheck.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Precheck extends Model
{
use HasFactory;
protected $fillable = [
'transaction_id',
'precheck_by',
'precheck_at',
'police_number',
'spk_number',
'front_image',
'front_image_metadata',
'kilometer',
'pressure_high',
'pressure_low',
'cabin_temperature',
'cabin_temperature_image',
'cabin_temperature_image_metadata',
'ac_condition',
'ac_image',
'ac_image_metadata',
'blower_condition',
'blower_image',
'blower_image_metadata',
'evaporator_condition',
'evaporator_image',
'evaporator_image_metadata',
'compressor_condition',
'precheck_notes'
];
protected $casts = [
'precheck_at' => 'datetime',
'kilometer' => 'decimal:2',
'pressure_high' => 'decimal:2',
'pressure_low' => 'decimal:2',
'cabin_temperature' => 'decimal:2',
'front_image_metadata' => 'array',
'cabin_temperature_image_metadata' => 'array',
'ac_image_metadata' => 'array',
'blower_image_metadata' => 'array',
'evaporator_image_metadata' => 'array',
];
/**
* Get the transaction associated with the Precheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
/**
* Get the user who performed the precheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function precheckBy()
{
return $this->belongsTo(User::class, 'precheck_by');
}
/**
* Get front image URL
*/
public function getFrontImageUrlAttribute()
{
return $this->front_image ? Storage::disk('public')->url($this->front_image) : null;
}
/**
* Get cabin temperature image URL
*/
public function getCabinTemperatureImageUrlAttribute()
{
return $this->cabin_temperature_image ? Storage::disk('public')->url($this->cabin_temperature_image) : null;
}
/**
* Get AC image URL
*/
public function getAcImageUrlAttribute()
{
return $this->ac_image ? Storage::disk('public')->url($this->ac_image) : null;
}
/**
* Get blower image URL
*/
public function getBlowerImageUrlAttribute()
{
return $this->blower_image ? Storage::disk('public')->url($this->blower_image) : null;
}
/**
* Get evaporator image URL
*/
public function getEvaporatorImageUrlAttribute()
{
return $this->evaporator_image ? Storage::disk('public')->url($this->evaporator_image) : null;
}
/**
* Delete associated files when model is deleted
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($precheck) {
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($precheck->$field && Storage::disk('public')->exists($precheck->$field)) {
Storage::disk('public')->delete($precheck->$field);
}
}
});
}
/**
* Get the AC condition options
*
* @return array
*/
public static function getAcConditionOptions()
{
return ['kotor', 'rusak', 'baik', 'tidak ada'];
}
/**
* Get the blower condition options
*
* @return array
*/
public static function getBlowerConditionOptions()
{
return ['kotor', 'rusak', 'baik', 'tidak ada'];
}
/**
* Get the evaporator condition options
*
* @return array
*/
public static function getEvaporatorConditionOptions()
{
return ['kotor', 'berlendir', 'bocor', 'bersih'];
}
/**
* Get the compressor condition options
*
* @return array
*/
public static function getCompressorConditionOptions()
{
return ['kotor', 'rusak', 'baik', 'tidak ada'];
}
/**
* Scope to filter by transaction
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $transactionId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByTransaction($query, $transactionId)
{
return $query->where('transaction_id', $transactionId);
}
/**
* Scope to filter by user who performed precheck
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByUser($query, $userId)
{
return $query->where('precheck_by', $userId);
}
/**
* Scope to filter by date range
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $startDate
* @param string $endDate
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('precheck_at', [$startDate, $endDate]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class Role extends Model
{
@@ -11,4 +12,19 @@ class Role extends Model
protected $fillable = [
'name'
];
public function dealers()
{
return $this->belongsToMany(Dealer::class, 'role_dealer');
}
public function users()
{
return $this->hasMany(User::class);
}
public function hasDealer($dealerId)
{
return $this->dealers()->where('dealers.id', $dealerId)->whereNull('dealers.deleted_at')->exists();
}
}

View File

@@ -10,7 +10,12 @@ class Transaction extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
"user_id", "user_sa_id", "work_id", "form", "spk", "police_number", "warranty", "date", "qty", "status", "dealer_id"
"user_id", "user_sa_id", "work_id", "form", "spk", "police_number", "warranty", "date", "qty", "status", "dealer_id",
"claimed_at", "claimed_by"
];
protected $casts = [
'claimed_at' => 'datetime',
];
/**
@@ -52,4 +57,24 @@ class Transaction extends Model
{
return $this->belongsTo(User::class, 'user_sa_id');
}
/**
* Get the precheck associated with the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function precheck()
{
return $this->hasOne(Precheck::class);
}
/**
* Get the postcheck associated with the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function postcheck()
{
return $this->hasOne(Postcheck::class);
}
}

View File

@@ -132,4 +132,193 @@ class User extends Authenticatable
return false;
}
}
/**
* Get all KPI targets for the User
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function kpiTargets()
{
return $this->hasMany(KpiTarget::class);
}
/**
* Get all KPI achievements for the User
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function kpiAchievements()
{
return $this->hasMany(KpiAchievement::class);
}
/**
* Check if user is mechanic
*
* @return bool
*/
public function isMechanic()
{
return $this->hasRole('mechanic');
}
/**
* Get current KPI target (no longer filtered by year/month)
*
* @return KpiTarget|null
*/
public function getCurrentKpiTarget()
{
return $this->kpiTargets()
->where('is_active', true)
->first();
}
/**
* Get KPI achievement for specific year and month
*
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function getKpiAchievement($year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
return $this->kpiAchievements()
->where('year', $year)
->where('month', $month)
->first();
}
public function accessibleDealers()
{
if (!$this->role_id) {
return collect();
}
// Load role with dealers
if (!$this->relationLoaded('role')) {
$this->load('role.dealers');
}
// If user has specific dealer_id, check if role allows access
if ($this->dealer_id) {
if ($this->role && $this->role->hasDealer($this->dealer_id)) {
return Dealer::where('id', $this->dealer_id)->get();
}
return collect();
}
// If no specific dealer_id, return all dealers accessible by role
return $this->role ? $this->role->dealers : collect();
}
public function canAccessDealer($dealerId)
{
if (!$this->role_id) {
return false;
}
// Load role with dealers
if (!$this->relationLoaded('role')) {
$this->load('role.dealers');
}
return $this->role && $this->role->hasDealer($dealerId);
}
public function getPrimaryDealer()
{
if ($this->dealer_id && $this->canAccessDealer($this->dealer_id)) {
return $this->dealer;
}
return null;
}
/**
* Get all accessible menus for a specific role
*
* @param int $roleId
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getAccessibleMenus($roleId)
{
return \App\Models\Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $roleId)
->where('privileges.view', 1)
->select('menus.*', 'privileges.view', 'privileges.create', 'privileges.update', 'privileges.delete')
->orderBy('menus.id')
->get();
}
/**
* Get accessible menus for current user
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getMyAccessibleMenus()
{
if (!$this->role_id) {
return collect();
}
return self::getAccessibleMenus($this->role_id);
}
/**
* Check if user can access specific menu
*
* @param string $menuLink
* @return bool
*/
public function canAccessMenu($menuLink)
{
if (!$this->role_id) {
return false;
}
return \App\Models\Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $this->role_id)
->where('menus.link', $menuLink)
->where('privileges.view', 1)
->exists();
}
/**
* Check if role can access specific menu (static method)
*
* @param int $roleId
* @param string $menuLink
* @return bool
*/
public static function roleCanAccessMenu($roleId, $menuLink)
{
return \App\Models\Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $roleId)
->where('menus.link', $menuLink)
->where('privileges.view', 1)
->exists();
}
/**
* Get all prechecks performed by this user
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function prechecks()
{
return $this->hasMany(Precheck::class, 'precheck_by');
}
/**
* Get all postchecks performed by this user
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function postchecks()
{
return $this->hasMany(Postcheck::class, 'postcheck_by');
}
}

View File

@@ -54,4 +54,52 @@ class Work extends Model
{
return $this->belongsTo(Category::class);
}
/**
* Get all dealer prices for this work
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function dealerPrices()
{
return $this->hasMany(WorkDealerPrice::class);
}
/**
* Get price for specific dealer
*
* @param int $dealerId
* @return WorkDealerPrice|null
*/
public function getPriceForDealer($dealerId)
{
return $this->dealerPrices()
->where('dealer_id', $dealerId)
->active()
->first();
}
/**
* Get price for specific dealer (including soft deleted)
*
* @param int $dealerId
* @return WorkDealerPrice|null
*/
public function getPriceForDealerWithTrashed($dealerId)
{
return $this->dealerPrices()
->withTrashed()
->where('dealer_id', $dealerId)
->first();
}
/**
* Get all active prices for this work
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activeDealerPrices()
{
return $this->hasMany(WorkDealerPrice::class)->active();
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class WorkDealerPrice extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'work_id',
'dealer_id',
'price',
'currency',
'is_active'
];
protected $casts = [
'price' => 'decimal:2',
'is_active' => 'boolean',
];
/**
* Get the work associated with the price
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function work()
{
return $this->belongsTo(Work::class);
}
/**
* Get the dealer associated with the price
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
/**
* Scope to get only active prices
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Get formatted price with currency
*
* @return string
*/
public function getFormattedPriceAttribute()
{
return number_format($this->price, 0, ',', '.') . ' ' . $this->currency;
}
/**
* Get price for specific work and dealer
*
* @param int $workId
* @param int $dealerId
* @return WorkDealerPrice|null
*/
public static function getPriceForWorkAndDealer($workId, $dealerId)
{
return static::where('work_id', $workId)
->where('dealer_id', $dealerId)
->active()
->first();
}
}

View File

@@ -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) {

453
app/Services/KpiService.php Normal file
View File

@@ -0,0 +1,453 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Transaction;
use App\Models\KpiTarget;
use App\Models\KpiAchievement;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class KpiService
{
/**
* Calculate KPI achievement for a user
*
* @param User $user
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function calculateKpiAchievement(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
// Get current KPI target (no longer filtered by year/month)
$kpiTarget = $user->kpiTargets()
->where('is_active', true)
->first();
if (!$kpiTarget) {
Log::info("No KPI target found for user {$user->id}");
return null;
}
// Calculate actual value based on month
$actualValue = $this->getActualWorkCount($user, $year, $month);
// Calculate percentage
$achievementPercentage = $kpiTarget->target_value > 0
? ($actualValue / $kpiTarget->target_value) * 100
: 0;
// Save or update achievement with target value stored directly
return KpiAchievement::updateOrCreate(
[
'user_id' => $user->id,
'year' => $year,
'month' => $month
],
[
'kpi_target_id' => $kpiTarget->id,
'target_value' => $kpiTarget->target_value, // Store target value directly for historical tracking
'actual_value' => $actualValue,
'achievement_percentage' => $achievementPercentage
]
);
}
/**
* Get actual work count for a user in specific month
*
* @param User $user
* @param int $year
* @param int $month
* @return int
*/
private function getActualWorkCount(User $user, $year, $month)
{
return Transaction::where('user_id', $user->id)
->whereIn('status', [0, 1]) // pending (0) and completed (1)
->whereYear('date', $year)
->whereMonth('date', $month)
->sum('qty');
}
/**
* Generate KPI report for a user
*
* @param User $user
* @param int|null $year
* @param int|null $month
* @return array
*/
public function generateKpiReport(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$achievements = $user->kpiAchievements()
->where('year', $year)
->where('month', $month)
->orderBy('month')
->get();
$target = $user->kpiTargets()
->where('is_active', true)
->first();
return [
'user' => $user,
'target' => $target,
'achievements' => $achievements,
'summary' => $this->calculateSummary($achievements),
'period' => [
'year' => $year,
'month' => $month,
'period_name' => $this->getMonthName($month) . ' ' . $year
]
];
}
/**
* Calculate summary statistics for achievements
*
* @param \Illuminate\Database\Eloquent\Collection $achievements
* @return array
*/
private function calculateSummary($achievements)
{
if ($achievements->isEmpty()) {
return [
'total_target' => 0,
'total_actual' => 0,
'average_achievement' => 0,
'best_period' => null,
'worst_period' => null,
'total_periods' => 0,
'achievement_rate' => 0
];
}
$totalTarget = $achievements->sum('target_value');
$totalActual = $achievements->sum('actual_value');
$averageAchievement = $achievements->avg('achievement_percentage');
$totalPeriods = $achievements->count();
$achievementRate = $totalPeriods > 0 ? ($achievements->where('achievement_percentage', '>=', 100)->count() / $totalPeriods) * 100 : 0;
$bestPeriod = $achievements->sortByDesc('achievement_percentage')->first();
$worstPeriod = $achievements->sortBy('achievement_percentage')->first();
return [
'total_target' => $totalTarget,
'total_actual' => $totalActual,
'average_achievement' => round($averageAchievement, 2),
'best_period' => $bestPeriod,
'worst_period' => $worstPeriod,
'total_periods' => $totalPeriods,
'achievement_rate' => round($achievementRate, 2)
];
}
/**
* Get KPI statistics for all mechanics
*
* @param int|null $year
* @param int|null $month
* @return array
*/
public function getMechanicsKpiStats($year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$mechanics = User::whereHas('role', function($query) {
$query->where('name', 'mechanic');
})->get();
$stats = [];
foreach ($mechanics as $mechanic) {
$report = $this->generateKpiReport($mechanic, $year, $month);
$stats[] = [
'user' => $mechanic,
'summary' => $report['summary'],
'target' => $report['target']
];
}
return $stats;
}
/**
* Auto-calculate KPI achievements for all mechanics
*
* @param int|null $year
* @param int|null $month
* @return array
*/
public function autoCalculateAllMechanics($year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$mechanics = User::whereHas('role', function($query) {
$query->where('name', 'mechanic');
})->get();
$results = [];
foreach ($mechanics as $mechanic) {
try {
$achievement = $this->calculateKpiAchievement($mechanic, $year, $month);
$results[] = [
'user_id' => $mechanic->id,
'user_name' => $mechanic->name,
'success' => true,
'achievement' => $achievement
];
} catch (\Exception $e) {
Log::error("Failed to calculate KPI for user {$mechanic->id}: " . $e->getMessage());
$results[] = [
'user_id' => $mechanic->id,
'user_name' => $mechanic->name,
'success' => false,
'error' => $e->getMessage()
];
}
}
return $results;
}
/**
* Get KPI trend data for chart
*
* @param User $user
* @param int $months
* @return array
*/
public function getKpiTrendData(User $user, $months = 12)
{
$endDate = now();
$startDate = $endDate->copy()->subMonths($months);
$achievements = $user->kpiAchievements()
->where(function($query) use ($startDate, $endDate) {
$query->where(function($q) use ($startDate, $endDate) {
$q->where('year', '>', $startDate->year)
->orWhere(function($subQ) use ($startDate, $endDate) {
$subQ->where('year', $startDate->year)
->where('month', '>=', $startDate->month);
});
})
->where(function($q) use ($endDate) {
$q->where('year', '<', $endDate->year)
->orWhere(function($subQ) use ($endDate) {
$subQ->where('year', $endDate->year)
->where('month', '<=', $endDate->month);
});
});
})
->orderBy('year')
->orderBy('month')
->get();
$trendData = [];
foreach ($achievements as $achievement) {
$trendData[] = [
'period' => $achievement->getPeriodDisplayName(),
'target' => $achievement->target_value,
'actual' => $achievement->actual_value,
'percentage' => $achievement->achievement_percentage,
'status' => $achievement->status
];
}
return $trendData;
}
/**
* Get month name in Indonesian
*
* @param int $month
* @return string
*/
private function getMonthName($month)
{
$monthNames = [
1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April',
5 => 'Mei', 6 => 'Juni', 7 => 'Juli', 8 => 'Agustus',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
];
return $monthNames[$month] ?? 'Unknown';
}
/**
* Get KPI summary for dashboard
*
* @param User $user
* @return array
*/
public function getKpiSummary(User $user)
{
$currentYear = now()->year;
$currentMonth = now()->month;
// Get current month achievement
$currentAchievement = $user->kpiAchievements()
->where('year', $currentYear)
->where('month', $currentMonth)
->first();
// Get current month target (no longer filtered by year/month)
$currentTarget = $user->kpiTargets()
->where('is_active', true)
->first();
// Get last 6 months achievements
$recentAchievements = $user->kpiAchievements()
->where(function($query) use ($currentYear, $currentMonth) {
$query->where('year', '>', $currentYear - 1)
->orWhere(function($q) use ($currentYear, $currentMonth) {
$q->where('year', $currentYear)
->where('month', '>=', max(1, $currentMonth - 5));
});
})
->orderBy('year', 'desc')
->orderBy('month', 'desc')
->limit(6)
->get();
return [
'current_achievement' => $currentAchievement,
'current_target' => $currentTarget,
'recent_achievements' => $recentAchievements,
'current_percentage' => $currentAchievement ? $currentAchievement->achievement_percentage : 0,
'is_on_track' => $currentAchievement ? $currentAchievement->achievement_percentage >= 100 : false
];
}
/**
* Get claimed transactions count for a mechanic
*
* @param User $user
* @param int|null $year
* @param int|null $month
* @return int
*/
public function getClaimedTransactionsCount(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
return Transaction::where('claimed_by', $user->id)
->whereNotNull('claimed_at')
->whereYear('claimed_at', $year)
->whereMonth('claimed_at', $month)
->sum('qty');
}
/**
* Calculate KPI achievement including claimed transactions
*
* @param User $user
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function calculateKpiAchievementWithClaims(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
// Get current KPI target
$kpiTarget = $user->kpiTargets()
->where('is_active', true)
->first();
if (!$kpiTarget) {
Log::info("No KPI target found for user {$user->id}");
return null;
}
// Calculate actual value including claimed transactions
$actualValue = $this->getActualWorkCountWithClaims($user, $year, $month);
// Calculate percentage
$achievementPercentage = $kpiTarget->target_value > 0
? ($actualValue / $kpiTarget->target_value) * 100
: 0;
// Save or update achievement
return KpiAchievement::updateOrCreate(
[
'user_id' => $user->id,
'year' => $year,
'month' => $month
],
[
'kpi_target_id' => $kpiTarget->id,
'target_value' => $kpiTarget->target_value,
'actual_value' => $actualValue,
'achievement_percentage' => $achievementPercentage
]
);
}
/**
* Get actual work count including claimed transactions
*
* @param User $user
* @param int $year
* @param int $month
* @return int
*/
private function getActualWorkCountWithClaims(User $user, $year, $month)
{
// Get transactions created by the user (including pending and completed)
$createdTransactions = Transaction::where('user_id', $user->id)
->whereIn('status', [0, 1]) // pending (0) and completed (1)
->whereYear('date', $year)
->whereMonth('date', $month)
->sum('qty');
// Get transactions claimed by the user
$claimedTransactions = Transaction::where('claimed_by', $user->id)
->whereNotNull('claimed_at')
->whereYear('claimed_at', $year)
->whereMonth('claimed_at', $month)
->sum('qty');
return $createdTransactions + $claimedTransactions;
}
/**
* Get KPI summary including claimed transactions
*
* @param User $user
* @return array
*/
public function getKpiSummaryWithClaims(User $user)
{
$currentYear = now()->year;
$currentMonth = now()->month;
// Calculate current month achievement including claims
$currentAchievement = $this->calculateKpiAchievementWithClaims($user, $currentYear, $currentMonth);
// Get current month target
$currentTarget = $user->kpiTargets()
->where('is_active', true)
->first();
return [
'current_achievement' => $currentAchievement,
'current_target' => $currentTarget,
'current_percentage' => $currentAchievement ? $currentAchievement->achievement_percentage : 0,
'is_on_track' => $currentAchievement ? $currentAchievement->achievement_percentage >= 100 : false
];
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Services;
use App\Models\Product;
use App\Models\Dealer;
use App\Models\Stock;
use App\Models\StockLog;
use App\Models\Role;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Auth;
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 dealers based on user role
$dealers = $this->getDealersBasedOnUserRole();
// 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 dealers based on user role
$dealers = $this->getDealersBasedOnUserRole();
// 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
];
}
/**
* Get dealers based on logged-in user's role
*/
public function getDealersBasedOnUserRole()
{
// Get current authenticated user
$user = Auth::user();
if (!$user) {
Log::warning('No authenticated user found, returning all dealers');
return Dealer::whereNull('deleted_at')->orderBy('name')->get();
}
Log::info('Getting dealers for user:', [
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers - return pivot dealers only
Log::info('Admin role with pivot dealers, returning pivot dealers only');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
Log::info('Returning pivot dealers for admin:', $dealers->pluck('name')->toArray());
return $dealers;
} else {
// Admin without pivot dealers - return all dealers
Log::info('Admin role without pivot dealers, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get();
Log::info('Returning all dealers for admin:', $allDealers->pluck('name')->toArray());
return $allDealers;
}
}
// Non-admin role - return dealers from role pivot
if ($role->dealers->count() > 0) {
Log::info('Non-admin role with dealers, returning role dealers');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
Log::info('Returning dealers from role:', $dealers->pluck('name')->toArray());
return $dealers;
}
}
}
// If user has specific dealer_id but no role dealers, check if they can access their dealer_id
if ($user->dealer_id) {
Log::info('User has specific dealer_id:', ['dealer_id' => $user->dealer_id]);
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role && $role->hasDealer($user->dealer_id)) {
Log::info('User can access their dealer_id, returning single dealer');
$dealer = Dealer::where('id', $user->dealer_id)->whereNull('deleted_at')->orderBy('name')->get();
Log::info('Returning dealer:', $dealer->pluck('name')->toArray());
return $dealer;
} else {
Log::info('User cannot access their dealer_id');
}
}
Log::info('User has dealer_id but no role or no access, returning empty');
return collect();
}
// Fallback: return all dealers if no restrictions
Log::info('No restrictions found, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get();
Log::info('Returning all dealers:', $allDealers->pluck('name')->toArray());
return $allDealers;
}
/**
* Check if role is admin type (should show all dealers if no pivot)
*/
private function isAdminRole($role)
{
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
Log::info('Role identified as admin type:', ['role_name' => $role->name]);
return true;
}
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
Log::info('Role contains "area", treating as area role (use pivot dealers):', ['role_name' => $role->name]);
return false;
}
Log::info('Role is not admin type:', ['role_name' => $role->name]);
return false;
}
/**
* 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;
}
}
}

View File

@@ -0,0 +1,798 @@
<?php
namespace App\Services;
use App\Models\Work;
use App\Models\User;
use App\Models\Transaction;
use App\Models\Dealer;
use App\Models\Role;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TechnicianReportService
{
/**
* Get technician report data for all works and mechanics on a specific date range
*/
public function getTechnicianReportData($dealerId = null, $startDate = null, $endDate = null)
{
try {
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
// Validate dealer access
if ($dealerId) {
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
} else {
// User has dealer_id but no role, can only access their dealer
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
}
}
Log::info('Getting technician report data', [
'user_id' => $user->id,
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate
]);
// Get all works with category in single query
$works = Work::with(['category'])
->orderBy('name')
->get();
// Get mechanics based on dealer and role access
$mechanics = $this->getMechanicsByDealer($dealerId);
Log::info('Mechanics found for report:', [
'count' => $mechanics->count(),
'dealer_id_filter' => $dealerId,
'mechanics' => $mechanics->map(function($mechanic) {
$roleName = 'Unknown';
if ($mechanic->role) {
$roleName = is_string($mechanic->role) ? $mechanic->role : $mechanic->role->name;
}
return [
'id' => $mechanic->id,
'name' => $mechanic->name,
'role_id' => $mechanic->role_id,
'role_name' => $roleName,
'dealer_id' => $mechanic->dealer_id
];
})
]);
// Get all transaction data in single optimized query
$transactions = $this->getOptimizedTransactionData($dealerId, $startDate, $endDate, $mechanics->pluck('id'), $works->pluck('id'));
Log::info('Transaction data:', [
'transaction_count' => count($transactions),
'sample_transactions' => array_slice($transactions, 0, 5, true),
'dealer_id_filter' => $dealerId,
'is_admin_with_pivot' => $user->role_id ? (function() use ($user) {
$role = Role::with('dealers')->find($user->role_id);
return $role && $this->isAdminRole($role) && $role->dealers->count() > 0;
})() : false
]);
$data = [];
foreach ($works as $work) {
$row = [
'work_id' => $work->id,
'work_name' => $work->name,
'work_code' => $work->shortname,
'category_name' => $work->category ? $work->category->name : '-',
'total_tickets' => 0
];
// Calculate totals for each mechanic
foreach ($mechanics as $mechanic) {
$key = $work->id . '_' . $mechanic->id;
$mechanicData = $transactions[$key] ?? ['total' => 0, 'completed' => 0, 'pending' => 0];
$row["mechanic_{$mechanic->id}_total"] = $mechanicData['total'];
// Add to totals
$row['total_tickets'] += $mechanicData['total'];
}
$data[] = $row;
}
Log::info('Final data prepared:', [
'data_count' => count($data),
'sample_data' => array_slice($data, 0, 2)
]);
return [
'data' => $data,
'mechanics' => $mechanics,
'works' => $works
];
} catch (\Exception $e) {
Log::error('Error in getTechnicianReportData: ' . $e->getMessage(), [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'trace' => $e->getTraceAsString()
]);
// Return empty data structure but with proper format
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
}
/**
* Get optimized transaction data in single query
*/
private function getOptimizedTransactionData($dealerId = null, $startDate = null, $endDate = null, $mechanicIds = null, $workIds = null)
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return [];
}
// Validate dealer access
if ($dealerId) {
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [];
}
} else {
// User has dealer_id but no role, can only access their dealer
return [];
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [];
}
}
}
Log::info('Getting optimized transaction data', [
'user_id' => $user->id,
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate
]);
$query = Transaction::select(
'work_id',
'user_id',
'status',
DB::raw('COUNT(*) as count')
)
->groupBy('work_id', 'user_id', 'status');
if ($dealerId) {
$query->where('dealer_id', $dealerId);
} else if ($user->role_id) {
// Check if admin with pivot dealers and "Semua Dealer" selected
$role = Role::with('dealers')->find($user->role_id);
if ($role && $this->isAdminRole($role) && $role->dealers->count() > 0) {
// Admin with pivot dealers and "Semua Dealer" selected - filter by pivot dealers
$accessibleDealerIds = $role->dealers->pluck('id');
$query->whereIn('dealer_id', $accessibleDealerIds);
Log::info('Admin with pivot dealers, filtering transactions by pivot dealer IDs:', $accessibleDealerIds->toArray());
}
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
if ($mechanicIds && $mechanicIds->count() > 0) {
$query->whereIn('user_id', $mechanicIds);
}
if ($workIds && $workIds->count() > 0) {
$query->whereIn('work_id', $workIds);
}
// Remove index hint that doesn't exist
$results = $query->get();
Log::info('Transaction query results', [
'results_count' => $results->count()
]);
// Organize data by work_id_user_id key
$organizedData = [];
foreach ($results as $result) {
$key = $result->work_id . '_' . $result->user_id;
if (!isset($organizedData[$key])) {
$organizedData[$key] = [
'total' => 0,
'completed' => 0,
'pending' => 0
];
}
$organizedData[$key]['total'] += $result->count;
if ($result->status == 1) {
$organizedData[$key]['completed'] += $result->count;
} else {
$organizedData[$key]['pending'] += $result->count;
}
}
return $organizedData;
}
/**
* Get total ticket count for a specific work and mechanic (legacy method for backward compatibility)
*/
private function getTicketCount($workId, $mechanicId, $dealerId = null, $startDate = null, $endDate = null)
{
$query = Transaction::where('work_id', $workId)
->where('user_id', $mechanicId);
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
return $query->count();
}
/**
* Get completed ticket count for a specific work and mechanic (legacy method for backward compatibility)
*/
private function getCompletedTicketCount($workId, $mechanicId, $dealerId = null, $startDate = null, $endDate = null)
{
$query = Transaction::where('work_id', $workId)
->where('user_id', $mechanicId)
->where('status', 1); // Assuming status 1 is completed
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
return $query->count();
}
/**
* Get pending ticket count for a specific work and mechanic (legacy method for backward compatibility)
*/
private function getPendingTicketCount($workId, $mechanicId, $dealerId = null, $startDate = null, $endDate = null)
{
$query = Transaction::where('work_id', $workId)
->where('user_id', $mechanicId)
->where('status', 0); // Assuming status 0 is pending
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
return $query->count();
}
/**
* Get all dealers for filter
*/
public function getDealers()
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
Log::info('No authenticated user found');
return collect();
}
Log::info('Getting dealers for user:', [
'user_id' => $user->id,
'user_name' => $user->name,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
Log::info('Role details:', [
'role_id' => $role ? $role->id : null,
'role_name' => $role ? $role->name : null,
'role_dealers_count' => $role ? $role->dealers->count() : 0,
'role_dealers' => $role ? $role->dealers->pluck('id', 'name')->toArray() : []
]);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers - return pivot dealers (for "Semua Dealer" option)
Log::info('Admin role with pivot dealers, returning pivot dealers');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get(['dealers.id', 'dealers.name', 'dealers.dealer_code']);
Log::info('Returning pivot dealers for admin:', $dealers->toArray());
return $dealers;
} else {
// Admin without pivot dealers - return all dealers
Log::info('Admin role without pivot dealers, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'dealer_code']);
Log::info('Returning all dealers for admin:', $allDealers->toArray());
return $allDealers;
}
}
// Role has dealer relationship (tampilkan dealer berdasarkan pivot)
if ($role->dealers->count() > 0) {
Log::info('Role has dealers relationship, returning role dealers');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get(['dealers.id', 'dealers.name', 'dealers.dealer_code']);
Log::info('Returning dealers from role:', $dealers->toArray());
return $dealers;
}
}
}
// If user has specific dealer_id but no role dealers, check if they can access their dealer_id
if ($user->dealer_id) {
Log::info('User has specific dealer_id:', ['dealer_id' => $user->dealer_id]);
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role && $role->hasDealer($user->dealer_id)) {
Log::info('User can access their dealer_id, returning single dealer');
$dealer = Dealer::where('id', $user->dealer_id)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'dealer_code']);
Log::info('Returning dealer:', $dealer->toArray());
return $dealer;
} else {
Log::info('User cannot access their dealer_id');
}
}
Log::info('User has dealer_id but no role or no access, returning empty');
return collect();
}
// Fallback: return all dealers if no restrictions
Log::info('No restrictions found, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'dealer_code']);
Log::info('Returning all dealers:', $allDealers->toArray());
return $allDealers;
}
/**
* Check if role is admin type (should show all dealers)
*/
public function isAdminRole($role)
{
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
Log::info('Role identified as admin type:', ['role_name' => $role->name]);
return true;
}
}
// Check if role has no dealer restrictions (no pivot relationships)
// This means role can access all dealers
if ($role->dealers->count() === 0) {
Log::info('Role has no dealer restrictions, treating as admin type:', ['role_name' => $role->name]);
return true;
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
Log::info('Role contains "area", treating as area role (use pivot dealers):', ['role_name' => $role->name]);
return false;
}
Log::info('Role is not admin type:', ['role_name' => $role->name]);
return false;
}
/**
* Get default dealer for filter (berbasis user role)
*/
public function getDefaultDealer()
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return null;
}
Log::info('Getting default dealer for user:', [
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers - return first dealer from pivot
Log::info('Admin role with pivot dealers, returning first dealer from pivot');
$defaultDealer = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->first();
Log::info('Default dealer for admin with pivot:', $defaultDealer ? $defaultDealer->toArray() : null);
return $defaultDealer;
} else {
// Admin without pivot dealers - no default dealer (show all dealers without selection)
Log::info('Admin role without pivot dealers, no default dealer');
return null;
}
}
// Role has dealer relationship (return first dealer from role dealers)
if ($role->dealers->count() > 0) {
Log::info('Role has dealers relationship, returning first dealer from role dealers');
$defaultDealer = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->first();
Log::info('Default dealer from role dealers:', $defaultDealer ? $defaultDealer->toArray() : null);
return $defaultDealer;
}
}
}
// If user has specific dealer_id, check if they can access it
if ($user->dealer_id) {
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role && $role->hasDealer($user->dealer_id)) {
$defaultDealer = Dealer::where('id', $user->dealer_id)->whereNull('deleted_at')->first();
Log::info('User dealer found:', $defaultDealer ? $defaultDealer->toArray() : null);
return $defaultDealer;
}
}
return null;
}
// Fallback: no default dealer
Log::info('No default dealer found');
return null;
}
/**
* Get mechanics for a specific dealer
*/
public function getMechanicsByDealer($dealerId = null)
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return collect();
}
Log::info('Getting mechanics by dealer:', [
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id,
'requested_dealer_id' => $dealerId
]);
$query = User::with('role')->whereHas('role', function($query) {
$query->where('name', 'mechanic');
});
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers
if ($dealerId) {
// Specific dealer selected - get mechanics from that dealer
Log::info('Admin with pivot dealers, specific dealer selected:', ['dealer_id' => $dealerId]);
$query->where('dealer_id', $dealerId);
} else {
// "Semua Dealer" selected - get mechanics from all pivot dealers
Log::info('Admin with pivot dealers, "Semua Dealer" selected, getting mechanics from all pivot dealers');
$accessibleDealerIds = $role->dealers->pluck('id');
$query->whereIn('dealer_id', $accessibleDealerIds);
Log::info('Accessible dealer IDs for admin:', $accessibleDealerIds->toArray());
}
} else {
// Admin without pivot dealers - can access all dealers
Log::info('Admin without pivot dealers, can access mechanics from all dealers');
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
// If no dealer_id, show all mechanics (no additional filtering)
}
} else {
// Role has dealer relationship (filter by accessible dealers)
if ($role->dealers->count() > 0) {
Log::info('Role has dealers relationship, filtering mechanics by accessible dealers');
$accessibleDealerIds = $role->dealers->pluck('id');
$query->whereIn('dealer_id', $accessibleDealerIds);
Log::info('Accessible dealer IDs:', $accessibleDealerIds->toArray());
} else {
Log::info('Role has no dealers, returning empty');
return collect();
}
}
}
} else if ($user->dealer_id) {
// User has specific dealer_id but no role, can only access their dealer
Log::info('User has dealer_id but no role, can only access their dealer');
$query->where('dealer_id', $user->dealer_id);
}
// Apply dealer filter if provided (for non-admin roles)
if ($dealerId && !$this->isAdminRole($role ?? null)) {
Log::info('Applying dealer filter for non-admin role:', ['dealer_id' => $dealerId]);
$query->where('dealer_id', $dealerId);
}
$mechanics = $query->orderBy('name')->get(['id', 'name', 'dealer_id']);
Log::info('Mechanics found:', [
'count' => $mechanics->count(),
'mechanics' => $mechanics->map(function($mechanic) {
return [
'id' => $mechanic->id,
'name' => $mechanic->name,
'dealer_id' => $mechanic->dealer_id
];
})->toArray()
]);
return $mechanics;
}
/**
* Get technician report data for Yajra DataTable
*/
public function getTechnicianReportDataForDataTable($dealerId = null, $startDate = null, $endDate = null)
{
try {
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
// Validate dealer access
if ($dealerId) {
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
} else {
// User has dealer_id but no role, can only access their dealer
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
}
}
Log::info('Getting technician report data for DataTable', [
'user_id' => $user->id,
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate
]);
// Get all works with category
$works = Work::with(['category'])
->orderBy('name')
->get();
// Get mechanics based on dealer and role access
$mechanics = $this->getMechanicsByDealer($dealerId);
// Get transaction data
$transactions = $this->getOptimizedTransactionData($dealerId, $startDate, $endDate, $mechanics->pluck('id'), $works->pluck('id'));
Log::info('Transaction data for DataTable:', [
'transaction_count' => count($transactions),
'dealer_id_filter' => $dealerId,
'is_admin_with_pivot' => $user->role_id ? (function() use ($user) {
$role = Role::with('dealers')->find($user->role_id);
return $role && $this->isAdminRole($role) && $role->dealers->count() > 0;
})() : false
]);
$data = [];
foreach ($works as $work) {
$row = [
'DT_RowIndex' => count($data) + 1,
'work_name' => $work->name,
'work_code' => $work->shortname,
'category_name' => $work->category ? $work->category->name : '-'
];
// Add mechanic columns
foreach ($mechanics as $mechanic) {
$key = $work->id . '_' . $mechanic->id;
$mechanicData = $transactions[$key] ?? ['total' => 0, 'completed' => 0, 'pending' => 0];
$row["mechanic_{$mechanic->id}_total"] = $mechanicData['total'];
}
$data[] = $row;
}
Log::info('DataTable response prepared', [
'data_count' => count($data),
'mechanics_count' => $mechanics->count(),
'works_count' => $works->count()
]);
// Create DataTable response
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => count($data),
'recordsFiltered' => count($data),
'data' => $data,
'mechanics' => $mechanics,
'works' => $works
]);
} catch (\Exception $e) {
Log::error('Error in getTechnicianReportDataForDataTable: ' . $e->getMessage(), [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'trace' => $e->getTraceAsString()
]);
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateKpiTargetsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('kpi_targets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->integer('target_value');
$table->boolean('is_active')->default(true);
$table->text('description')->nullable();
$table->timestamps();
// Unique constraint untuk mencegah duplikasi target aktif per user (satu target aktif per user)
$table->unique(['user_id', 'is_active'], 'kpi_targets_user_active_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('kpi_targets');
}
}

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateKpiAchievementsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('kpi_achievements', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('kpi_target_id')->constrained()->onDelete('cascade');
$table->integer('target_value'); // Menyimpan target value secara langsung untuk historical tracking
$table->integer('actual_value')->default(0);
$table->decimal('achievement_percentage', 5, 2)->default(0);
$table->integer('year');
$table->integer('month');
$table->text('notes')->nullable();
$table->timestamps();
// Unique constraint untuk mencegah duplikasi achievement per user per bulan
// Note: Tidak menggunakan kpi_target_id karena target sekarang permanen per user
$table->unique(['user_id', 'year', 'month']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('kpi_achievements');
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddClaimedColumnsToTransactionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('transactions', function (Blueprint $table) {
$table->timestamp('claimed_at')->nullable()->after('status');
$table->unsignedBigInteger('claimed_by')->nullable()->after('claimed_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn(['claimed_at', 'claimed_by']);
});
}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWorkDealerPricesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('work_dealer_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('work_id')->constrained()->onDelete('cascade');
$table->foreignId('dealer_id')->constrained()->onDelete('cascade');
$table->decimal('price', 15, 2)->default(0.00);
$table->string('currency', 3)->default('IDR');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
$table->unique(['work_id', 'dealer_id']);
$table->index(['dealer_id', 'is_active']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('work_dealer_prices');
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateRoleDealerTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('role_dealer', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained()->onDelete('cascade');
$table->foreignId('dealer_id')->constrained()->onDelete('cascade');
$table->timestamps();
$table->unique(['role_id', 'dealer_id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('role_dealer');
}
}

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePrechecksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('prechecks', function (Blueprint $table) {
$table->id();
$table->foreignId('transaction_id')->constrained()->onDelete('cascade');
$table->foreignId('precheck_by')->constrained('users')->onDelete('cascade');
$table->timestamp('precheck_at')->nullable();
$table->string('police_number');
$table->string('spk_number');
$table->string('front_image', 255)->nullable();
$table->json('front_image_metadata')->nullable();
$table->decimal('kilometer', 10, 2);
$table->decimal('pressure_high', 10, 2);
$table->decimal('pressure_low', 10, 2)->nullable();
$table->decimal('cabin_temperature', 10, 2)->nullable();
$table->string('cabin_temperature_image', 255)->nullable();
$table->json('cabin_temperature_image_metadata')->nullable();
$table->enum('ac_condition', ['kotor', 'rusak', 'baik', 'tidak ada'])->nullable();
$table->string('ac_image', 255)->nullable();
$table->json('ac_image_metadata')->nullable();
$table->enum('blower_condition', ['kotor', 'rusak', 'baik', 'tidak ada'])->nullable();
$table->string('blower_image', 255)->nullable();
$table->json('blower_image_metadata')->nullable();
$table->enum('evaporator_condition', ['kotor', 'berlendir', 'bocor', 'bersih'])->nullable();
$table->string('evaporator_image', 255)->nullable();
$table->json('evaporator_image_metadata')->nullable();
$table->enum('compressor_condition', ['kotor', 'rusak', 'baik', 'tidak ada'])->nullable();
$table->text('precheck_notes')->nullable();
$table->timestamps();
$table->index(['transaction_id']);
$table->index(['precheck_by']);
$table->index(['precheck_at']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('prechecks');
}
}

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePostchecksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('postchecks', function (Blueprint $table) {
$table->id();
$table->foreignId('transaction_id')->constrained()->onDelete('cascade');
$table->foreignId('postcheck_by')->constrained('users')->onDelete('cascade');
$table->timestamp('postcheck_at')->nullable();
$table->string('police_number');
$table->string('spk_number');
$table->string('front_image', 255)->nullable();
$table->json('front_image_metadata')->nullable();
$table->decimal('kilometer', 10, 2);
$table->decimal('pressure_high', 10, 2);
$table->decimal('pressure_low', 10, 2)->nullable();
$table->decimal('cabin_temperature', 10, 2)->nullable();
$table->string('cabin_temperature_image', 255)->nullable();
$table->json('cabin_temperature_image_metadata')->nullable();
$table->enum('ac_condition', ['sudah dikerjakan', 'sudah diganti'])->nullable();
$table->string('ac_image', 255)->nullable();
$table->json('ac_image_metadata')->nullable();
$table->enum('blower_condition', ['sudah dibersihkan atau dicuci', 'sudah diganti'])->nullable();
$table->string('blower_image', 255)->nullable();
$table->json('blower_image_metadata')->nullable();
$table->enum('evaporator_condition', ['sudah dikerjakan', 'sudah diganti'])->nullable();
$table->string('evaporator_image', 255)->nullable();
$table->json('evaporator_image_metadata')->nullable();
$table->enum('compressor_condition', ['sudah dikerjakan', 'sudah diganti'])->nullable();
$table->text('postcheck_notes')->nullable();
$table->timestamps();
$table->index(['transaction_id']);
$table->index(['postcheck_by']);
$table->index(['postcheck_at']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('postchecks');
}
}

View File

@@ -34,6 +34,18 @@ class MenuSeeder extends Seeder
[
'name' => 'Histori Stock',
'link' => 'stock-audit.index'
],
[
'name' => 'Target',
'link' => 'kpi.targets.index'
],
[
'name' => 'Stock Produk',
'link' => 'reports.stock-product.index'
],
[
'name' => 'Teknisi',
'link' => 'reports.technician.index'
]
];
@@ -47,5 +59,7 @@ class MenuSeeder extends Seeder
]
);
}
Menu::whereIn('link', ['targets.index','product-categories.index'])->delete();
}
}

View File

@@ -1,63 +1,67 @@
"use strict";
function moneyFormat(n, currency) {
n = (n != null) ? n : 0;
n = n != null ? n : 0;
var v = parseFloat(n).toFixed(0);
return currency + " " + v.replace(/./g, function (c, i, a) {
return (
currency +
" " +
v.replace(/./g, function (c, i, a) {
return i > 0 && c !== "," && (a.length - i) % 3 === 0 ? "." + c : c;
});
})
);
}
var KTToastr = function () {
var KTToastr = (function () {
var successToastr = function (msg, title) {
toastr.options = {
"closeButton": true,
"debug": false,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-top-right",
"preventDuplicates": true,
"onclick": null,
"showDuration": "500",
"hideDuration": "500",
"timeOut": "3000",
"extendedTimeOut": "3000",
"showEasing": "swing",
"hideEasing": "swing",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
closeButton: true,
debug: false,
newestOnTop: false,
progressBar: false,
positionClass: "toast-top-right",
preventDuplicates: true,
onclick: null,
showDuration: "500",
hideDuration: "500",
timeOut: "3000",
extendedTimeOut: "3000",
showEasing: "swing",
hideEasing: "swing",
showMethod: "fadeIn",
hideMethod: "fadeOut",
};
var $toast = toastr["success"](msg, title);
if (typeof $toast === 'undefined') {
if (typeof $toast === "undefined") {
return;
}
}
};
var errorToastr = function (msg, title) {
toastr.options = {
"closeButton": true,
"debug": false,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-top-right",
"preventDuplicates": true,
"onclick": null,
"showDuration": "500",
"hideDuration": "500",
"timeOut": "3000",
"extendedTimeOut": "3000",
"showEasing": "swing",
"hideEasing": "swing",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
closeButton: true,
debug: false,
newestOnTop: false,
progressBar: false,
positionClass: "toast-top-right",
preventDuplicates: true,
onclick: null,
showDuration: "500",
hideDuration: "500",
timeOut: "3000",
extendedTimeOut: "3000",
showEasing: "swing",
hideEasing: "swing",
showMethod: "fadeIn",
hideMethod: "fadeOut",
};
var $toast = toastr["error"](msg, title);
if (typeof $toast === 'undefined') {
if (typeof $toast === "undefined") {
return;
}
}
};
return {
initSuccess: function (msg, title) {
@@ -65,11 +69,29 @@ var KTToastr = function () {
},
initError: function (msg, title) {
errorToastr(msg, title);
}
},
};
}();
})();
jQuery(document).ready(function () {
var li = $('.kt-menu__item--active');
li.closest('li.kt-menu__item--submenu').addClass('kt-menu__item--open');
var li = $(".kt-menu__item--active");
li.closest("li.kt-menu__item--submenu").addClass("kt-menu__item--open");
// Initialize Select2 globally
if (typeof $.fn.select2 !== "undefined") {
$(".select2").select2({
theme: "bootstrap4",
width: "100%",
});
}
// Initialize DatePicker globally
if (typeof $.fn.datepicker !== "undefined") {
$(".datepicker").datepicker({
format: "yyyy-mm-dd",
autoclose: true,
todayHighlight: true,
orientation: "bottom auto",
});
}
});

View File

@@ -0,0 +1,655 @@
"use strict";
// Class definition
var WorkPrices = (function () {
// Private variables
var workId;
var dealersTable;
var saveTimeout = {}; // For debouncing save requests
// 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();
};
// Private functions
var initTable = function () {
dealersTable = $("#dealers-table").DataTable({
pageLength: -1, // Show all records
lengthChange: false, // Hide length change dropdown
searching: true,
ordering: true,
info: false, // Hide "Showing X of Y entries"
responsive: true,
dom: '<"top"f>rt<"bottom"p>', // Only show search and pagination
paging: false, // Disable pagination
});
};
var initEvents = function () {
// Get work ID from URL
var pathArray = window.location.pathname.split("/");
workId = pathArray[pathArray.length - 2]; // work/{id}/set-prices
// Save single price with debouncing
$(document).on("click", ".save-single", function () {
var dealerId = $(this).data("dealer-id");
// Clear existing timeout for this dealer
if (saveTimeout[dealerId]) {
clearTimeout(saveTimeout[dealerId]);
}
// Set new timeout to prevent rapid clicks
saveTimeout[dealerId] = setTimeout(function () {
saveSinglePrice(dealerId);
}, 300); // 300ms delay
});
// Delete price
$(document).on("click", ".delete-price", function () {
var priceId = $(this).data("price-id");
var dealerId = $(this).data("dealer-id");
deletePrice(priceId, dealerId);
});
// Save all prices
$("#btn-save-all").on("click", function () {
saveAllPrices();
});
// Status toggle
$(document).on("change", ".status-input", function () {
var dealerId = $(this).data("dealer-id");
var isChecked = $(this).is(":checked");
var label = $(this).siblings("label");
var checkbox = $(this);
// Update visual immediately
if (isChecked) {
label.text("Aktif").removeClass("inactive").addClass("active");
} else {
label
.text("Nonaktif")
.removeClass("active")
.addClass("inactive");
}
// Send AJAX request to update database
toggleStatus(dealerId, isChecked, checkbox, label);
});
// Format price input with thousand separator
$(document).on("input", ".price-input", function () {
var input = $(this);
var value = input.val().replace(/[^\d]/g, "");
if (value === "") {
input.val("0");
} else {
var numValue = parseInt(value);
input.val(numValue.toLocaleString("id-ID"));
// Don't update original value here - let it be updated only when saving
}
});
// Format price inputs on page load
$(".price-input").each(function () {
var input = $(this);
var value = input.val();
if (value && value !== "0") {
var numValue = parseInt(value.replace(/[^\d]/g, ""));
input.val(numValue.toLocaleString("id-ID"));
// Store the original numeric value for comparison
input.data("original-value", numValue.toString());
console.log(
"Initialized price for dealer",
input.attr("name").replace("price_", ""),
":",
numValue
);
}
});
};
var saveSinglePrice = function (dealerId) {
// Prevent multiple clicks
var saveButton = $('.save-single[data-dealer-id="' + dealerId + '"]');
if (saveButton.hasClass("loading")) {
return; // Already processing
}
var priceInput = $('input[name="price_' + dealerId + '"]');
var statusInput = $('input[name="status_' + dealerId + '"]');
var price = priceInput.val().replace(/[^\d]/g, ""); // Remove non-numeric characters
var isActive = statusInput.is(":checked");
if (!price || parseInt(price) <= 0) {
toastr.error("Harga harus lebih dari 0");
return;
}
// Get original price from data attribute (without separator)
var originalPrice = priceInput.data("original-value") || "0";
var currentPrice = parseInt(price);
var originalPriceInt = parseInt(originalPrice);
console.log(
"Debug - Original price:",
originalPriceInt,
"Current price:",
currentPrice
);
// If price hasn't actually changed, don't update
if (currentPrice === originalPriceInt && originalPrice !== "0") {
toastr.info("Harga tidak berubah, tidak perlu update");
return;
}
// If price has changed, update original value for next comparison
if (currentPrice !== originalPriceInt) {
priceInput.data("original-value", currentPrice.toString());
console.log(
"Price changed from",
originalPriceInt,
"to",
currentPrice
);
}
// Disable button and show loading state
saveButton.addClass("loading").prop("disabled", true);
var originalText = saveButton.text();
saveButton.text("Menyimpan...");
var data = {
work_id: parseInt(workId),
dealer_id: parseInt(dealerId),
price: currentPrice, // Use the validated price
currency: "IDR",
is_active: isActive ? 1 : 0,
};
// Debug: Log the data being sent
console.log("Sending data:", data);
console.log("Original price:", originalPriceInt);
console.log("Current price:", currentPrice);
$.ajax({
url: "/admin/work/" + workId + "/prices",
method: "POST",
data: data,
headers: {
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content"),
},
beforeSend: function () {
console.log("Sending AJAX request with data:", data);
},
success: function (response) {
// Re-enable button
saveButton
.removeClass("loading")
.prop("disabled", false)
.text(originalText);
if (response.status === 200) {
toastr.success(response.message);
// Update UI
updateRowAfterSave(dealerId, response.data);
// Ensure consistent formatting after update
var updatedPrice = priceInput.val().replace(/[^\d]/g, "");
if (updatedPrice && updatedPrice !== "0") {
var formattedPrice =
parseInt(updatedPrice).toLocaleString("id-ID");
priceInput.val(formattedPrice);
}
// Show brief loading message
toastr.info(
"Data berhasil disimpan, memperbarui tampilan..."
);
} else {
toastr.error(response.message || "Terjadi kesalahan");
}
},
error: function (xhr) {
// Re-enable button
saveButton
.removeClass("loading")
.prop("disabled", false)
.text(originalText);
var message = "Terjadi kesalahan";
if (xhr.responseJSON) {
if (xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
if (xhr.responseJSON.errors) {
// Show validation errors
var errorMessages = [];
for (var field in xhr.responseJSON.errors) {
var fieldName = field;
switch (field) {
case "work_id":
fieldName = "ID Pekerjaan";
break;
case "dealer_id":
fieldName = "ID Dealer";
break;
case "price":
fieldName = "Harga";
break;
case "currency":
fieldName = "Mata Uang";
break;
case "is_active":
fieldName = "Status Aktif";
break;
}
errorMessages.push(
fieldName +
": " +
xhr.responseJSON.errors[field][0]
);
}
message = errorMessages.join("\n");
}
}
toastr.error(message);
},
});
};
var saveAllPrices = function () {
// Prevent multiple clicks
var saveAllButton = $("#btn-save-all");
if (saveAllButton.hasClass("loading")) {
return; // Already processing
}
var prices = [];
var hasValidPrice = false;
$(".price-input").each(function () {
var dealerId = $(this).attr("name").replace("price_", "");
var price = $(this).val().replace(/[^\d]/g, ""); // Remove non-numeric characters
var statusInput = $('input[name="status_' + dealerId + '"]');
var isActive = statusInput.is(":checked");
if (price && parseInt(price) > 0) {
hasValidPrice = true;
prices.push({
dealer_id: dealerId,
price: parseInt(price),
currency: "IDR",
is_active: isActive,
});
}
});
if (!hasValidPrice) {
toastr.error("Minimal satu dealer harus memiliki harga yang valid");
return;
}
// Disable button and show loading state
saveAllButton.addClass("loading").prop("disabled", true);
var originalText = saveAllButton.text();
saveAllButton.text("Menyimpan...");
// Show confirmation
$("#confirmMessage").text(
"Apakah Anda yakin ingin menyimpan semua harga?"
);
$("#confirmModal").modal("show");
$("#confirmAction")
.off("click")
.on("click", function () {
$("#confirmModal").modal("hide");
$.ajax({
url: "/admin/work/" + workId + "/prices/bulk",
method: "POST",
data: {
prices: prices,
},
headers: {
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr(
"content"
),
},
success: function (response) {
// Re-enable button
saveAllButton
.removeClass("loading")
.prop("disabled", false)
.text(originalText);
if (response.status === 200) {
toastr.success(response.message);
// Show loading overlay
showLoadingOverlay("Memperbarui data...");
// Reload page to update all data
setTimeout(function () {
location.reload();
}, 1000);
} else {
toastr.error(
response.message || "Terjadi kesalahan"
);
}
},
error: function (xhr) {
// Re-enable button
saveAllButton
.removeClass("loading")
.prop("disabled", false)
.text(originalText);
var message = "Terjadi kesalahan";
if (xhr.responseJSON && xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
toastr.error(message);
},
});
});
};
var deletePrice = function (priceId, dealerId) {
$("#confirmMessage").text(
"Apakah Anda yakin ingin menghapus harga ini? Harga yang dihapus dapat dipulihkan dengan menyimpan ulang."
);
$("#confirmModal").modal("show");
$("#confirmAction")
.off("click")
.on("click", function () {
$("#confirmModal").modal("hide");
// Show loading overlay
showLoadingOverlay("Menghapus harga...");
$.ajax({
url: "/admin/work/" + workId + "/prices/" + priceId,
method: "DELETE",
headers: {
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr(
"content"
),
},
success: function (response) {
hideLoadingOverlay();
if (response.status === 200) {
toastr.success(response.message);
// Reset the row
resetRow(dealerId);
} else {
toastr.error(
response.message || "Terjadi kesalahan"
);
}
},
error: function (xhr) {
hideLoadingOverlay();
var message = "Terjadi kesalahan";
if (xhr.responseJSON && xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
toastr.error(message);
},
});
});
};
// Handle delete button click
$(document).on("click", ".delete-price", function () {
var priceId = $(this).data("price-id");
var dealerId = $(this).data("dealer-id");
deletePrice(priceId, dealerId);
});
var updateRowAfterSave = function (dealerId, data) {
var row = $('[data-dealer-id="' + dealerId + '"]');
var priceInput = row.find('input[name="price_' + dealerId + '"]');
var statusInput = row.find('input[name="status_' + dealerId + '"]');
var label = statusInput.siblings("label").find(".status-text");
var actionCell = row.find("td:last");
// Update price input if data contains price
if (data.price !== undefined) {
// Only update if the price actually changed
var currentDisplayValue = priceInput.val().replace(/[^\d]/g, "");
var newPrice = parseInt(data.price);
if (parseInt(currentDisplayValue) !== newPrice) {
priceInput.val(newPrice.toLocaleString("id-ID"));
// Update the original value for future comparisons
priceInput.data("original-value", newPrice.toString());
}
}
// If this is a new record (price = 0), update the save button
if (data.price === 0) {
actionCell
.find(".save-single")
.text("Simpan")
.removeClass("btn-warning")
.addClass("btn-success");
}
// Update status if data contains is_active
if (data.is_active !== undefined) {
statusInput.prop("checked", data.is_active);
if (data.is_active) {
label.text("Aktif").removeClass("inactive").addClass("active");
} else {
label
.text("Nonaktif")
.removeClass("active")
.addClass("inactive");
}
}
// Update save button if this is a new price save (not just status toggle)
if (data.price !== undefined) {
actionCell
.find(".save-single")
.text("Update")
.removeClass("btn-success")
.addClass("btn-warning");
// Update delete button
if (actionCell.find(".delete-price").length === 0) {
// Add delete button if it doesn't exist
var deleteBtn =
'<button type="button" class="btn btn-sm btn-danger delete-price" data-price-id="' +
data.id +
'" data-dealer-id="' +
dealerId +
'" title="Hapus Harga">Hapus</button>';
actionCell.find(".d-flex.flex-row.gap-1").append(deleteBtn);
} else {
// Update existing delete button with new price ID
actionCell.find(".delete-price").attr("data-price-id", data.id);
}
}
};
var toggleStatus = function (dealerId, isActive, checkbox, label) {
var data = {
dealer_id: parseInt(dealerId),
is_active: isActive,
};
$.ajax({
url: "/admin/work/" + workId + "/prices/toggle-status",
method: "POST",
data: data,
headers: {
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content"),
},
beforeSend: function () {
// Show brief loading indicator on checkbox
checkbox.prop("disabled", true);
},
success: function (response) {
// Re-enable checkbox
checkbox.prop("disabled", false);
if (response.status === 200) {
toastr.success(response.message);
// Update UI if needed
if (response.data) {
// If this is a new record, update the row to show save button
if (response.data.price === 0) {
updateRowAfterSave(dealerId, response.data);
}
}
} else {
toastr.error(response.message || "Terjadi kesalahan");
// Revert checkbox state
checkbox.prop("checked", !isActive);
if (!isActive) {
label.text("Aktif");
} else {
label.text("Nonaktif");
}
}
},
error: function (xhr) {
// Re-enable checkbox
checkbox.prop("disabled", false);
var message = "Terjadi kesalahan";
if (xhr.responseJSON) {
if (xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
if (xhr.responseJSON.errors) {
var errorMessages = [];
for (var field in xhr.responseJSON.errors) {
var fieldName = field;
switch (field) {
case "dealer_id":
fieldName = "ID Dealer";
break;
case "is_active":
fieldName = "Status Aktif";
break;
}
errorMessages.push(
fieldName +
": " +
xhr.responseJSON.errors[field][0]
);
}
message = errorMessages.join("\n");
}
}
toastr.error(message);
// Revert checkbox state
checkbox.prop("checked", !isActive);
if (!isActive) {
label.text("Aktif");
} else {
label.text("Nonaktif");
}
},
});
};
var resetRow = function (dealerId) {
var row = $('[data-dealer-id="' + dealerId + '"]');
var priceInput = row.find('input[name="price_' + dealerId + '"]');
var statusInput = row.find('input[name="status_' + dealerId + '"]');
var label = statusInput.siblings("label").find(".status-text");
var actionCell = row.find("td:last");
// Reset price input
priceInput.val("0");
// Reset status
statusInput.prop("checked", false);
label.text("Nonaktif").removeClass("active").addClass("inactive");
// Remove delete button and update save button
actionCell.find(".delete-price").remove();
actionCell
.find(".save-single")
.text("Simpan")
.removeClass("btn-warning")
.addClass("btn-success");
};
// Public methods
return {
init: function () {
initTable();
initEvents();
// Initialize price formatting on page load
setTimeout(function () {
$(".price-input").each(function () {
var value = $(this).val();
if (value && value !== "0") {
var numValue = parseInt(value.replace(/[^\d]/g, ""));
if (!isNaN(numValue)) {
$(this).val(numValue.toLocaleString("id-ID"));
}
}
});
}, 100);
// Cleanup timeouts on page unload
$(window).on("beforeunload", function () {
for (var dealerId in saveTimeout) {
if (saveTimeout[dealerId]) {
clearTimeout(saveTimeout[dealerId]);
}
}
});
},
};
})();
// On document ready
jQuery(document).ready(function () {
WorkPrices.init();
});

View File

@@ -1,2 +1,2 @@
(()=>{function e(){$("#date_to").datepicker({format:"yyyy-mm-dd",autoclose:!0,todayHighlight:!0,orientation:"bottom left",templates:{leftArrow:'<i class="la la-angle-left"></i>',rightArrow:'<i class="la la-angle-right"></i>'},endDate:new Date,clearBtn:!0}).on("changeDate",(function(e){console.log("End date selected:",e.format())})).on("clearDate",(function(e){console.log("End date cleared")}))}function a(){$("#date_to").datepicker("remove"),$("#date_to").val(""),e(),$("#date_to").prop("disabled",!0),console.log("End date picker reset and disabled")}$(document).ready((function(){console.log("Opnames index.js loaded"),void 0!==$.fn.DataTable?(console.log("Initializing Select2..."),void 0!==$.fn.select2?$("#dealer_filter").select2({placeholder:"Pilih...",allowClear:!0,width:"100%"}):console.warn("Select2 not available, using regular select"),function(){if(console.log("Initializing datepickers..."),void 0===$.fn.datepicker)return void console.error("Bootstrap Datepicker not available!");$("#date_from").datepicker({format:"yyyy-mm-dd",autoclose:!0,todayHighlight:!0,orientation:"bottom left",templates:{leftArrow:'<i class="la la-angle-left"></i>',rightArrow:'<i class="la la-angle-right"></i>'},endDate:new Date,clearBtn:!0}).on("changeDate",(function(e){var a;console.log("Start date selected:",e.format()),a=e.format(),console.log("Enabling end date picker with min date:",a),$("#date_to").prop("disabled",!1),$("#date_to").datepicker("remove"),$("#date_to").datepicker({format:"yyyy-mm-dd",autoclose:!0,todayHighlight:!0,orientation:"bottom left",templates:{leftArrow:'<i class="la la-angle-left"></i>',rightArrow:'<i class="la la-angle-right"></i>'},startDate:a,endDate:new Date,clearBtn:!0}).on("changeDate",(function(e){console.log("End date selected:",e.format())})).on("clearDate",(function(e){console.log("End date cleared")})),console.log("End date picker enabled with startDate:",a)})).on("clearDate",(function(e){console.log("Start date cleared"),a()})),e(),$("#date_to").prop("disabled",!0)}(),setTimeout((function(){!function(){console.log("Initializing DataTable..."),$.fn.DataTable.isDataTable("#opnames-table")&&$("#opnames-table").DataTable().destroy();var e=$("#opnames-table").DataTable({processing:!0,serverSide:!0,destroy:!0,ajax:{url:$("#opnames-table").data("url"),type:"GET",data:function(e){return e.dealer_filter=$("#dealer_filter").val(),e.date_from=$("#date_from").val(),e.date_to=$("#date_to").val(),console.log("AJAX data being sent:",{dealer_filter:e.dealer_filter,date_from:e.date_from,date_to:e.date_to}),e},error:function(e,a,t){console.error("DataTables AJAX error:",a,t),console.error("Response:",e.responseText)}},columnDefs:[{targets:0,width:"15%"},{targets:5,width:"15%",className:"text-center"}],columns:[{data:"created_at",name:"created_at",orderable:!0},{data:"opname_date",name:"opname_date",orderable:!0},{data:"dealer_name",name:"dealer.name",orderable:!0},{data:"user_name",name:"user.name",orderable:!0},{data:"status",name:"status",orderable:!0},{data:"action",name:"action",orderable:!1,searchable:!1}],order:[[4,"desc"]],pageLength:10,responsive:!0,ordering:!0,orderMulti:!1});(function(e){$("#kt_search").on("click",(function(){console.log("Filter button clicked");var a=$("#dealer_filter").val(),t=$("#date_from").val(),o=$("#date_to").val();console.log("Filtering with:",{dealer:a,dateFrom:t,dateTo:o}),e.ajax.reload()})),$("#kt_reset").on("click",(function(){console.log("Reset button clicked"),$("#dealer_filter").val(null).trigger("change.select2"),$("#date_from").datepicker("clearDates"),$("#date_to").datepicker("clearDates"),a(),e.ajax.reload()})),$("#date_from, #date_to").on("keypress",(function(e){13===e.which&&$("#kt_search").click()})),$("#dealer_filter").on("change",(function(){console.log("Dealer filter changed:",$(this).val())}))})(e),function(e){e.on("order.dt",(function(){console.log("Order changed:",e.order())})),e.on("processing.dt",(function(e,a,t){t?console.log("DataTable processing started"):console.log("DataTable processing finished")}))}(e)}()}),100)):console.error("DataTables not available!")}))})();
(()=>{function e(){$("#date_to").datepicker({format:"yyyy-mm-dd",autoclose:!0,todayHighlight:!0,orientation:"bottom left",templates:{leftArrow:'<i class="la la-angle-left"></i>',rightArrow:'<i class="la la-angle-right"></i>'},endDate:new Date,clearBtn:!0}).on("changeDate",(function(e){console.log("End date selected:",e.format())})).on("clearDate",(function(e){console.log("End date cleared")}))}function a(){$("#date_to").datepicker("remove"),$("#date_to").val(""),e(),$("#date_to").prop("disabled",!0),console.log("End date picker reset and disabled")}$(document).ready((function(){console.log("Opnames index.js loaded"),void 0!==$.fn.DataTable?(console.log("Initializing Select2..."),void 0!==$.fn.select2?$("#dealer_filter").select2({placeholder:"Pilih...",allowClear:!0,width:"100%"}):console.warn("Select2 not available, using regular select"),function(){if(console.log("Initializing datepickers..."),void 0===$.fn.datepicker)return void console.error("Bootstrap Datepicker not available!");$("#date_from").datepicker({format:"yyyy-mm-dd",autoclose:!0,todayHighlight:!0,orientation:"bottom left",templates:{leftArrow:'<i class="la la-angle-left"></i>',rightArrow:'<i class="la la-angle-right"></i>'},endDate:new Date,clearBtn:!0}).on("changeDate",(function(e){var a;console.log("Start date selected:",e.format()),a=e.format(),console.log("Enabling end date picker with min date:",a),$("#date_to").prop("disabled",!1),$("#date_to").datepicker("remove"),$("#date_to").datepicker({format:"yyyy-mm-dd",autoclose:!0,todayHighlight:!0,orientation:"bottom left",templates:{leftArrow:'<i class="la la-angle-left"></i>',rightArrow:'<i class="la la-angle-right"></i>'},startDate:a,endDate:new Date,clearBtn:!0}).on("changeDate",(function(e){console.log("End date selected:",e.format())})).on("clearDate",(function(e){console.log("End date cleared")})),console.log("End date picker enabled with startDate:",a)})).on("clearDate",(function(e){console.log("Start date cleared"),a()})),e(),$("#date_to").prop("disabled",!0)}(),setTimeout((function(){!function(){console.log("Initializing DataTable..."),$.fn.DataTable.isDataTable("#opnames-table")&&$("#opnames-table").DataTable().destroy();var e=$("#opnames-table").DataTable({processing:!0,serverSide:!0,destroy:!0,ajax:{url:$("#opnames-table").data("url"),type:"GET",data:function(e){return e.dealer_filter=$("#dealer_filter").val(),e.date_from=$("#date_from").val(),e.date_to=$("#date_to").val(),console.log("AJAX data being sent:",{dealer_filter:e.dealer_filter,date_from:e.date_from,date_to:e.date_to}),e},error:function(e,a,t){console.error("DataTables AJAX error:",a,t),console.error("Response:",e.responseText)}},columnDefs:[{targets:0,width:"15%"},{targets:1,width:"12%"},{targets:2,width:"15%"},{targets:3,width:"12%"},{targets:4,width:"10%"},{targets:5,width:"15%",className:"text-center"},{targets:6,width:"15%",className:"text-center"}],columns:[{data:"created_at",name:"created_at",orderable:!0},{data:"opname_date",name:"opname_date",orderable:!0},{data:"dealer_name",name:"dealer.name",orderable:!0},{data:"user_name",name:"user.name",orderable:!0},{data:"status",name:"status",orderable:!0},{data:"stock_info",name:"stock_info",orderable:!1,searchable:!1},{data:"action",name:"action",orderable:!1,searchable:!1}],order:[[0,"desc"]],pageLength:10,responsive:!0,ordering:!0,orderMulti:!1});(function(e){$("#kt_search").on("click",(function(){console.log("Filter button clicked");var a=$("#dealer_filter").val(),t=$("#date_from").val(),o=$("#date_to").val();console.log("Filtering with:",{dealer:a,dateFrom:t,dateTo:o}),e.ajax.reload()})),$("#kt_reset").on("click",(function(){console.log("Reset button clicked"),$("#dealer_filter").val(null).trigger("change.select2"),$("#date_from").datepicker("clearDates"),$("#date_to").datepicker("clearDates"),a(),e.ajax.reload()})),$("#date_from, #date_to").on("keypress",(function(e){13===e.which&&$("#kt_search").click()})),$("#dealer_filter").on("change",(function(){console.log("Dealer filter changed:",$(this).val())}))})(e),function(e){e.on("order.dt",(function(){console.log("Order changed:",e.order())})),e.on("processing.dt",(function(e,a,t){t?console.log("DataTable processing started"):console.log("DataTable processing finished")}))}(e)}()}),100)):console.error("DataTables not available!")}))})();
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -196,8 +196,13 @@ function initializeDataTable() {
},
},
columnDefs: [
{ targets: 0, width: "15%" }, // Opname Date column
{ targets: 5, width: "15%", className: "text-center" }, // Action column
{ targets: 0, width: "15%" }, // Created At column
{ targets: 1, width: "12%" }, // Opname Date column
{ targets: 2, width: "15%" }, // Dealer column
{ targets: 3, width: "12%" }, // User column
{ targets: 4, width: "10%" }, // Status column
{ targets: 5, width: "15%", className: "text-center" }, // Stock Info column
{ targets: 6, width: "15%", className: "text-center" }, // Action column
],
columns: [
{
@@ -225,6 +230,12 @@ function initializeDataTable() {
name: "status",
orderable: true,
},
{
data: "stock_info",
name: "stock_info",
orderable: false,
searchable: false,
},
{
data: "action",
name: "action",
@@ -232,7 +243,7 @@ function initializeDataTable() {
searchable: false,
},
],
order: [[4, "desc"]], // Order by created_at desc
order: [[0, "desc"]], // Order by created_at desc
pageLength: 10,
responsive: true,
ordering: true,

View File

@@ -81,7 +81,7 @@
<meta charset="utf-8" />
<title>POS | Login</title>
<meta name="description" content="Login page example">
<meta name="description" content="CKB POS Login Page">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="base-url" content="{{ url('/') }}">

View File

@@ -0,0 +1,322 @@
@extends('layouts.backapp')
@section('content')
<style type="text/css">
/* Action button flex layout */
.d-flex.flex-row.gap-1 {
display: flex !important;
flex-direction: row !important;
gap: 0.25rem !important;
align-items: center;
flex-wrap: nowrap;
}
.d-flex.flex-row.gap-1 .btn {
white-space: nowrap;
margin: 0;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
border-radius: 0.2rem;
}
.d-flex.flex-row.gap-1 .btn i {
margin-right: 0.25rem;
}
/* Ensure DataTables doesn't break flex layout */
.dataTables_wrapper .dataTables_scrollBody .d-flex {
display: flex !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.d-flex.flex-row.gap-1 {
flex-direction: column !important;
gap: 0.125rem !important;
}
.d-flex.flex-row.gap-1 .btn {
width: 100%;
margin-bottom: 0.125rem;
}
}
/* Form control styling */
.form-control {
border-radius: 0.375rem;
border: 1px solid #ced4da;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.btn {
border-radius: 0.375rem;
font-weight: 500;
transition: all 0.15s ease-in-out;
}
.btn i {
font-size: 0.875rem;
}
/* Status text styling */
.status-text {
font-weight: 600;
transition: color 0.15s ease-in-out;
}
.status-text.active {
color: #28a745;
}
.status-text.inactive {
color: #dc3545;
}
/* Input group styling */
.input-group-text {
background-color: #e9ecef;
border: 1px solid #ced4da;
color: #495057;
font-weight: 500;
}
/* Table styling */
.table th {
background-color: #f8f9fa;
border-color: #dee2e6;
font-weight: 600;
color: #495057;
}
.table td {
vertical-align: middle;
}
/* Price input styling */
.price-input {
text-align: right;
font-weight: 500;
font-size: 1.15rem;
}
.price-input:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Status label styling */
.custom-control-label {
font-size: 0.875rem;
font-weight: 500;
}
/* Table styling */
.table th {
background-color: #f8f9fa;
border-color: #dee2e6;
font-weight: 600;
color: #495057;
}
.table td {
vertical-align: middle;
}
/* Status label styling */
.custom-control-label {
font-size: 0.875rem;
font-weight: 500;
}
/* Alert styling */
.alert {
border-radius: 0.5rem;
border: none;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
}
/* Button loading state */
.btn.loading {
opacity: 0.7;
cursor: not-allowed;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Loading overlay */
#loading-overlay {
backdrop-filter: blur(2px);
}
#loading-overlay .spinner-border {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<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">
<span class="kt-portlet__head-icon">
<i class="kt-font-brand flaticon2-line-chart"></i>
</span>
<h3 class="kt-portlet__head-title">
Set Harga Pekerjaan: <strong>{{ $work->name }}</strong>
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-wrapper">
<div class="kt-portlet__head-actions">
<button type="button" class="btn btn-bold btn-label-brand" id="btn-save-all">
Simpan Semua Harga
</button>
</div>
</div>
</div>
</div>
<div class="kt-portlet__body">
<div class="table-responsive">
<!--begin: Datatable -->
<table class="table table-striped table-bordered table-hover table-checkable" id="dealers-table">
<thead>
<tr>
<th width="5%">No</th>
<th width="25%">Nama Dealer</th>
<th width="15%">Kode Dealer</th>
<th width="20%">Harga (IDR)</th>
<th width="10%">Status</th>
<th width="15%">Aksi</th>
</tr>
</thead>
<tbody>
@foreach($dealers as $index => $dealer)
@php
$existingPrice = $work->getPriceForDealer($dealer->id);
@endphp
<tr data-dealer-id="{{ $dealer->id }}">
<td>{{ $index + 1 }}</td>
<td>{{ $dealer->name }}</td>
<td>{{ $dealer->dealer_code }}</td>
<td>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Rp</span>
</div>
<input type="text"
class="form-control price-input"
name="price_{{ $dealer->id }}"
value="{{ $existingPrice ? number_format($existingPrice->price, 0, ',', '.') : '0' }}"
placeholder="0"
data-dealer-id="{{ $dealer->id }}"
data-original-value="{{ $existingPrice ? $existingPrice->price : '0' }}">
</div>
</td>
<td>
<input type="checkbox"
class="status-input"
id="status_{{ $dealer->id }}"
name="status_{{ $dealer->id }}"
data-dealer-id="{{ $dealer->id }}"
{{ $existingPrice && $existingPrice->is_active ? 'checked' : '' }}>
<label for="status_{{ $dealer->id }}" style="margin-left: 0.5rem; font-weight: 500;">Aktif</label>
</td>
<td>
<div class="d-flex flex-row gap-1">
<button type="button"
class="btn btn-sm {{ $existingPrice ? 'btn-warning' : 'btn-success' }} save-single"
data-dealer-id="{{ $dealer->id }}"
title="{{ $existingPrice ? 'Update Harga' : 'Simpan Harga' }}">
{{ $existingPrice ? 'Update' : 'Simpan' }}
</button>
@if($existingPrice)
<button type="button"
class="btn btn-sm btn-danger delete-price"
data-price-id="{{ $existingPrice->id }}"
data-dealer-id="{{ $dealer->id }}"
title="Hapus Harga">
Hapus
</button>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
<!--end: Datatable -->
</div>
@if($dealers->count() == 0)
<div class="row">
<div class="col-12">
<div class="alert alert-warning text-center">
<i class="fa fa-exclamation-triangle mr-2"></i>
Tidak ada dealer yang tersedia.
</div>
</div>
</div>
@endif
</div>
</div>
<!--begin::Modal-->
<div class="modal fade" id="confirmModal" tabindex="-1" role="dialog" aria-labelledby="confirmModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmModalLabel">
<i class="fa fa-question-circle mr-2"></i>
Konfirmasi
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p id="confirmMessage"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
<i class="fa fa-times mr-1"></i>
Batal
</button>
<button type="button" class="btn btn-primary" id="confirmAction">
<i class="fa fa-check mr-1"></i>
Ya, Lanjutkan
</button>
</div>
</div>
</div>
</div>
<!--end::Modal-->
@endsection
@section('javascripts')
<script src="{{ url('js/pages/back/master/work-prices.js') }}" type="text/javascript"></script>
@endsection

View File

@@ -39,6 +39,7 @@
<tr>
<th>No</th>
<th>Nama Role</th>
<th>Dealer Tambahan</th>
<th>Aksi</th>
</tr>
</thead>
@@ -47,13 +48,30 @@
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $role->name }}</td>
<td>
@if($role->dealers->count() > 0)
<div class="dealer-list">
@foreach($role->dealers->take(3) as $dealer)
<span class="badge badge-info mr-1 mb-1">{{ $dealer->name }}</span>
@endforeach
@if($role->dealers->count() > 3)
<span class="badge badge-secondary">+{{ $role->dealers->count() - 3 }} more</span>
@endif
</div>
@else
<span class="text-muted">Tidak ada dealer tambahan untuk role ini</span>
@endif
</td>
<td>
<div class="d-flex">
@can('update', $menus['roleprivileges.index'])
<button class="btn btn-sm btn-bold btn-warning mr-2" onclick="editRole({{$role->id}})"> Edit</button>
@endcan
@can('delete', $menus['roleprivileges.index'])
<button class="btn btn-sm btn-bold btn-danger" onclick="deleteRole({{$role->id}}, '{{$role->name}}')">Hapus</button>
<button class="btn btn-sm btn-bold btn-danger mr-2" onclick="deleteRole({{$role->id}}, '{{$role->name}}')">Hapus</button>
@endcan
@can('create', $menus['roleprivileges.index'])
<button class="btn btn-sm btn-bold btn-success" onclick="assignDealer({{$role->id}})"> Tambah Dealer</button>
@endcan
</div>
</td>
@@ -183,6 +201,141 @@
</form>
</div>
</div>
<div class="modal fade" id="assignDealerModal" tabindex="-1" role="dialog" aria-labelledby="assignDealerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<form id="assignDealerForm" method="POST">
@csrf
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="assignDealerModalLabel">
Assign Dealer ke Role
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-light border">
<strong>Petunjuk:</strong> Pilih dealer yang akan di-assign ke role ini. Dealer yang sudah di-assign akan otomatis tercentang.
</div>
<div class="form-group">
<label class="font-weight-bold">
Daftar Dealer
</label>
<div class="dealer-checkboxes border rounded p-3" style="max-height: 350px; overflow-y: auto;">
<div class="row">
@foreach ($dealers as $dealer)
<div class="col-md-6 mb-2">
<div class="form-check custom-checkbox">
<input class="form-check-input dealer-checkbox" type="checkbox"
name="dealers[]" value="{{ $dealer->id }}"
id="dealer_{{ $dealer->id }}">
<label class="form-check-label d-flex align-items-center" for="dealer_{{ $dealer->id }}">
<div class="dealer-info">
<div class="dealer-name font-weight-semibold">{{ $dealer->name }}</div>
<div class="dealer-code text-muted small">
<i class="fas fa-tag mr-1"></i>
{{ $dealer->dealer_code }}
</div>
</div>
</label>
</div>
</div>
@endforeach
</div>
</div>
</div>
<div class="form-group mb-0">
<div class="d-flex justify-content-between align-items-center">
<div>
<button type="button" class="btn btn-outline-primary btn-sm mr-2" onclick="selectAllDealers()">
Pilih Semua
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="deselectAllDealers()">
Hapus Semua
</button>
</div>
<div class="text-muted small">
<span id="selectedCount">0</span> dealer dipilih
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
Batal
</button>
<button type="submit" class="btn btn-primary" id="submitBtn">
Simpan Perubahan
</button>
</div>
</div>
</form>
</div>
</div>
@endsection
@section('styles')
<style>
.custom-checkbox .form-check-label {
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
width: 100%;
}
.custom-checkbox .form-check-label:hover {
background-color: #f8f9fa;
}
.custom-checkbox .form-check-input:checked + .form-check-label {
background-color: #e8f5e8;
border-left: 3px solid #28a745;
}
.dealer-info {
flex: 1;
margin-left: 0.5rem;
}
.dealer-name {
font-weight: 500;
margin-bottom: 0.125rem;
}
.dealer-code {
font-size: 0.75rem;
color: #6c757d;
}
.dealer-checkboxes::-webkit-scrollbar {
width: 6px;
}
.dealer-checkboxes::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dealer-checkboxes::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.dealer-checkboxes::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Responsive improvements */
@media (max-width: 768px) {
.dealer-checkboxes .col-md-6 {
flex: 0 0 100%;
max-width: 100%;
}
}
</style>
@endsection
@section('javascripts')
@@ -296,17 +449,145 @@
}
})
}
function assignDealer(roleId) {
// Set form action
let url = '{{ route("roleprivileges.assignDealer", ":id") }}'.replace(':id', roleId);
$("#assignDealerForm").attr("action", url);
// Reset checkboxes and counter
$('.dealer-checkbox').prop('checked', false);
updateSelectedCount();
// Load existing assigned dealers
$.ajax({
url: '{{ route("roleprivileges.getAssignedDealers", ":id") }}'.replace(':id', roleId),
type: 'GET',
success: function(response) {
if (response.assignedDealers) {
response.assignedDealers.forEach(function(dealerId) {
$(`#dealer_${dealerId}`).prop('checked', true);
});
}
updateSelectedCount();
},
error: function() {
console.log('Error loading assigned dealers');
updateSelectedCount();
}
});
$("#assignDealerModal").modal("show");
}
function selectAllDealers() {
$('.dealer-checkbox').prop('checked', true);
updateSelectedCount();
}
function deselectAllDealers() {
$('.dealer-checkbox').prop('checked', false);
updateSelectedCount();
}
function updateSelectedCount() {
const selectedCount = $('.dealer-checkbox:checked').length;
$('#selectedCount').text(selectedCount);
// Update submit button state
if (selectedCount > 0) {
$('#submitBtn').prop('disabled', false).removeClass('btn-secondary').addClass('btn-primary');
} else {
$('#submitBtn').prop('disabled', true).removeClass('btn-primary').addClass('btn-secondary');
}
}
$(document).ready(function () {
// Add event handlers for modal close buttons
$('.close, [data-dismiss="modal"]').on("click", function () {
$("#roleModal").modal("hide");
$("#roleEditModal").modal("hide");
$("#assignDealerModal").modal("hide");
});
// Also handle the "Close" button
$('.btn-secondary[data-dismiss="modal"]').on("click", function () {
$("#roleModal").modal("hide");
$("#roleEditModal").modal("hide");
$("#assignDealerModal").modal("hide");
});
// Event listener for dealer checkboxes
$(document).on('change', '.dealer-checkbox', function() {
updateSelectedCount();
});
// Handle form submission for assign dealer
$("#assignDealerForm").on("submit", function(e) {
e.preventDefault();
// Validate if at least one dealer is selected
const selectedDealers = $('.dealer-checkbox:checked').length;
if (selectedDealers === 0) {
Swal.fire({
title: 'Peringatan!',
text: 'Silakan pilih minimal satu dealer',
icon: 'warning',
confirmButtonText: 'OK'
});
return;
}
// Disable submit button and show loading
const submitBtn = $('#submitBtn');
const originalText = submitBtn.html();
submitBtn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin mr-1"></i>Menyimpan...');
let formData = new FormData(this);
let url = $(this).attr("action");
$.ajax({
url: url,
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
if (response.success) {
Swal.fire({
title: 'Berhasil!',
text: response.message,
icon: 'success',
confirmButtonText: 'OK'
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: 'Error!',
text: response.message || 'Terjadi kesalahan',
icon: 'error',
confirmButtonText: 'OK'
});
}
},
error: function(xhr) {
let message = 'Terjadi kesalahan';
if (xhr.responseJSON && xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
Swal.fire({
title: 'Error!',
text: message,
icon: 'error',
confirmButtonText: 'OK'
});
},
complete: function() {
// Re-enable submit button
submitBtn.prop('disabled', false).html(originalText);
}
});
});
});
</script>

View File

@@ -24,8 +24,9 @@
</div>
<div class="kt-portlet__body">
<div class="table-responsive">
<!--begin: Datatable -->
<table class="table table-responsive table-striped table-bordered table-hover table-checkable" id="kt_table">
<table class="table table-striped table-bordered table-hover table-checkable" id="kt_table">
<thead>
<tr>
<th>No</th>
@@ -41,6 +42,7 @@
</table>
<!--end: Datatable -->
</div>
</div>
</div>
<!--begin::Modal-->

View File

@@ -0,0 +1,252 @@
@extends('layouts.backapp')
@section('title', 'Tambah Target KPI')
@section('styles')
<style>
.select2-container .select2-selection {
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.select2-container.select2-container--focus .select2-selection {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(197, 214, 233, 0.25);
}
.select2-container .select2-selection--single .select2-selection__rendered {
padding-left: 0;
line-height: 1.5;
}
.select2-container .select2-selection--single .select2-selection__arrow {
height: 100%;
}
.select2-results__option--highlighted[aria-selected] {
background-color: #007bff;
color: white;
}
/* Limit Select2 dropdown height */
.select2-results__options {
max-height: 200px;
overflow-y: auto;
}
/* Style for Select2 results */
.select2-results__option {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.select2-results__option:last-child {
border-bottom: none;
}
/* Improve Select2 search box */
.select2-search--dropdown .select2-search__field {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
</style>
@endsection
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Tambah Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
<form id="kpi-form" method="POST" action="{{ route('kpi.targets.store') }}">
@csrf
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="user_id" class="form-control-label">Mekanik <span class="text-danger">*</span></label>
<select name="user_id" id="user_id" class="form-control select2" required>
<option value="">Pilih Mekanik</option>
@foreach($mechanics as $mechanic)
<option value="{{ $mechanic->id }}" {{ old('user_id') == $mechanic->id ? 'selected' : '' }}>
{{ $mechanic->name }} ({{ $mechanic->dealer->name ?? 'N/A' }})
</option>
@endforeach
</select>
@if($mechanics->isEmpty())
<div class="alert alert-warning mt-2">
<i class="fas fa-exclamation-triangle"></i>
Tidak ada mekanik yang ditemukan. Pastikan ada user dengan role "mechanic" di sistem.
</div>
@else
<small class="form-text text-muted">
Ditemukan {{ $mechanics->count() }} mekanik.
@if($mechanics->count() >= 50)
Menampilkan 50 mekanik pertama. Gunakan pencarian untuk menemukan mekanik tertentu.
@endif
</small>
@endif
@error('user_id')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="target_value" class="form-control-label">Target Nilai <span class="text-danger">*</span></label>
<input type="number" name="target_value" id="target_value" class="form-control"
value="{{ old('target_value') }}" min="1" required>
@error('target_value')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label for="description" class="form-control-label">Deskripsi</label>
<textarea name="description" id="description" class="form-control" rows="3"
placeholder="Deskripsi target (opsional)">{{ old('description') }}</textarea>
@error('description')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" name="is_active" id="is_active" class="custom-control-input"
value="1" {{ old('is_active', true) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">
Target Aktif
</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Simpan Target
</button>
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">Kembali</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
@push('javascripts')
<script>
$(document).ready(function() {
// Initialize Select2 with fallback
try {
// Initialize Select2 for mechanics with search limit
$('#user_id').select2({
theme: 'bootstrap4',
width: '100%',
placeholder: 'Pilih Mekanik',
allowClear: true,
minimumInputLength: 1,
maximumInputLength: 50,
maximumResultsForSearch: 10,
language: {
inputTooShort: function() {
return "Masukkan minimal 1 karakter untuk mencari";
},
inputTooLong: function() {
return "Maksimal 50 karakter";
},
noResults: function() {
return "Tidak ada hasil ditemukan";
},
searching: function() {
return "Mencari...";
}
}
});
} catch (error) {
console.log('Select2 not available, using regular select');
// Fallback: ensure regular select works
$('.select2').removeClass('select2').addClass('form-control');
}
// Form validation
$('#kpi-form').on('submit', function(e) {
var isValid = true;
var errors = [];
// Clear previous errors
$('.text-danger').remove();
// Validate required fields
if (!$('#user_id').val()) {
errors.push('Mekanik harus dipilih');
isValid = false;
}
if (!$('#target_value').val() || $('#target_value').val() < 1) {
errors.push('Target nilai harus diisi dan minimal 1');
isValid = false;
}
if (!isValid) {
e.preventDefault();
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'Validasi Gagal',
html: errors.join('<br>'),
confirmButtonText: 'OK'
});
} else {
alert('Validasi Gagal:\n' + errors.join('\n'));
}
}
});
});
</script>
@endpush

View File

@@ -0,0 +1,263 @@
@extends('layouts.backapp')
@section('title', 'Edit Target KPI')
@section('styles')
<style>
.select2-container .select2-selection {
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.select2-container.select2-container--focus .select2-selection {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.select2-container .select2-selection--single .select2-selection__rendered {
padding-left: 0;
line-height: 1.5;
}
.select2-container .select2-selection--single .select2-selection__arrow {
height: 100%;
}
.select2-results__option--highlighted[aria-selected] {
background-color: #007bff;
color: white;
}
/* Ensure Select2 is visible */
.select2-container {
z-index: 9999;
}
.select2-dropdown {
z-index: 9999;
}
/* Fix Select2 width */
.select2-container--default .select2-selection--single {
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
}
/* Limit Select2 dropdown height */
.select2-results__options {
max-height: 200px;
overflow-y: auto;
}
/* Style for Select2 results */
.select2-results__option {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.select2-results__option:last-child {
border-bottom: none;
}
/* Improve Select2 search box */
.select2-search--dropdown .select2-search__field {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
</style>
@endsection
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Edit Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
<form id="kpi-form" method="POST" action="{{ route('kpi.targets.update', $target->id) }}">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="user_id" class="form-control-label">Mekanik <span class="text-danger">*</span></label>
<select name="user_id" id="user_id" class="form-control select2" required>
<option value="">Pilih Mekanik</option>
@foreach($mechanics as $mechanic)
@php
$isSelected = old('user_id', $target->user_id) == $mechanic->id;
@endphp
<option value="{{ $mechanic->id }}"
{{ $isSelected ? 'selected' : '' }}>
{{ $mechanic->name }}
</option>
@endforeach
</select>
@error('user_id')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="target_value" class="form-control-label">Target Nilai <span class="text-danger">*</span></label>
<input type="number" name="target_value" id="target_value" class="form-control"
value="{{ old('target_value', $target->target_value) }}" min="1" required>
@error('target_value')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label for="description" class="form-control-label">Deskripsi</label>
<textarea name="description" id="description" class="form-control" rows="3"
placeholder="Deskripsi target (opsional)">{{ old('description', $target->description) }}</textarea>
@error('description')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" name="is_active" id="is_active" class="custom-control-input"
value="1" {{ old('is_active', $target->is_active) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">
Target Aktif
</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Update Target
</button>
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">Kembali</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
@push('javascripts')
<script>
$(document).ready(function() {
// Initialize Select2 with fallback and delay
setTimeout(function() {
try {
// Initialize Select2 for mechanics with search limit
$('#user_id').select2({
theme: 'bootstrap4',
width: '100%',
placeholder: 'Pilih Mekanik',
allowClear: true,
minimumInputLength: 1,
maximumInputLength: 50,
maximumResultsForSearch: 10,
language: {
inputTooShort: function() {
return "Masukkan minimal 1 karakter untuk mencari";
},
inputTooLong: function() {
return "Maksimal 50 karakter";
},
noResults: function() {
return "Tidak ada hasil ditemukan";
},
searching: function() {
return "Mencari...";
}
}
});
} catch (error) {
console.log('Select2 not available, using regular select');
// Fallback: ensure regular select works
$('.select2').removeClass('select2').addClass('form-control');
}
}, 100);
// Form validation
$('#kpi-form').on('submit', function(e) {
var isValid = true;
var errors = [];
// Clear previous errors
$('.text-danger').remove();
// Validate required fields
if (!$('#user_id').val()) {
errors.push('Mekanik harus dipilih');
isValid = false;
}
if (!$('#target_value').val() || $('#target_value').val() < 1) {
errors.push('Target nilai harus diisi dan minimal 1');
isValid = false;
}
if (!isValid) {
e.preventDefault();
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'Validasi Gagal',
html: errors.join('<br>'),
confirmButtonText: 'OK'
});
} else {
alert('Validasi Gagal:\n' + errors.join('\n'));
}
}
});
});
</script>
@endpush

View File

@@ -0,0 +1,209 @@
@extends('layouts.backapp')
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Manajemen Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.create') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Tambah Target
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<div class="table-responsive">
<table class="table table-striped table-bordered" id="kpiTargetsTable">
<thead>
<tr>
<th>No</th>
<th>Mekanik</th>
<th>Target</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
@forelse($targets as $target)
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $target->user->name }}</td>
<td>{{ number_format($target->target_value) }}</td>
<td>
@if($target->is_active)
<span class="badge badge-success">Aktif</span>
@else
<span class="badge badge-secondary">Nonaktif</span>
@endif
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ route('kpi.targets.show', $target->id) }}"
class="btn btn-sm btn-info" title="Detail">
<i class="fas fa-eye"></i>
</a>
<a href="{{ route('kpi.targets.edit', $target->id) }}"
class="btn btn-sm btn-warning" title="Edit">
<i class="fas fa-edit"></i>
</a>
<button type="button"
class="btn btn-sm btn-{{ $target->is_active ? 'warning' : 'success' }}"
onclick="toggleStatus({{ $target->id }})"
title="{{ $target->is_active ? 'Nonaktifkan' : 'Aktifkan' }}">
<i class="fas fa-{{ $target->is_active ? 'pause' : 'play' }}"></i>
</button>
<form action="{{ route('kpi.targets.destroy', $target->id) }}"
method="POST"
style="display: inline;"
onsubmit="return confirm('Yakin ingin menghapus target ini?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" title="Hapus">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center">Tidak ada data target KPI</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($targets->hasPages())
<div class="d-flex justify-content-center">
{{ $targets->links() }}
</div>
@endif
</div>
</div>
</div>
</div>
<!-- Filter Modal -->
<div class="modal fade" id="filterModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Filter Target KPI</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="{{ route('kpi.targets.index') }}" method="GET">
<div class="modal-body">
<div class="form-group">
<label>Mekanik</label>
<select name="user_id" class="form-control">
<option value="">Semua Mekanik</option>
@foreach($mechanics as $mechanic)
<option value="{{ $mechanic->id }}"
{{ request('user_id') == $mechanic->id ? 'selected' : '' }}>
{{ $mechanic->name }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<label>Status</label>
<select name="is_active" class="form-control">
<option value="">Semua Status</option>
<option value="1" {{ request('is_active') == '1' ? 'selected' : '' }}>Aktif</option>
<option value="0" {{ request('is_active') == '0' ? 'selected' : '' }}>Nonaktif</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-primary">Filter</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('javascripts')
<script>
$(document).ready(function() {
// Initialize DataTable
$('#kpiTargetsTable').DataTable({
"pageLength": 25,
"order": [[0, "asc"]]
});
// Auto hide alerts after 5 seconds
setTimeout(function() {
$('.alert').fadeOut('slow');
}, 5000);
});
function toggleStatus(targetId) {
if (confirm('Yakin ingin mengubah status target ini?')) {
$.ajax({
url: '{{ route("kpi.targets.toggle-status", ":id") }}'.replace(':id', targetId),
type: 'POST',
data: {
_token: '{{ csrf_token() }}'
},
success: function(response) {
if (response.success) {
Swal.fire({
icon: 'success',
title: 'Berhasil',
text: response.message,
timer: 2000,
showConfirmButton: false
}).then(function() {
location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: response.message
});
}
},
error: function() {
Swal.fire({
icon: 'error',
title: 'Error',
text: 'Terjadi kesalahan saat mengubah status'
});
}
});
}
}
</script>
@endpush

View File

@@ -0,0 +1,193 @@
@extends('layouts.backapp')
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Detail Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.edit', $target->id) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Edit
</a>
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
<div class="row">
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td width="150"><strong>Mekanik</strong></td>
<td>: {{ $target->user->name }}</td>
</tr>
<tr>
<td><strong>Email</strong></td>
<td>: {{ $target->user->email }}</td>
</tr>
<tr>
<td><strong>Dealer</strong></td>
<td>: {{ $target->user->dealer->name ?? 'N/A' }}</td>
</tr>
<tr>
<td><strong>Target Nilai</strong></td>
<td>: {{ number_format($target->target_value) }} Pekerjaan</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>:
@if($target->is_active)
<span class="badge badge-success">Aktif</span>
@else
<span class="badge badge-secondary">Nonaktif</span>
@endif
</td>
</tr>
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td width="150"><strong>Jenis Target</strong></td>
<td>: <span class="badge badge-info">Target Permanen</span></td>
</tr>
<tr>
<td><strong>Berlaku Sejak</strong></td>
<td>: {{ $target->created_at->format('d/m/Y') }}</td>
</tr>
<tr>
<td><strong>Dibuat</strong></td>
<td>: {{ $target->created_at->format('d/m/Y H:i') }}</td>
</tr>
<tr>
<td><strong>Terakhir Update</strong></td>
<td>: {{ $target->updated_at->format('d/m/Y H:i') }}</td>
</tr>
<tr>
<td><strong>Total Pencapaian</strong></td>
<td>: {{ $target->achievements->count() }} Bulan</td>
</tr>
</table>
</div>
</div>
@if($target->description)
<div class="row mt-3">
<div class="col-12">
<h6><strong>Deskripsi:</strong></h6>
<p class="text-muted">{{ $target->description }}</p>
</div>
</div>
@endif
<!-- Achievement History -->
<div class="row mt-4">
<div class="col-12">
<h5><i class="fas fa-chart-line"></i> Riwayat Pencapaian Bulanan</h5>
@if($target->achievements->count() > 0)
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Periode</th>
<th>Target</th>
<th>Aktual</th>
<th>Pencapaian</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($target->achievements->sortByDesc('year')->sortByDesc('month') as $achievement)
<tr>
<td>{{ $achievement->getPeriodDisplayName() }}</td>
<td>{{ number_format($achievement->target_value) }}</td>
<td>{{ number_format($achievement->actual_value) }}</td>
<td>{{ number_format($achievement->achievement_percentage, 1) }}%</td>
<td>
<span class="badge badge-{{ $achievement->status_color }}">
@switch($achievement->status)
@case('exceeded')
Melebihi Target
@break
@case('good')
Baik
@break
@case('fair')
Cukup
@break
@case('poor')
Kurang
@break
@default
Tidak Diketahui
@endswitch
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> Belum ada data pencapaian untuk target ini.
</div>
@endif
</div>
</div>
<!-- Summary Statistics -->
@if($target->achievements->count() > 0)
<div class="row mt-4">
<div class="col-12">
<h5><i class="fas fa-chart-bar"></i> Statistik Pencapaian</h5>
<div class="row">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<h4>{{ $target->achievements->count() }}</h4>
<small>Total Pencapaian</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body text-center">
<h4>{{ $target->achievements->where('achievement_percentage', '>=', 100)->count() }}</h4>
<small>Target Tercapai</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h4>{{ number_format($target->achievements->avg('achievement_percentage'), 1) }}%</h4>
<small>Rata-rata Pencapaian</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body text-center">
<h4>{{ number_format($target->achievements->max('achievement_percentage'), 1) }}%</h4>
<small>Pencapaian Tertinggi</small>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
@endsection

View File

@@ -48,7 +48,6 @@
@if(Gate::check('view', $menus['user.index']) || Gate::check('view', $menus['roleprivileges.index']))
<div class="kt-menu__section" style="padding: 15px 20px; margin-top: 10px; margin-bottom: 5px;">
<div style="display: flex; align-items: center; color: #a7abc3; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="fa fa-users" style="margin-right: 8px; font-size: 14px;"></i>
<span>Manajemen Pengguna</span>
</div>
</div>
@@ -77,7 +76,6 @@
@if(Gate::check('view', $menus['work.index']) || Gate::check('view', $menus['category.index']) || Gate::check('view', $menus['dealer.index']))
<div class="kt-menu__section" style="padding: 15px 20px; margin-top: 10px; margin-bottom: 5px;">
<div style="display: flex; align-items: center; color: #a7abc3; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="fa fa-exchange-alt" style="margin-right: 8px; font-size: 14px;"></i>
<span>Manajemen Transaksi</span>
</div>
</div>
@@ -115,7 +113,6 @@
@if(Gate::check('view', $menus['products.index']) || Gate::check('view', $menus['product_categories.index']) || Gate::check('view', $menus['mutations.index']) || Gate::check('view', $menus['opnames.index']) || Gate::check('view', $menus['stock-audit.index']))
<div class="kt-menu__section" style="padding: 15px 20px; margin-top: 10px; margin-bottom: 5px;">
<div style="display: flex; align-items: center; color: #a7abc3; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="fa fa-warehouse" style="margin-right: 8px; font-size: 14px;"></i>
<span>Manajemen Gudang</span>
</div>
</div>
@@ -171,7 +168,6 @@
@if(Gate::check('view', $menus['report.transaction_sa']) || Gate::check('view', $menus['report.transaction']) || Gate::check('view', $menus['report.transaction_dealer']) || Gate::check('view', $menus['work.index']))
<div class="kt-menu__section" style="padding: 15px 20px; margin-top: 10px; margin-bottom: 5px;">
<div style="display: flex; align-items: center; color: #a7abc3; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="fa fa-chart-bar" style="margin-right: 8px; font-size: 14px;"></i>
<span>Laporan</span>
</div>
</div>
@@ -205,23 +201,42 @@
</li>
@endcan
@can('view', $menus['work.index'])
@can('view', $menus['reports.stock-product.index'])
<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>
<span class="kt-menu__link-text">Stok Produk</span>
</a>
</li>
@endcan
@can('view', $menus['work.index'])
@can('view', $menus['reports.technician.index'])
<li class="kt-menu__item" aria-haspopup="true">
<a href="{{ route('work.index') }}" class="kt-menu__link">
<a href="{{ route('reports.technician.index') }}" class="kt-menu__link">
<i class="fa fa-user-cog" style="display: flex; align-items: center; margin-right: 10px;"></i>
<span class="kt-menu__link-text">Teknisi</span>
</a>
</li>
@endcan
{{-- Section Header - Only show if user has access to any submenu --}}
@if(Gate::check('view', $menus['kpi.targets.index']))
<div class="kt-menu__section" style="padding: 15px 20px; margin-top: 10px; margin-bottom: 5px;">
<div style="display: flex; align-items: center; color: #a7abc3; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
<span>KPI</span>
</div>
</div>
@endif
{{-- Submenu Items --}}
@can('view', $menus['kpi.targets.index'])
<li class="kt-menu__item" aria-haspopup="true">
<a href="{{ route('kpi.targets.index') }}" class="kt-menu__link">
<i class="fa fa-user-cog" style="display: flex; align-items: center; margin-right: 10px;"></i>
<span class="kt-menu__link-text">Target</span>
</a>
</li>
@endcan
</ul>
</div>
</div>

View File

@@ -0,0 +1,544 @@
@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;
}
/* 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">&nbsp;</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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -249,22 +249,72 @@
$string = "*[Laporan Harian Mekanik ]*\n";
$string .= "*". Auth::user()->dealer->name ."*\n";
$string .= "*". $transaction_mechanics['today_date'] ."*\n\n\n";
// Add KPI Achievement Information
if($kpiData['has_target']) {
$string .= "*=== KPI ACHIEVEMENT PROGRESS ===*\n";
$string .= "*Target ". $kpiData['period'] .": ". number_format($kpiData['target']) ." Pekerjaan*\n";
$string .= "*Pencapaian: ". number_format($kpiData['actual']) ." Pekerjaan*\n";
$string .= "*Progress: ". $kpiData['percentage'] ."%*\n";
// Add status message
if($kpiData['status'] == 'exceeded') {
$string .= "*Status: ✅ Target tercapai!*\n";
} elseif($kpiData['status'] == 'good') {
$string .= "*Status: 🔵 Performa baik*\n";
} elseif($kpiData['status'] == 'fair') {
$string .= "*Status: 🟡 Perlu peningkatan*\n";
} elseif($kpiData['status'] == 'poor') {
$string .= "*Status: 🔴 Perlu perbaikan*\n";
} else {
$string .= "*Status: ⚪ Belum ada data*\n";
}
$string .= "\n";
}
$overall_total = 0;
// Debug: Check if data exists
if (!empty($transaction_mechanics['data'])) {
foreach ($transaction_mechanics['data'] as $shortname => $trx) {
$string .= $shortname."\n";
$string .= "*". $shortname ."*\n";
$total_qty = 0;
// Check if data array exists
if (isset($trx['data']) && is_array($trx['data'])) {
foreach ($trx['data'] as $item) {
$total_qty += explode(':', $item)[1];
$parts = explode(':', $item);
if (count($parts) >= 2) {
$qty = intval($parts[1]);
$total_qty += $qty;
}
$string .= $item."\n";
}
$string .= "*TOTAL".$total_qty."*\n\n";
$string .= $trx['total_title'];
foreach ($trx['total_body'] as $total) {
$string .= $total;
}
$string .= $trx['total_total']."\n\n";
$string .= "*TOTAL: ".$total_qty." Unit*\n\n";
$overall_total += $total_qty;
// Remove monthly data display since this is daily report
// if (isset($trx['total_body']) && is_array($trx['total_body'])) {
// foreach ($trx['total_body'] as $total) {
// $string .= $total;
// }
// }
// if (isset($trx['total_total'])) {
// $string .= $trx['total_total']."\n\n";
// }
}
} else {
$string .= "*Tidak ada data transaksi hari ini*\n\n";
}
// Add overall summary
$string .= "*=== RINGKASAN HARIAN ===*\n";
$string .= "*Tanggal: ". $transaction_mechanics['today_date'] ."*\n";
$string .= "*Dealer: ". Auth::user()->dealer->name ."*\n";
$string .= "*Total Keseluruhan: ". $overall_total ." Unit*\n";
@endphp
{!! $string !!}
</div>
@@ -335,23 +385,156 @@
<script>
$("#kt-table").DataTable()
const shareData = {
title: 'Dealer',
text: $("#shareThis").html()
// Check if Web Share API is supported
function isWebShareSupported() {
return navigator.share && typeof navigator.share === 'function';
}
// Fallback function for copying to clipboard
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
// Use modern clipboard API
return navigator.clipboard.writeText(text).then(() => {
return true;
}).catch(() => {
return false;
});
} else {
// Fallback for older browsers
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return Promise.resolve(successful);
} catch (err) {
return Promise.resolve(false);
}
}
}
// Main share function
async function shareData() {
const shareText = $("#shareThis").html();
if (isWebShareSupported()) {
// Use Web Share API
try {
await navigator.share({
title: 'Laporan Harian Mekanik',
text: shareText
});
console.log('Data berhasil dibagikan');
showSuccessMessage('Data berhasil dibagikan!');
} catch (err) {
console.log('Share cancelled or failed:', err);
// Fallback to clipboard
await copyToClipboardFallback(shareText);
}
} else {
// Fallback for unsupported browsers
await copyToClipboardFallback(shareText);
}
}
// Clipboard fallback function
async function copyToClipboardFallback(text) {
const success = await copyToClipboard(text);
if (success) {
showSuccessMessage('Data berhasil disalin ke clipboard! Silakan paste di aplikasi yang diinginkan.');
} else {
showErrorMessage('Gagal menyalin data. Silakan copy manual dari bawah ini:');
showManualCopy(text);
}
}
// Show success message
function showSuccessMessage(message) {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: message,
timer: 3000,
showConfirmButton: false
});
}
// Show error message
function showErrorMessage(message) {
Swal.fire({
icon: 'warning',
title: 'Perhatian',
text: message,
confirmButtonText: 'OK'
});
}
// Show manual copy option
function showManualCopy(text) {
const modal = `
<div class="modal fade" id="manualCopyModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Copy Manual Data</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<p>Silakan copy data di bawah ini:</p>
<textarea class="form-control" rows="15" readonly style="font-family: monospace; font-size: 12px;">${text}</textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Tutup</button>
<button type="button" class="btn btn-primary" onclick="copyFromTextarea()">Copy</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
$('#manualCopyModal').remove();
// Add modal to body
$('body').append(modal);
$('#manualCopyModal').modal('show');
}
// Copy from textarea function
function copyFromTextarea() {
const textarea = document.querySelector('#manualCopyModal textarea');
textarea.select();
textarea.setSelectionRange(0, 99999); // For mobile devices
try {
const successful = document.execCommand('copy');
if (successful) {
showSuccessMessage('Data berhasil disalin!');
$('#manualCopyModal').modal('hide');
} else {
showErrorMessage('Gagal menyalin data. Silakan copy manual.');
}
} catch (err) {
showErrorMessage('Gagal menyalin data. Silakan copy manual.');
}
}
// Share button click handler
const btn = $('#share');
const resultPara = $('.result');
// Share must be triggered by "user activation"
btn.click(async function() {
try {
await navigator.share(shareData)
console.log('Dealer shared successfully')
} catch(err) {
console.log('Error: ' + err)
}
})
btn.click(function() {
shareData();
});
function editTransaction(id) {
let form_action = $("#editTransaction"+id).attr("data-action")

View File

@@ -0,0 +1,810 @@
@extends('layouts.frontapp')
@section('styles')
<style>
.card {
border: 1px solid #ddd;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card-body {
padding: 20px;
}
.section-header {
background: linear-gradient(135deg, #2c5aa0 0%, #1e3a8a 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
margin: 25px 0 15px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.section-header h5 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.section-header i {
margin-right: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
display: block;
}
.form-control {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 12px 15px;
font-size: 14px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #2c5aa0;
box-shadow: 0 0 0 0.2rem rgba(44, 90, 160, 0.25);
}
.form-control[readonly] {
background-color: #f8f9fa;
color: #6c757d;
}
.camera-container {
border: 2px dashed #ddd;
border-radius: 12px;
padding: 15px;
background: #f8f9fa;
margin-top: 10px;
}
.camera-video {
width: 100%;
max-width: 400px;
height: 250px;
background: #000;
border-radius: 8px;
margin-bottom: 10px;
}
.camera-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.btn {
border-radius: 8px;
font-weight: 500;
padding: 8px 16px;
font-size: 14px;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #2c5aa0 0%, #1e3a8a 100%);
border: none;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.btn-success {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
border: none;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.btn-secondary {
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
border: none;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.btn-lg {
padding: 15px 30px;
font-size: 16px;
}
.photo-preview {
margin-top: 10px;
}
.photo-preview img {
max-width: 200px;
max-height: 150px;
border-radius: 8px;
border: 3px solid #059669;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.alert {
border-radius: 8px;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#postcheck_notes {
resize: vertical;
min-height: 80px;
}
.file-input-hidden {
display: none;
}
.file-info {
margin-top: 5px;
font-size: 12px;
color: #6c757d;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 0 10px;
}
.card-body {
padding: 15px;
}
.section-header {
padding: 10px 15px;
margin: 20px 0 10px 0;
}
.section-header h5 {
font-size: 14px;
}
.form-control {
padding: 10px 12px;
font-size: 16px; /* Prevent zoom on iOS */
}
.camera-video {
height: 200px;
}
.camera-controls {
flex-direction: column;
}
.btn {
width: 100%;
margin-bottom: 5px;
}
.photo-preview img {
max-width: 100%;
height: auto;
}
.form-row {
margin: 0;
}
.form-row > .col-md-6 {
padding: 0 5px;
}
}
@media (max-width: 576px) {
.mobile-container {
padding: 0;
}
.card {
border-radius: 0;
margin-bottom: 0;
}
.camera-container {
padding: 10px;
}
.camera-video {
height: 180px;
}
}
</style>
@endsection
@section('content')
<div class="mobile-container">
<div class="container">
<div class="row mb-4 mt-4">
<div class="col-8">
<a href="/"><img src="{{ asset('logo-ckb.png') }}" style="width: 100%" alt="LOGO CKB"></a>
</div>
<div class="col-4 text-right my-auto">
<a class="btn btn-sm btn-danger mt-3" style="background: red !important;" href="{{ route('logout') }}" onclick="logout(event)">
{{ __('Logout') }}
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
@csrf
</form>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<h2 class="text-center">Form Postcheck</h2>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<a href="/" class="btn btn-warning btn-sm">
Kembali
</a>
<form action="{{ route('postchecks.store', $transaction->id) }}" method="POST" id="postcheckForm" enctype="multipart/form-data">
@csrf
<input type="hidden" name="transaction_id" value="{{ $transaction->id }}">
<!-- Informasi Transaksi -->
<div class="section-header">
<h5><i class="fas fa-info-circle"></i> Informasi Transaksi</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label>Nomor Polisi</label>
<input type="text" class="form-control" value="{{ $transaction->police_number }}" readonly>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Nomor SPK</label>
<input type="text" class="form-control" value="{{ $transaction->spk }}" readonly>
</div>
</div>
</div>
<!-- Data Dasar -->
<div class="section-header">
<h5><i class="fas fa-clipboard-list"></i> Data Dasar</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label>Tanggal Postcheck</label>
<input type="text" class="form-control" value="{{ now()->format('d/m/Y H:i') }}" readonly>
<small class="form-text text-muted">Tanggal dan waktu saat ini akan digunakan</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="kilometer">Kilometer <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="kilometer" name="kilometer" step="0.01" required>
</div>
</div>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="pressure_high">Pressure High (PSI) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="pressure_high" name="pressure_high" step="0.01" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="pressure_low">Pressure Low (PSI)</label>
<input type="number" class="form-control" id="pressure_low" name="pressure_low" step="0.01">
</div>
</div>
</div>
<!-- Foto Depan -->
<div class="section-header">
<h5><i class="fas fa-camera"></i> Foto Depan Kendaraan <span class="text-danger">*</span></h5>
</div>
<div class="form-group">
<input type="file" id="front_image" name="front_image" accept="image/*" class="file-input-hidden" required>
<div class="camera-container">
<video id="front_camera" autoplay playsinline class="camera-video"></video>
<canvas id="front_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('front_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('front_camera', 'front_canvas', 'front_image', 'front_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'front_image', 'front_preview')">
</div>
<div id="front_preview" class="photo-preview"></div>
</div>
</div>
<!-- Foto Suhu Kabin -->
<div class="section-header">
<h5><i class="fas fa-thermometer-half"></i> Foto Suhu Kabin</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="cabin_temperature">Suhu Kabin (°C)</label>
<input type="number" class="form-control" id="cabin_temperature" name="cabin_temperature" step="0.1" placeholder="Masukkan suhu kabin">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cabin_temperature_image">Foto Suhu Kabin</label>
<input type="file" id="cabin_temperature_image" name="cabin_temperature_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="cabin_camera" autoplay playsinline class="camera-video"></video>
<canvas id="cabin_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('cabin_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('cabin_camera', 'cabin_canvas', 'cabin_temperature_image', 'cabin_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'cabin_temperature_image', 'cabin_preview')">
</div>
<div id="cabin_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi AC -->
<div class="section-header">
<h5><i class="fas fa-snowflake"></i> Kondisi AC</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="ac_condition">Kondisi AC</label>
<select class="form-control" id="ac_condition" name="ac_condition">
<option value="">Pilih Kondisi</option>
@foreach($acConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="ac_image">Foto AC</label>
<input type="file" id="ac_image" name="ac_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="ac_camera" autoplay playsinline class="camera-video"></video>
<canvas id="ac_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('ac_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('ac_camera', 'ac_canvas', 'ac_image', 'ac_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'ac_image', 'ac_preview')">
</div>
<div id="ac_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi Blower -->
<div class="section-header">
<h5><i class="fas fa-fan"></i> Kondisi Blower</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="blower_condition">Kondisi Blower</label>
<select class="form-control" id="blower_condition" name="blower_condition">
<option value="">Pilih Kondisi</option>
@foreach($blowerConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="blower_image">Foto Blower</label>
<input type="file" id="blower_image" name="blower_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="blower_camera" autoplay playsinline class="camera-video"></video>
<canvas id="blower_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('blower_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('blower_camera', 'blower_canvas', 'blower_image', 'blower_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'blower_image', 'blower_preview')">
</div>
<div id="blower_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi Evaporator -->
<div class="section-header">
<h5><i class="fas fa-tint"></i> Kondisi Evaporator</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="evaporator_condition">Kondisi Evaporator</label>
<select class="form-control" id="evaporator_condition" name="evaporator_condition">
<option value="">Pilih Kondisi</option>
@foreach($evaporatorConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="evaporator_image">Foto Evaporator</label>
<input type="file" id="evaporator_image" name="evaporator_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="evaporator_camera" autoplay playsinline class="camera-video"></video>
<canvas id="evaporator_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('evaporator_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('evaporator_camera', 'evaporator_canvas', 'evaporator_image', 'evaporator_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'evaporator_image', 'evaporator_preview')">
</div>
<div id="evaporator_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi Compressor -->
<div class="section-header">
<h5><i class="fas fa-cogs"></i> Kondisi Compressor</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="compressor_condition">Kondisi Compressor</label>
<select class="form-control" id="compressor_condition" name="compressor_condition">
<option value="">Pilih Kondisi</option>
@foreach($compressorConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="postcheck_notes">Catatan Tambahan</label>
<textarea class="form-control" id="postcheck_notes" name="postcheck_notes" rows="3" placeholder="Masukkan catatan tambahan jika ada..."></textarea>
</div>
</div>
</div>
<!-- Tombol Submit -->
<div class="section-header">
<h5><i class="fas fa-save"></i> Simpan Data</h5>
</div>
<div class="form-group text-center">
<button type="submit" class="btn btn-primary btn-lg">
Simpan Postcheck
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('javascripts')
<script>
let streams = {};
// Logout function
function logout(event){
event.preventDefault();
Swal.fire({
title: 'Logout?',
text: "Anda akan keluar dari sistem!",
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#dedede',
confirmButtonText: 'Logout'
}).then((result) => {
if (result.value) {
$('#logout-form').submit();
}
})
}
// Fallback untuk browser lama
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = function(constraints) {
// Coba berbagai versi getUserMedia
const getUserMedia = navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia ||
navigator.oGetUserMedia;
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia tidak didukung di browser ini'));
}
return new Promise(function(resolve, reject) {
getUserMedia.call(navigator, constraints, resolve, reject);
});
}
}
// Tambahan fallback untuk browser yang sangat lama
if (navigator.getUserMedia === undefined) {
navigator.getUserMedia = navigator.mediaDevices.getUserMedia;
}
// Start camera
async function startCamera(videoId) {
try {
const video = document.getElementById(videoId);
// Cek apakah browser mendukung getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Browser tidak mendukung akses kamera');
}
// Stop stream yang sedang berjalan
if (streams[videoId]) {
streams[videoId].getTracks().forEach(track => track.stop());
}
// Konfigurasi kamera
const constraints = {
video: {
width: { min: 320, ideal: 640, max: 1280 },
height: { min: 240, ideal: 480, max: 720 },
aspectRatio: { ideal: 4/3 }
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
streams[videoId] = stream;
// Tunggu video siap
video.onloadedmetadata = function() {
video.play();
};
video.onerror = function(e) {
console.error('Error pada video:', e);
alert('Error pada video stream');
};
} catch (err) {
// Pesan error yang lebih spesifik
let errorMessage = 'Tidak dapat mengakses kamera. ';
if (err.name === 'NotAllowedError') {
errorMessage += 'Izin kamera ditolak. Silakan:\n1. Klik ikon kamera di address bar\n2. Pilih "Allow"\n3. Refresh halaman';
} else if (err.name === 'NotFoundError') {
errorMessage += 'Kamera tidak ditemukan. Pastikan HP memiliki kamera.';
} else if (err.name === 'NotReadableError') {
errorMessage += 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi kamera lain.';
} else if (err.name === 'OverconstrainedError') {
errorMessage += 'Kamera tidak mendukung resolusi yang diminta.';
} else if (err.name === 'SecurityError') {
errorMessage += 'Akses kamera diblokir. Pastikan menggunakan HTTPS atau localhost.';
} else {
errorMessage += 'Error: ' + err.message;
}
alert(errorMessage);
}
}
// Capture photo and convert to file
function capturePhoto(videoId, canvasId, inputId, previewId) {
const video = document.getElementById(videoId);
const canvas = document.getElementById(canvasId);
const fileInput = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!video.srcObject) {
alert('Silakan buka kamera terlebih dahulu');
return;
}
// Pastikan video sudah siap
if (video.videoWidth === 0 || video.videoHeight === 0) {
alert('Video belum siap. Tunggu sebentar dan coba lagi.');
return;
}
try {
const context = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert canvas ke File object
canvas.toBlob(function(blob) {
// Buat File object
const file = new File([blob], `photo_${Date.now()}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now()
});
// Assign ke file input
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
// Preview
const url = URL.createObjectURL(blob);
preview.innerHTML = `
<img src="${url}" style="max-width: 200px; max-height: 150px; border-radius: 8px; border: 3px solid #059669; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div class="mt-2">
<small class="text-success"><i class="fas fa-check"></i> Foto berhasil diambil</small>
<br>
<small class="text-muted">Ukuran: ${(file.size / 1024).toFixed(1)} KB</small>
</div>
`;
}, 'image/jpeg', 0.8);
} catch (err) {
alert('Gagal mengambil foto: ' + err.message);
}
}
// Handle file upload from gallery
function handleFileUpload(input, inputId, previewId) {
const file = input.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Pilih file gambar');
return;
}
// Validasi ukuran file (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Ukuran file maksimal 2MB');
return;
}
// Assign ke file input yang sesuai
const targetInput = document.getElementById(inputId);
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
targetInput.files = dataTransfer.files;
// Preview
const url = URL.createObjectURL(file);
const preview = document.getElementById(previewId);
preview.innerHTML = `
<img src="${url}" style="max-width: 200px; max-height: 150px; border-radius: 8px; border: 3px solid #059669; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div class="mt-2">
<small class="text-success"><i class="fas fa-check"></i> Foto berhasil diupload</small>
<br>
<small class="text-muted">Ukuran: ${(file.size / 1024).toFixed(1)} KB</small>
</div>
`;
}
// Stop all cameras when page is unloaded
window.addEventListener('beforeunload', function() {
Object.values(streams).forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
});
// Form validation
document.getElementById('postcheckForm').addEventListener('submit', function(e) {
const requiredFields = ['kilometer', 'front_image', 'pressure_high'];
let isValid = true;
requiredFields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field.type === 'file') {
// Validasi file input
if (!field.files || field.files.length === 0) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
} else {
// Validasi input biasa
if (!field.value.trim()) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
}
});
if (!isValid) {
e.preventDefault();
alert('Mohon lengkapi semua field yang wajib diisi');
}
});
</script>
@endsection

View File

@@ -0,0 +1,810 @@
@extends('layouts.frontapp')
@section('styles')
<style>
.card {
border: 1px solid #ddd;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card-body {
padding: 20px;
}
.section-header {
background: linear-gradient(135deg, #2c5aa0 0%, #1e3a8a 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
margin: 25px 0 15px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.section-header h5 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.section-header i {
margin-right: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
display: block;
}
.form-control {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 12px 15px;
font-size: 14px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #2c5aa0;
box-shadow: 0 0 0 0.2rem rgba(44, 90, 160, 0.25);
}
.form-control[readonly] {
background-color: #f8f9fa;
color: #6c757d;
}
.camera-container {
border: 2px dashed #ddd;
border-radius: 12px;
padding: 15px;
background: #f8f9fa;
margin-top: 10px;
}
.camera-video {
width: 100%;
max-width: 400px;
height: 250px;
background: #000;
border-radius: 8px;
margin-bottom: 10px;
}
.camera-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.btn {
border-radius: 8px;
font-weight: 500;
padding: 8px 16px;
font-size: 14px;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #2c5aa0 0%, #1e3a8a 100%);
border: none;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.btn-success {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
border: none;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.btn-secondary {
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
border: none;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.btn-lg {
padding: 15px 30px;
font-size: 16px;
}
.photo-preview {
margin-top: 10px;
}
.photo-preview img {
max-width: 200px;
max-height: 150px;
border-radius: 8px;
border: 3px solid #059669;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.alert {
border-radius: 8px;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#precheck_notes {
resize: vertical;
min-height: 80px;
}
.file-input-hidden {
display: none;
}
.file-info {
margin-top: 5px;
font-size: 12px;
color: #6c757d;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 0 10px;
}
.card-body {
padding: 15px;
}
.section-header {
padding: 10px 15px;
margin: 20px 0 10px 0;
}
.section-header h5 {
font-size: 14px;
}
.form-control {
padding: 10px 12px;
font-size: 16px; /* Prevent zoom on iOS */
}
.camera-video {
height: 200px;
}
.camera-controls {
flex-direction: column;
}
.btn {
width: 100%;
margin-bottom: 5px;
}
.photo-preview img {
max-width: 100%;
height: auto;
}
.form-row {
margin: 0;
}
.form-row > .col-md-6 {
padding: 0 5px;
}
}
@media (max-width: 576px) {
.mobile-container {
padding: 0;
}
.card {
border-radius: 0;
margin-bottom: 0;
}
.camera-container {
padding: 10px;
}
.camera-video {
height: 180px;
}
}
</style>
@endsection
@section('content')
<div class="mobile-container">
<div class="container">
<div class="row mb-4 mt-4">
<div class="col-8">
<a href="/"><img src="{{ asset('logo-ckb.png') }}" style="width: 100%" alt="LOGO CKB"></a>
</div>
<div class="col-4 text-right my-auto">
<a class="btn btn-sm btn-danger mt-3" style="background: red !important;" href="{{ route('logout') }}" onclick="logout(event)">
{{ __('Logout') }}
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
@csrf
</form>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<h2 class="text-center">Form Precheck</h2>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<ul class="mb-0">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<a href="/" class="btn btn-warning btn-sm">
Kembali
</a>
<form action="{{ route('prechecks.store', $transaction->id) }}" method="POST" id="precheckForm" enctype="multipart/form-data">
@csrf
<input type="hidden" name="transaction_id" value="{{ $transaction->id }}">
<!-- Informasi Transaksi -->
<div class="section-header">
<h5><i class="fas fa-info-circle"></i> Informasi Transaksi</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label>Nomor Polisi</label>
<input type="text" class="form-control" value="{{ $transaction->police_number }}" readonly>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Nomor SPK</label>
<input type="text" class="form-control" value="{{ $transaction->spk }}" readonly>
</div>
</div>
</div>
<!-- Data Dasar -->
<div class="section-header">
<h5><i class="fas fa-clipboard-list"></i> Data Dasar</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label>Tanggal Precheck</label>
<input type="text" class="form-control" value="{{ now()->format('d/m/Y H:i') }}" readonly>
<small class="form-text text-muted">Tanggal dan waktu saat ini akan digunakan</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="kilometer">Kilometer <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="kilometer" name="kilometer" step="0.01" required>
</div>
</div>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="pressure_high">Pressure High (PSI) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="pressure_high" name="pressure_high" step="0.01" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="pressure_low">Pressure Low (PSI)</label>
<input type="number" class="form-control" id="pressure_low" name="pressure_low" step="0.01">
</div>
</div>
</div>
<!-- Foto Depan -->
<div class="section-header">
<h5><i class="fas fa-camera"></i> Foto Depan Kendaraan <span class="text-danger">*</span></h5>
</div>
<div class="form-group">
<input type="file" id="front_image" name="front_image" accept="image/*" class="file-input-hidden" required>
<div class="camera-container">
<video id="front_camera" autoplay playsinline class="camera-video"></video>
<canvas id="front_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('front_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('front_camera', 'front_canvas', 'front_image', 'front_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'front_image', 'front_preview')">
</div>
<div id="front_preview" class="photo-preview"></div>
</div>
</div>
<!-- Foto Suhu Kabin -->
<div class="section-header">
<h5><i class="fas fa-thermometer-half"></i> Foto Suhu Kabin</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="cabin_temperature">Suhu Kabin (°C)</label>
<input type="number" class="form-control" id="cabin_temperature" name="cabin_temperature" step="0.1" placeholder="Masukkan suhu kabin">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cabin_temperature_image">Foto Suhu Kabin</label>
<input type="file" id="cabin_temperature_image" name="cabin_temperature_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="cabin_camera" autoplay playsinline class="camera-video"></video>
<canvas id="cabin_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('cabin_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('cabin_camera', 'cabin_canvas', 'cabin_temperature_image', 'cabin_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'cabin_temperature_image', 'cabin_preview')">
</div>
<div id="cabin_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi AC -->
<div class="section-header">
<h5><i class="fas fa-snowflake"></i> Kondisi AC</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="ac_condition">Kondisi AC</label>
<select class="form-control" id="ac_condition" name="ac_condition">
<option value="">Pilih Kondisi</option>
@foreach($acConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="ac_image">Foto AC</label>
<input type="file" id="ac_image" name="ac_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="ac_camera" autoplay playsinline class="camera-video"></video>
<canvas id="ac_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('ac_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('ac_camera', 'ac_canvas', 'ac_image', 'ac_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'ac_image', 'ac_preview')">
</div>
<div id="ac_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi Blower -->
<div class="section-header">
<h5><i class="fas fa-fan"></i> Kondisi Blower</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="blower_condition">Kondisi Blower</label>
<select class="form-control" id="blower_condition" name="blower_condition">
<option value="">Pilih Kondisi</option>
@foreach($blowerConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="blower_image">Foto Blower</label>
<input type="file" id="blower_image" name="blower_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="blower_camera" autoplay playsinline class="camera-video"></video>
<canvas id="blower_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('blower_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('blower_camera', 'blower_canvas', 'blower_image', 'blower_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'blower_image', 'blower_preview')">
</div>
<div id="blower_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi Evaporator -->
<div class="section-header">
<h5><i class="fas fa-tint"></i> Kondisi Evaporator</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="evaporator_condition">Kondisi Evaporator</label>
<select class="form-control" id="evaporator_condition" name="evaporator_condition">
<option value="">Pilih Kondisi</option>
@foreach($evaporatorConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="evaporator_image">Foto Evaporator</label>
<input type="file" id="evaporator_image" name="evaporator_image" accept="image/*" class="file-input-hidden">
<div class="camera-container">
<video id="evaporator_camera" autoplay playsinline class="camera-video"></video>
<canvas id="evaporator_canvas" style="display: none;"></canvas>
<div class="camera-controls">
<button type="button" class="btn btn-primary btn-sm" onclick="startCamera('evaporator_camera')">
<i class="fas fa-camera"></i> Buka Kamera
</button>
<button type="button" class="btn btn-success btn-sm" onclick="capturePhoto('evaporator_camera', 'evaporator_canvas', 'evaporator_image', 'evaporator_preview')">
<i class="fas fa-camera-retro"></i> Ambil Foto
</button>
</div>
<div class="mt-2">
<small class="text-muted">Atau upload foto dari galeri:</small>
<input type="file" class="form-control-file mt-1" accept="image/*" onchange="handleFileUpload(this, 'evaporator_image', 'evaporator_preview')">
</div>
<div id="evaporator_preview" class="photo-preview"></div>
</div>
</div>
</div>
</div>
<!-- Kondisi Compressor -->
<div class="section-header">
<h5><i class="fas fa-cogs"></i> Kondisi Compressor</h5>
</div>
<div class="form-row">
<div class="col-md-6">
<div class="form-group">
<label for="compressor_condition">Kondisi Compressor</label>
<select class="form-control" id="compressor_condition" name="compressor_condition">
<option value="">Pilih Kondisi</option>
@foreach($compressorConditions as $condition)
<option value="{{ $condition }}">{{ ucfirst($condition) }}</option>
@endforeach
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="precheck_notes">Catatan Tambahan</label>
<textarea class="form-control" id="precheck_notes" name="precheck_notes" rows="3" placeholder="Masukkan catatan tambahan jika ada..."></textarea>
</div>
</div>
</div>
<!-- Tombol Submit -->
<div class="section-header">
<h5><i class="fas fa-save"></i> Simpan Data</h5>
</div>
<div class="form-group text-center">
<button type="submit" class="btn btn-primary btn-lg">
Simpan Precheck
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('javascripts')
<script>
let streams = {};
// Logout function
function logout(event){
event.preventDefault();
Swal.fire({
title: 'Logout?',
text: "Anda akan keluar dari sistem!",
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#dedede',
confirmButtonText: 'Logout'
}).then((result) => {
if (result.value) {
$('#logout-form').submit();
}
})
}
// Fallback untuk browser lama
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = function(constraints) {
// Coba berbagai versi getUserMedia
const getUserMedia = navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia ||
navigator.oGetUserMedia;
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia tidak didukung di browser ini'));
}
return new Promise(function(resolve, reject) {
getUserMedia.call(navigator, constraints, resolve, reject);
});
}
}
// Tambahan fallback untuk browser yang sangat lama
if (navigator.getUserMedia === undefined) {
navigator.getUserMedia = navigator.mediaDevices.getUserMedia;
}
// Start camera
async function startCamera(videoId) {
try {
const video = document.getElementById(videoId);
// Cek apakah browser mendukung getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Browser tidak mendukung akses kamera');
}
// Stop stream yang sedang berjalan
if (streams[videoId]) {
streams[videoId].getTracks().forEach(track => track.stop());
}
// Konfigurasi kamera
const constraints = {
video: {
width: { min: 320, ideal: 640, max: 1280 },
height: { min: 240, ideal: 480, max: 720 },
aspectRatio: { ideal: 4/3 }
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
streams[videoId] = stream;
// Tunggu video siap
video.onloadedmetadata = function() {
video.play();
};
video.onerror = function(e) {
console.error('Error pada video:', e);
alert('Error pada video stream');
};
} catch (err) {
// Pesan error yang lebih spesifik
let errorMessage = 'Tidak dapat mengakses kamera. ';
if (err.name === 'NotAllowedError') {
errorMessage += 'Izin kamera ditolak. Silakan:\n1. Klik ikon kamera di address bar\n2. Pilih "Allow"\n3. Refresh halaman';
} else if (err.name === 'NotFoundError') {
errorMessage += 'Kamera tidak ditemukan. Pastikan HP memiliki kamera.';
} else if (err.name === 'NotReadableError') {
errorMessage += 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi kamera lain.';
} else if (err.name === 'OverconstrainedError') {
errorMessage += 'Kamera tidak mendukung resolusi yang diminta.';
} else if (err.name === 'SecurityError') {
errorMessage += 'Akses kamera diblokir. Pastikan menggunakan HTTPS atau localhost.';
} else {
errorMessage += 'Error: ' + err.message;
}
alert(errorMessage);
}
}
// Capture photo and convert to file
function capturePhoto(videoId, canvasId, inputId, previewId) {
const video = document.getElementById(videoId);
const canvas = document.getElementById(canvasId);
const fileInput = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!video.srcObject) {
alert('Silakan buka kamera terlebih dahulu');
return;
}
// Pastikan video sudah siap
if (video.videoWidth === 0 || video.videoHeight === 0) {
alert('Video belum siap. Tunggu sebentar dan coba lagi.');
return;
}
try {
const context = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert canvas ke File object
canvas.toBlob(function(blob) {
// Buat File object
const file = new File([blob], `photo_${Date.now()}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now()
});
// Assign ke file input
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
// Preview
const url = URL.createObjectURL(blob);
preview.innerHTML = `
<img src="${url}" style="max-width: 200px; max-height: 150px; border-radius: 8px; border: 3px solid #059669; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div class="mt-2">
<small class="text-success"><i class="fas fa-check"></i> Foto berhasil diambil</small>
<br>
<small class="text-muted">Ukuran: ${(file.size / 1024).toFixed(1)} KB</small>
</div>
`;
}, 'image/jpeg', 0.8);
} catch (err) {
alert('Gagal mengambil foto: ' + err.message);
}
}
// Handle file upload from gallery
function handleFileUpload(input, inputId, previewId) {
const file = input.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Pilih file gambar');
return;
}
// Validasi ukuran file (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Ukuran file maksimal 2MB');
return;
}
// Assign ke file input yang sesuai
const targetInput = document.getElementById(inputId);
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
targetInput.files = dataTransfer.files;
// Preview
const url = URL.createObjectURL(file);
const preview = document.getElementById(previewId);
preview.innerHTML = `
<img src="${url}" style="max-width: 200px; max-height: 150px; border-radius: 8px; border: 3px solid #059669; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div class="mt-2">
<small class="text-success"><i class="fas fa-check"></i> Foto berhasil diupload</small>
<br>
<small class="text-muted">Ukuran: ${(file.size / 1024).toFixed(1)} KB</small>
</div>
`;
}
// Stop all cameras when page is unloaded
window.addEventListener('beforeunload', function() {
Object.values(streams).forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
});
// Form validation
document.getElementById('precheckForm').addEventListener('submit', function(e) {
const requiredFields = ['kilometer', 'front_image', 'pressure_high'];
let isValid = true;
requiredFields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field.type === 'file') {
// Validasi file input
if (!field.files || field.files.length === 0) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
} else {
// Validasi input biasa
if (!field.value.trim()) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
}
});
if (!isValid) {
e.preventDefault();
alert('Mohon lengkapi semua field yang wajib diisi');
}
});
</script>
@endsection

View File

@@ -327,7 +327,7 @@
<!-- Header -->
<div class="header">
<div class="company-info">
<div class="company-name">PT. CINTA KASIH BERSAMA</div>
<div class="company-name">PT. CIPTA KREASI BARU</div>
<div class="company-tagline">Warehouse Management System</div>
</div>
<div class="document-title">Dokumen Mutasi Stock</div>
@@ -528,7 +528,7 @@
<div class="footer">
<div class="print-info">
Dicetak pada: {{ now()->format('d F Y H:i:s') }} |
Sistem Manajemen Gudang PT. Cinta Kasih Bersama
Sistem Manajemen Gudang PT. Cipta Kreasi Baru
</div>
</div>
</div>

View File

@@ -69,6 +69,7 @@
<th>Dealer</th>
<th>Pengguna</th>
<th>Status</th>
<th>Informasi Stock</th>
<th>Aksi</th>
</tr>
</thead>
@@ -114,6 +115,44 @@
letter-spacing: normal;
display: block;
}
/* Stock info column styling */
.stock-info-cell {
min-width: 120px;
max-width: 150px;
}
.stock-info-cell .text-success {
color: #28a745 !important;
font-weight: 600;
}
.stock-info-cell .text-danger {
color: #dc3545 !important;
font-weight: 600;
}
.stock-info-cell .text-muted {
color: #6c757d !important;
font-size: 11px;
}
.stock-info-cell i {
margin-right: 4px;
}
/* Responsive adjustments for stock info column */
@media (max-width: 768px) {
.stock-info-cell {
min-width: 100px;
max-width: 120px;
font-size: 12px;
}
.stock-info-cell .text-muted {
font-size: 10px;
}
}
</style>
@endsection

View File

@@ -287,7 +287,7 @@
<!-- Header -->
<div class="header">
<div class="company-info">
<div class="company-name">PT. CINTA KASIH BERSAMA</div>
<div class="company-name">PT. CIPTA KREASI BARU</div>
<div class="company-tagline">Warehouse Management System</div>
</div>
<div class="document-title">Laporan Stock Opname</div>
@@ -420,7 +420,7 @@
<div class="footer">
<div class="print-info">
Dicetak pada: {{ now()->format('d F Y H:i:s') }} |
Sistem Manajemen Gudang PT. Cinta Kasih Bersama
Sistem Manajemen Gudang PT. Cipta Kreasi Baru
</div>
</div>
</div>

View File

@@ -85,7 +85,7 @@ table.dataTable thead th.sorting:hover:before {
<div class="kt-portlet__head-wrapper">
<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;">
<i class="flaticon2-download"></i>Export Stok Dealer
Export
</a>
@can('create', $menus['products.index'])
<a href="{{ route('products.create') }}" class="btn btn-bold btn-label-brand btn--sm">Tambah</a>

View File

@@ -29,7 +29,6 @@ input.datepicker {
<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">
Histori Stock
</h3>

View File

@@ -11,12 +11,18 @@ use App\Http\Controllers\WarehouseManagement\OpnamesController;
use App\Http\Controllers\WarehouseManagement\ProductCategoriesController;
use App\Http\Controllers\WarehouseManagement\ProductsController;
use App\Http\Controllers\WorkController;
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\Http\Controllers\Reports\ReportTechniciansController;
use App\Models\Menu;
use App\Models\Privilege;
use App\Models\Role;
use App\Models\User;
use App\Http\Controllers\Transactions\PrechecksController;
use App\Http\Controllers\Transactions\PostchecksController;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -165,8 +171,23 @@ Route::group(['middleware' => 'auth'], function() {
// Stock Management Routes
Route::post('/transaction/check-stock', [TransactionController::class, 'checkStockAvailability'])->name('transaction.check-stock');
Route::get('/transaction/stock-prediction', [TransactionController::class, 'getStockPrediction'])->name('transaction.stock-prediction');
// Claim Transactions Route
Route::get('/transaction/get-claim-transactions', [TransactionController::class, 'getClaimTransactions'])->name('transaction.get-claim-transactions');
Route::post('/transaction/claim/{id}', [TransactionController::class, 'claim'])->name('transaction.claim');
// Prechecks Routes
Route::get('/transaction/prechecks/{transaction}', [PrechecksController::class, 'index'])->name('prechecks.index');
Route::post('/transaction/prechecks/{transaction}', [PrechecksController::class, 'store'])->name('prechecks.store');
// Postchecks Routes
Route::get('/transaction/postchecks/{transaction}', [PostchecksController::class, 'index'])->name('postchecks.index');
Route::post('/transaction/postchecks/{transaction}', [PostchecksController::class, 'store'])->name('postchecks.store');
});
// KPI Data Route - accessible to all authenticated users
Route::get('/transaction/get-kpi-data', [TransactionController::class, 'getKpiData'])->name('transaction.get-kpi-data');
Route::group(['prefix' => 'admin', 'middleware' => 'adminRole'], function() {
Route::get('/dashboard2', [AdminController::class, 'dashboard2'])->name('dashboard2');
Route::post('/dealer_work_trx', [AdminController::class, 'dealer_work_trx'])->name('dealer_work_trx');
@@ -181,6 +202,23 @@ Route::group(['middleware' => 'auth'], function() {
Route::resource('category', CategoryController::class);
Route::resource('work', WorkController::class);
// Work Dealer Prices Routes
Route::prefix('work/{work}/prices')->name('work.prices.')->controller(WorkDealerPriceController::class)->group(function () {
Route::get('/', 'index')->name('index');
Route::post('/', 'store')->name('store');
Route::get('{price}/edit', 'edit')->name('edit');
Route::put('{price}', 'update')->name('update');
Route::delete('{price}', 'destroy')->name('destroy');
Route::post('bulk', 'bulkCreate')->name('bulk-create');
Route::post('toggle-status', 'toggleStatus')->name('toggle-status');
});
// Route untuk halaman set harga (menggunakan WorkController)
Route::get('work/{work}/set-prices', [WorkController::class, 'showPrices'])->name('work.set-prices');
// API route untuk mendapatkan harga
Route::get('work/get-price', [WorkDealerPriceController::class, 'getPrice'])->name('work.get-price');
// Work Products Management Routes
Route::prefix('work/{work}/products')->name('work.products.')->controller(App\Http\Controllers\WorkProductController::class)->group(function () {
Route::get('/', 'index')->name('index');
@@ -202,6 +240,8 @@ Route::group(['middleware' => 'auth'], function() {
Route::get('/roleprivileges/{id}/edit', [RolePrivilegeController::class, 'edit'])->name('roleprivileges.edit');
Route::put('/roleprivileges/{id}/update', [RolePrivilegeController::class, 'update'])->name('roleprivileges.update');
Route::delete('/roleprivileges/{id}/delete', [RolePrivilegeController::class, 'delete'])->name('roleprivileges.delete');
Route::post('/roleprivileges/{id}/assign-dealer', [RolePrivilegeController::class, 'assignDealer'])->name('roleprivileges.assignDealer');
Route::get('/roleprivileges/{id}/assigned-dealers', [RolePrivilegeController::class, 'getAssignedDealers'])->name('roleprivileges.getAssignedDealers');
Route::get('/report/transaction', [ReportController::class, 'transaction'])->name('report.transaction');
Route::post('/report/transaction/data', [ReportController::class, 'transaction_data'])->name('report.transaction_data');
@@ -282,6 +322,28 @@ Route::group(['middleware' => 'auth'], function() {
Route::get('{stockLog}/detail', 'getDetail')->name('detail');
});
});
// KPI Routes for Admins
Route::prefix('kpi')->middleware(['adminRole'])->group(function () {
// Target Management
Route::resource('targets', TargetsController::class, ['as' => 'kpi']);
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');
Route::get('technician', [ReportTechniciansController::class, 'index'])->name('reports.technician.index');
Route::get('technician/data', [ReportTechniciansController::class, 'getData'])->name('reports.technician.data');
Route::get('technician/datatable', [ReportTechniciansController::class, 'getDataTable'])->name('reports.technician.datatable');
Route::get('technician/dealers', [ReportTechniciansController::class, 'getDealers'])->name('reports.technician.dealers');
Route::get('technician/export', [ReportTechniciansController::class, 'export'])->name('reports.technician.export');
});
});
Auth::routes();