@extends('layouts.app') @section('content') @php $normalizeUnitLabel = function ($unit) { $unit = strtolower(trim((string) $unit)); return match ($unit) { 'm2', 'm²' => 'm²', 'meter', 'm' => 'meter', 'pcs', 'pc', 'piece', 'lembar' => 'lembar', default => $unit !== '' ? $unit : 'lembar', }; }; $normalizeUnitKey = function ($unit) { $unit = strtolower(trim((string) $unit)); return match ($unit) { 'm2', 'm²' => 'm2', 'meter', 'm' => 'meter', 'pcs', 'pc', 'piece', 'lembar' => 'lembar', default => $unit !== '' ? $unit : 'lembar', }; }; $formatNumberClean = function ($value, $decimals = 2) { $formatted = number_format((float) $value, $decimals, ',', '.'); $formatted = rtrim($formatted, '0'); $formatted = rtrim($formatted, ','); return $formatted === '' ? '0' : $formatted; }; $parseTwoDimensions = function ($text) { $clean = strtolower(trim((string) $text)); if ($clean === '') { return null; } $clean = preg_replace('/\s+/', '', $clean); $clean = str_replace(['×', '*'], 'x', $clean); if (!preg_match('/^(\d+(?:[.,]\d+)?)x(\d+(?:[.,]\d+)?)(cm|m)?$/', $clean, $matches)) { return null; } $a = (float) str_replace(',', '.', $matches[1]); $b = (float) str_replace(',', '.', $matches[2]); $suffix = $matches[3] ?? ''; if ($suffix === 'cm') { return [$a, $b, 'cm']; } if ($suffix === 'm') { return [$a, $b, 'm']; } $unit = ($a >= 10 && $b >= 10) ? 'cm' : 'm'; return [$a, $b, $unit]; }; $resolveAreaValue = function ($sizeValue, $sizeDescription, $basePrice, $qty, $storedSubtotal) use ($parseTwoDimensions) { $sizeValue = (float) $sizeValue; $basePrice = (float) $basePrice; $qty = max((float) $qty, 0); $storedSubtotal = (float) $storedSubtotal; if ($sizeValue > 0) { return round($sizeValue, 4); } $dims = $parseTwoDimensions($sizeDescription); if ($dims) { [$a, $b, $parsedUnit] = $dims; if ($parsedUnit === 'cm') { return round(($a * $b) / 10000, 4); } return round($a * $b, 4); } if ($storedSubtotal > 0 && $basePrice > 0 && $qty > 0) { return round($storedSubtotal / ($basePrice * $qty), 4); } return 1; }; $resolveMeterValue = function ($sizeValue, $sizeDescription, $material, $basePrice, $qty, $storedSubtotal) use ($parseTwoDimensions) { $sizeValue = (float) $sizeValue; $basePrice = (float) $basePrice; $qty = max((float) $qty, 0); $storedSubtotal = (float) $storedSubtotal; if ($sizeValue > 0) { return round($sizeValue, 4); } $dims = $parseTwoDimensions($sizeDescription); if ($dims) { [$a, $b, $parsedUnit] = $dims; $aCm = $parsedUnit === 'cm' ? $a : ($a * 100); $bCm = $parsedUnit === 'cm' ? $b : ($b * 100); $rollWidthCm = (float) ($material->roll_width_cm ?? 0); $allowRotation = (bool) ($material->allow_rotation ?? true); if ($rollWidthCm > 0) { $candidates = []; if ($aCm <= $rollWidthCm) { $candidates[] = $bCm; } if ($allowRotation && $bCm <= $rollWidthCm) { $candidates[] = $aCm; } if (!empty($candidates)) { return round(min($candidates) / 100, 4); } return round(max($aCm, $bCm) / 100, 4); } return round(min($aCm, $bCm) / 100, 4); } if ($storedSubtotal > 0 && $basePrice > 0 && $qty > 0) { return round($storedSubtotal / ($basePrice * $qty), 4); } return 1; }; $resolveSizeValue = function ($sizeValue, $sizeDescription, $unit, $material = null, $basePrice = 0, $qty = 1, $storedSubtotal = 0) use ( $normalizeUnitKey, $resolveAreaValue, $resolveMeterValue ) { $unitKey = $normalizeUnitKey($unit); if ($unitKey === 'm2') { return $resolveAreaValue($sizeValue, $sizeDescription, $basePrice, $qty, $storedSubtotal); } if ($unitKey === 'meter') { return $resolveMeterValue($sizeValue, $sizeDescription, $material, $basePrice, $qty, $storedSubtotal); } return 1; }; $applyRoundingOnly = function ($amount) { $amount = (int) ceil(max((float) $amount, 0)); if ($amount <= 0) { return 0; } $remainder = $amount % 1000; if ($remainder === 0) { return (float) $amount; } $base = $amount - $remainder; return (float) ($base + ($remainder <= 500 ? 500 : 1000)); }; $applyMeterMinimumAndRounding = function ($amount) { $amount = max((float) $amount, 0); $minimumOrder = 10000; if ($amount <= 0) { return 0; } if ($amount < $minimumOrder) { $amount = $minimumOrder; } $amount = (int) ceil($amount); $remainder = $amount % 1000; if ($remainder === 0) { return (float) $amount; } $base = $amount - $remainder; return (float) ($base + ($remainder <= 500 ? 500 : 1000)); }; $calculateDisplaySubtotal = function ($basePrice, $qty, $unit, $resolvedSizeValue, $storedSubtotal = 0) use ($normalizeUnitKey, $applyRoundingOnly, $applyMeterMinimumAndRounding) { $unitKey = $normalizeUnitKey($unit); $basePrice = max((float) $basePrice, 0); $qty = max((float) $qty, 0); $resolvedSizeValue = max((float) $resolvedSizeValue, 0); $storedSubtotal = max((float) $storedSubtotal, 0); // Wajib utamakan subtotal final yang tersimpan. // Setelah ada minimal order + pembulatan, angka tampilan juga dipaksa rapi: // minimal Rp 10.000 dan pembulatan ke atas Rp 1.000. if ($storedSubtotal > 0) { return in_array($unitKey, ['m2', 'meter'], true) ? round($applyMeterMinimumAndRounding($storedSubtotal), 2) : round($applyRoundingOnly($storedSubtotal), 2); } if (in_array($unitKey, ['m2', 'meter'], true)) { return round($applyMeterMinimumAndRounding($basePrice * $resolvedSizeValue * $qty), 2); } return round($applyRoundingOnly($basePrice * $qty), 2); }; $rawItems = method_exists($order, 'items') ? ($order->relationLoaded('items') ? $order->items : $order->items()->with('material')->get()) : collect(); $displayItems = $rawItems->isNotEmpty() ? $rawItems->map(function ($item) use ($normalizeUnitLabel, $normalizeUnitKey, $resolveSizeValue, $calculateDisplaySubtotal, $order) { $sizeUnit = $item->size_unit ?? 'lembar'; $sizeUnitKey = $normalizeUnitKey($sizeUnit); $basePrice = (float) ($item->base_price ?? 0); $manualPrice = (float) ($item->manual_price ?? 0); $hasManualPrice = $manualPrice > 0; $qty = (float) ($item->qty ?? 1); $storedSubtotal = (float) ($item->subtotal ?? $item->system_subtotal ?? 0); $sisi = max((int) ($item->sisi ?? 1), 1); // Sinkron dengan kasir/order: // - Kalau subtotal final sudah tersimpan dari proses order/kasir, pakai angka itu. // - Kalau data lama hanya menyimpan harga manual satuan di subtotal, hitung ulang: qty x manual x sisi. // - Jangan jadikan harga manual sebagai grand total langsung. $manualCalculatedSubtotal = $hasManualPrice ? ($manualPrice * max($qty, 0) * $sisi) : 0; if ($hasManualPrice && $manualCalculatedSubtotal > 0 && ($storedSubtotal <= 0 || abs($storedSubtotal - $manualPrice) < 0.01)) { $storedSubtotal = $manualCalculatedSubtotal; } $resolvedSizeValue = $resolveSizeValue( $item->size_value ?? 0, $item->size_description ?? '', $sizeUnit, $item->material ?? null, $basePrice, $qty, $storedSubtotal ); $displaySubtotal = $calculateDisplaySubtotal( $hasManualPrice ? $manualPrice : $basePrice, $qty, $sizeUnit, $resolvedSizeValue, $storedSubtotal ); return [ 'model' => $item, 'id' => $item->id ?? null, 'product_name' => $item->product_name ?? '-', 'material_name' => $item->material_name ?: ($item->material->name ?? null), 'material_category_name' => $item->material->category->name ?? null, 'file_name' => $item->file_name ?? '-', 'size_description' => $item->size_description ?? '-', 'size_value' => (float) ($item->size_value ?? 0), 'resolved_size_value' => $resolvedSizeValue, 'size_unit' => $sizeUnitKey, 'size_unit_label' => $normalizeUnitLabel($sizeUnit), 'qty' => $qty, 'sisi' => $sisi, 'base_price' => $basePrice, 'manual_price' => $manualPrice, 'is_manual_price' => $hasManualPrice, 'stored_subtotal' => $storedSubtotal, 'display_subtotal' => $displaySubtotal, 'finishing_note' => $item->finishing_note ?? null, 'file_url' => $item->file_url ?? $item->file_path_url ?? null, 'preview_image_path' => $item->preview_image_path ?? null, 'preview_image_url' => !empty($item->preview_image_path) ? route('orders.items.preview', [$order, $item]) : null, 'is_cancelled' => method_exists($item, 'isCancelled') ? $item->isCancelled() : (bool) ($item->is_cancelled ?? false), 'has_pending_cancellation' => method_exists($item, 'hasPendingCancellationRequest') ? $item->hasPendingCancellationRequest() : false, 'cancel_reason' => $item->cancel_reason ?? null, 'can_cancel' => method_exists($item, 'canBeCancelled') && auth()->check() && method_exists(auth()->user(), 'canRequestCancellations') ? ($item->canBeCancelled() && !$item->hasPendingCancellationRequest() && auth()->user()->canRequestCancellations()) : false, ]; })->values() : collect([ (function () use ( $order, $normalizeUnitLabel, $normalizeUnitKey, $resolveSizeValue, $calculateDisplaySubtotal ) { $sizeUnit = $order->size_unit ?? 'lembar'; $sizeUnitKey = $normalizeUnitKey($sizeUnit); $basePrice = (float) ($order->base_price ?? 0); $qty = (float) ($order->qty ?? 1); $storedSubtotal = (float) ($order->total_price ?? 0); $resolvedSizeValue = $resolveSizeValue( $order->size_value ?? 0, $order->size ?? '', $sizeUnit, null, $basePrice, $qty, $storedSubtotal ); $displaySubtotal = $calculateDisplaySubtotal( $basePrice, $qty, $sizeUnit, $resolvedSizeValue, $storedSubtotal ); return [ 'model' => null, 'id' => null, 'product_name' => $order->product_name ?? '-', 'material_name' => $order->material_name ?? null, 'file_name' => $order->file_name ?? '-', 'size_description' => $order->size ?? '-', 'size_value' => (float) ($order->size_value ?? 0), 'resolved_size_value' => $resolvedSizeValue, 'size_unit' => $sizeUnitKey, 'size_unit_label' => $normalizeUnitLabel($sizeUnit), 'qty' => $qty, 'sisi' => (int) ($order->sisi ?? 1), 'base_price' => $basePrice, 'manual_price' => 0, 'is_manual_price' => false, 'stored_subtotal' => $storedSubtotal, 'display_subtotal' => $displaySubtotal, 'finishing_note' => $order->finishing_note ?? null, 'file_url' => null, 'preview_image_path' => null, 'preview_image_url' => null, 'is_cancelled' => false, 'has_pending_cancellation' => false, 'cancel_reason' => null, 'can_cancel' => false, ]; })(), ]); $activeItems = $displayItems->where('is_cancelled', false)->values(); $activeItemsCount = $activeItems->count(); $cancelledItemsCount = $displayItems->where('is_cancelled', true)->count(); $displayGrandTotal = (float) $activeItems->sum(fn ($item) => (float) $item['display_subtotal']); // Sinkron tampilan pembayaran dengan total final yang sudah kena minimal order + pembulatan. // Jangan lagi pakai change_amount lama apa adanya, karena data lama bisa masih menyimpan // kembalian dari total sebelum pembulatan. $displayPaidAmount = (float) ($order->paid_amount ?? 0); if ($displayPaidAmount <= 0 && isset($order->payments)) { $displayPaidAmount = (float) $order->payments->sum('amount'); } // Detail order harus mengikuti nilai final kasir. // Jika order lama sudah telanjur dibayar lebih besar daripada hitungan ulang item, // tampilkan grand total mengikuti pembayaran agar tidak muncul kembalian palsu. if ($displayPaidAmount > $displayGrandTotal) { $displayGrandTotal = $displayPaidAmount; } $displayChangeAmount = 0; $currentUser = auth()->user(); $invoiceEditorRole = strtolower((string) ($currentUser->role ?? '')); $canEditInvoiceItems = auth()->check() && ( $currentUser->can('process payments') || $currentUser->can('edit orders') || $currentUser->can('create orders') || $currentUser->can('view orders') || (method_exists($currentUser, 'isDeveloper') && $currentUser->isDeveloper()) || in_array($invoiceEditorRole, ['developer', 'owner', 'admin', 'administrator', 'administrasi', 'kasir', 'designer'], true) ); $userRoleName = strtolower((string) ( $currentUser->role_name ?? optional($currentUser->role)->name ?? optional($currentUser->roles->first() ?? null)->name ?? $currentUser->role ?? '' )); $isAdminKasirUser = auth()->check() && ( in_array($invoiceEditorRole, ['admin', 'administrator', 'kasir', 'cashier'], true) || in_array($userRoleName, ['admin', 'administrator', 'kasir', 'cashier'], true) || (method_exists($currentUser, 'hasRole') && ($currentUser->hasRole('admin') || $currentUser->hasRole('administrator') || $currentUser->hasRole('kasir') || $currentUser->hasRole('cashier'))) || (method_exists($currentUser, 'can') && $currentUser->can('process payments')) || (method_exists($currentUser, 'isKasir') && $currentUser->isKasir()) || (method_exists($currentUser, 'isAdmin') && $currentUser->isAdmin()) || (method_exists($currentUser, 'isDeveloper') && $currentUser->isDeveloper()) ); // Admin/kasir boleh cetak nota/invoice untuk semua status pembayaran: // belum bayar, DP/partial, maupun lunas. Ini hanya membuka akses cetak, // tidak mengubah pembayaran, status order, atau transaksi. $canPrintAdminKasirNota = $isAdminKasirUser; $printNotaRouteExists = \Illuminate\Support\Facades\Route::has('print.nota'); $invoicePrintRouteExists = \Illuminate\Support\Facades\Route::has('orders.invoice-print'); @endphp @if(session('success'))
{{ session('success') }}
@endif @if(session('error'))
{{ session('error') }}
@endif @if($order->isCancelled())

Order Dibatalkan

{{ $order->cancel_reason }}

Dibatalkan oleh {{ $order->canceller->name ?? '-' }} pada {{ $order->cancelled_at?->format('d M Y H:i') }}

@endif @if(!$order->isCancelled() && $order->hasPendingCancellationRequest())

Menunggu approval pembatalan order dari supervisor

@endif

{{ $order->invoice_number }}

@if($order->isCancelled()) Dibatalkan @endif @if($order->is_credit) Piutang @endif {{ $order->getPaymentStatusLabel() }} @if(!$order->isCancelled()) {{ $order->getProductionStatusLabel() }} @endif {{ $order->getPickupStatusLabel() }}

Data Pelanggan

Nama
{{ $order->customer_name }}
@if($order->customer_phone)
Telepon
{{ $order->customer_phone }}
@endif

Detail Order

Tanggal
{{ $order->created_at->format('d M Y H:i') }}
Designer
{{ $order->designer->name ?? '-' }}
Jumlah Item
{{ $displayItems->count() }} item
@if($order->notes)
Catatan Order
{{ $order->notes }}
@endif

Item Order

@foreach($displayItems as $index => $displayItem) @php $itemModel = $displayItem['model']; $isCancelled = $displayItem['is_cancelled']; $hasPendingCancellation = $displayItem['has_pending_cancellation']; $canCancelItem = $displayItem['can_cancel'] && $itemModel; $qtyDisplay = $formatNumberClean($displayItem['qty']); $sizeValueDisplay = $displayItem['size_unit'] === 'lembar' ? '1' : $formatNumberClean($displayItem['resolved_size_value'], 4); $displaySubtotal = (float) $displayItem['display_subtotal']; @endphp
@if($isCancelled)
Dibatalkan
@endif @if(!$isCancelled && $hasPendingCancellation)
Menunggu Approval
@endif
{{ $index + 1 }}
{{ $displayItem['product_name'] }}
@if($displayItem['material_name'])

{{ $displayItem['material_name'] }} @if(!empty($displayItem['material_category_name'])) • {{ $displayItem['material_category_name'] }} @endif

@endif
Rp {{ number_format($displaySubtotal, 0, ',', '.') }} @if($canCancelItem) @endif
Nama File
{{ $displayItem['file_name'] }}
Ukuran
{{ $displayItem['size_description'] }}
Sisi
@if($displayItem['size_unit'] === 'm2') - @elseif(($displayItem['sisi'] ?? 1) == 2) 2 sisi @else 1 sisi @endif
{{ !empty($displayItem['is_manual_price']) ? 'Harga Manual' : 'Harga/' . $displayItem['size_unit_label'] }}
Rp {{ number_format((float) (!empty($displayItem['is_manual_price']) ? $displayItem['manual_price'] : $displayItem['base_price']), 0, ',', '.') }}
Qty
{{ $qtyDisplay }}
@if($displayItem['finishing_note'])
Finishing: {{ $displayItem['finishing_note'] }}
@endif @if($displayItem['file_url']) @endif @if(!empty($displayItem['preview_image_url']))

Preview Upload

Klik gambar untuk lihat ukuran besar.

@endif @if($isCancelled && $displayItem['cancel_reason'])
Alasan: {{ $displayItem['cancel_reason'] }}
@endif
@if(!empty($displayItem['is_manual_price'])) Harga Manual @else Rp {{ number_format((float) $displayItem['base_price'], 0, ',', '.') }} × {{ $sizeValueDisplay }} {{ $displayItem['size_unit_label'] }} × {{ $qtyDisplay }} @endif = Rp {{ number_format($displaySubtotal, 0, ',', '.') }}
@endforeach
@if($canEditInvoiceItems && $activeItems->whereNotNull('model')->count() > 0)

Edit Item Invoice / Penagihan

Khusus merapikan nama barang agar sesuai PO. Qty, harga, subtotal, stok, dan status produksi tidak berubah.

@csrf @method('PATCH') @foreach($activeItems as $editIndex => $editItem) @if($editItem['model'])
Item {{ $editIndex + 1 }} Untuk tampilan invoice/nota
@endif @endforeach
@endif
Grand Total ({{ $activeItemsCount }} item aktif) Rp {{ number_format($displayGrandTotal, 0, ',', '.') }}
@if($cancelledItemsCount > 0)

{{ $cancelledItemsCount }} item dibatalkan

@endif @if($order->isPaid())
Dibayar Rp {{ number_format($displayPaidAmount, 0, ',', '.') }}
{{-- Kembalian disembunyikan di detail order agar tidak muncul dari koreksi total lama --}}
@endif

Aksi

@if(!$order->isCancelled()) @php $waSendUrl = url('/orders/' . $order->getKey() . '/wa/send'); $customerWaNumber = $order->customer_phone ?? optional($order->customer)->phone ?? optional($order->customer)->whatsapp ?? null; @endphp

Dokumen Order

Cetak dokumen administrasi dan kirimkan dokumen penagihan customer sesuai kebutuhan operasional.

@if($customerWaNumber) Kirim Invoice + SPK ke WA @else
Nomor WhatsApp customer belum tersedia
@endif @if($order->canPrintSPK() && auth()->user()->canPrintSPK()) Cetak SPK @endif @if($canPrintAdminKasirNota) @endif @if($canEditInvoiceItems && $activeItems->whereNotNull('model')->count() > 0) Edit Item Invoice / Penagihan @endif @if(auth()->user()->can('edit orders')) Edit Order @endif @if($order->canMarkAsPickedUp())
@csrf
@endif @if($order->canBeCancelled() && !$order->hasPendingCancellationRequest() && auth()->user()->canRequestCancellations()) @endif @endif Kembali

Riwayat

Order Dibuat

{{ $order->created_at->format('d M Y H:i') }}

oleh {{ $order->designer->name ?? '-' }}

@if($order->isPaid())

Pembayaran

{{ $order->paid_at?->format('d M Y H:i') }}

oleh {{ $order->cashier->name ?? '-' }}

@endif @if($order->spk_printed)

SPK Dicetak

{{ $order->spk_printed_at?->format('d M Y H:i') }}

@endif @if($order->started_at)

Produksi Dimulai

{{ $order->started_at->format('d M Y H:i') }}

oleh {{ $order->operator->name ?? '-' }}

@endif @if($order->finished_at)

Selesai

{{ $order->finished_at->format('d M Y H:i') }}

@endif @if($order->picked_up_at)

Diambil

{{ $order->picked_up_at->format('d M Y H:i') }}

@endif @if($order->isCancelled())

Dibatalkan

{{ $order->cancelled_at?->format('d M Y H:i') }}

oleh {{ $order->canceller->name ?? '-' }}

@if($order->cancel_reason)

{{ $order->cancel_reason }}

@endif
@endif
{{-- Modal Preview Gambar --}} @if($canPrintAdminKasirNota) @endif @endsection {{-- Preview gambar tersedia via $item->preview_image_path --}}