partial update stock opname feature

This commit is contained in:
root
2025-06-11 18:29:32 +07:00
parent 9b25a772a6
commit 647aa51187
4 changed files with 926 additions and 75 deletions

View File

@@ -1,5 +1,10 @@
@extends('layouts.frontapp')
@php
use App\Models\Dealer;
use Illuminate\Support\Facades\Auth;
@endphp
{{-- @section('contentHead')
<div class="kt-subheader kt-grid__item" id="kt_subheader">
<div class="kt-container kt-container--fluid ">
@@ -19,6 +24,79 @@
cursor: not-allowed !important;
pointer-events: none;
}
.table-responsive {
max-height: 400px;
overflow-y: auto;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.text-success {
color: #28a745 !important;
}
.text-danger {
color: #dc3545 !important;
}
.text-bold {
font-weight: bold;
}
.system-stock {
font-weight: 600;
color: #007bff;
}
.table-bordered th,
.table-bordered td {
border: 1px solid #dee2e6;
vertical-align: middle;
padding: 12px 8px;
}
.table thead th {
background-color: #f8f9fa;
color: #495057;
font-weight: 600;
font-size: 14px;
border-bottom: 2px solid #dee2e6;
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0,0,0,.02);
}
.physical-stock {
font-weight: 500;
border: 2px solid #e9ecef;
transition: border-color 0.15s ease-in-out;
}
.physical-stock:focus {
border-color: #ffc107;
box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.25);
}
.difference {
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
background-color: #f8f9fa;
display: inline-block;
min-width: 60px;
}
.btn-lg {
padding: 12px 20px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transition: all 0.3s ease;
}
.btn-lg:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.physical-stock.is-invalid {
border-color: #dc3545;
background-color: #fff5f5;
}
.physical-stock.is-invalid:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
</style>
@endsection
@@ -92,22 +170,52 @@
</div>
</div>
@endif
@if (session('error'))
<div class="row mt-2 mb-2">
<div class="col-12">
<div class="alert alert-danger fade show" role="alert">
<div class="alert-text">{{ session('error') }}</div>
<div class="alert-close">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true"><i class="la la-close"></i></span>
</button>
</div>
</div>
</div>
</div>
@endif
<div class="row">
<div class="col-12">
<!--begin::Portlet-->
<div class="kt-portlet">
<div class="kt-portlet__body">
<ul class="nav nav-tabs nav-tabs-line nav-tabs-line-primary" role="tablist">
<!-- Main Tabs -->
<ul class="nav nav-tabs nav-tabs-line nav-tabs-line-primary" role="tablist">
<li class="nav-item">
<a class="nav-link @if(old('form') == 'wash') @else active @endif" data-toggle="tab" href="#work">Form Kerja</a>
<a class="nav-link active" data-toggle="tab" href="#transaksi">Transaksi</a>
</li>
<li class="nav-item">
<a class="nav-link @if(old('form') == 'wash') active @endif" data-toggle="tab" href="#wash">Form Cuci</a>
<a class="nav-link" data-toggle="tab" href="#stock">Stock</a>
</li>
</ul>
</ul>
<div class="tab-content">
<div class="tab-pane @if(old('form') == 'wash') @else active @endif" id="work" role="tabpanel">
<!-- Tab Transaksi -->
<div class="tab-pane active" id="transaksi" role="tabpanel">
<!-- Sub Tabs untuk Transaksi -->
<ul class="nav nav-tabs nav-tabs-line nav-tabs-line-success mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link @if(old('form') == 'wash') @else active @endif" data-toggle="tab" href="#form-kerja">Form Kerja</a>
</li>
<li class="nav-item">
<a class="nav-link @if(old('form') == 'wash') active @endif" data-toggle="tab" href="#form-cuci">Form Cuci</a>
</li>
</ul>
<div class="tab-content mt-3">
<!-- Form Kerja -->
<div class="tab-pane @if(old('form') == 'wash') @else active @endif" id="form-kerja" role="tabpanel">
<form action="{{ route('transaction.store') }}" method="POST" id="workForm">
@csrf
<input type="hidden" name="form" value="work">
@@ -301,10 +409,12 @@
</div>
</div>
</div>
<button class="btn btn-brand button-save" style="display: block; width: 100%;">Simpan</button>
</form>
</div>
<div class="tab-pane @if(old('form') == 'wash') active @endif" id="wash" role="tabpanel">
<button class="btn btn-brand button-save" style="display: block; width: 100%;">Simpan</button>
</form>
</div>
<!-- Form Cuci -->
<div class="tab-pane @if(old('form') == 'wash') active @endif" id="form-cuci" role="tabpanel">
<form action="{{ route('transaction.store') }}" method="POST" id="washForm">
@csrf
<input type="hidden" name="form" value="wash">
@@ -378,8 +488,180 @@
<input type="hidden" class="form-control" name="work_id[]" value="{{ $wash_work->id }}">
<input type="hidden" class="form-control" name="quantity[]" value="1">
</div>
<button class="btn btn-brand button-save" style="display: block; width: 100%;">Simpan</button>
</form>
<button class="btn btn-brand button-save" style="display: block; width: 100%;">Simpan</button>
</form>
</div>
</div>
</div>
<!-- Tab Stock -->
<div class="tab-pane" id="stock" role="tabpanel">
<!-- Sub Tabs untuk Stock -->
<ul class="nav nav-tabs nav-tabs-line nav-tabs-line-warning mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#opname">Opname</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#mutasi">Mutasi</a>
</li>
</ul>
<div class="tab-content mt-3">
<!-- Form Opname -->
<div class="tab-pane active" id="opname" role="tabpanel">
<form action="{{ route('opnames.store') }}" method="POST" id="opnameForm">
@csrf
<input type="hidden" name="form" value="opname">
<input type="hidden" name="user_id" value="{{ $mechanic->id }}">
<input type="hidden" name="dealer_id" value="{{ $mechanic->dealer_id }}">
<div class="form-group">
<label>Tanggal Opname <small class="text-muted">(Default: Hari ini)</small></label>
<input type="text" name="opname_date" id="date-opname" class="form-control @error('opname_date') is-invalid @enderror" value="{{ old('opname_date', date('Y-m-d')) }}" placeholder="YYYY-MM-DD">
@error('opname_date')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="form-group">
<label>Keterangan</label>
<textarea name="description" class="form-control @error('description') is-invalid @enderror" rows="3" placeholder="Keterangan opname">{{ old('description') }}</textarea>
@error('description')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<!-- List Produk dengan Stock -->
<div class="table-responsive">
<table class="table table-bordered table-striped">
<thead class="thead-light">
<tr>
<th class="text-center">Produk</th>
<th class="text-center">Dealer</th>
<th class="text-center">Stock Sistem</th>
<th class="text-center">Stock Fisik</th>
<th class="text-center">Selisih</th>
</tr>
</thead>
<tbody>
{{-- Dealer/Mechanic - Show products for current dealer only --}}
@foreach($products as $product)
@php
$stock = $product->stocks->first();
$currentStock = $stock ? $stock->quantity : 0;
@endphp
<tr>
<td class="text-center">{{ $product->name }}</td>
<td class="text-center">{{ $mechanic->dealer_name }}</td>
<td class="text-center">
<span class="system-stock">{{ number_format($currentStock, 2) }}</span>
<input type="hidden" name="product_id[]" value="{{ $product->id }}">
<input type="hidden" name="dealer_id_stock[]" value="{{ $mechanic->dealer_id }}">
<input type="hidden" name="system_stock[]" value="{{ $currentStock }}">
</td>
<td>
<input type="number" class="form-control physical-stock @error('physical_stock.'.$loop->index) is-invalid @enderror" name="physical_stock[]" step="0.01" placeholder="0.00" data-system="{{ $currentStock }}" value="{{ old('physical_stock.'.$loop->index) }}">
@error('physical_stock.'.$loop->index)
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</td>
<td class="text-center">
<span class="difference text-bold">0.00</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4 mb-3">
<button class="btn btn-warning btn-lg button-save d-block w-100 mt-2">
Simpan Opname
</button>
</div>
</form>
</div>
<!-- Form Mutasi -->
<div class="tab-pane" id="mutasi" role="tabpanel">
<form action="#" method="POST" id="mutasiForm">
@csrf
<input type="hidden" name="form" value="mutasi">
<input type="hidden" name="mechanic_id" value="{{ $mechanic->id }}">
<input type="hidden" name="dealer_id" value="{{ $mechanic->dealer_id }}">
<div class="form-group row">
<div class="col-6">
<label>Tanggal Mutasi</label>
<input type="text" name="mutasi_date" id="date-mutasi" class="form-control" placeholder="Tanggal Mutasi" required>
</div>
<div class="col-6">
<label>Jenis Mutasi</label>
<select name="mutasi_type" class="form-control" required>
<option value="">Pilih Jenis</option>
<option value="masuk">Barang Masuk</option>
<option value="keluar">Barang Keluar</option>
<option value="transfer">Transfer</option>
</select>
</div>
</div>
<div class="form-group">
<label>Keterangan</label>
<textarea name="description" class="form-control" rows="3" placeholder="Keterangan mutasi"></textarea>
</div>
<!-- List Produk untuk Mutasi -->
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th width="5%">
<input type="checkbox" id="select-all-mutasi">
</th>
<th>Produk</th>
<th>Stock Saat Ini</th>
<th>Jumlah Mutasi</th>
</tr>
</thead>
<tbody>
{{-- Dealer/Mechanic - Show products for current dealer only --}}
@foreach($products as $product)
@php
$stock = $product->stocks->first();
$currentStock = $stock ? $stock->quantity : 0;
@endphp
<tr>
<td>
<input type="checkbox" class="mutasi-product-checkbox" name="selected_products[]" value="{{ $product->id }}_{{ $mechanic->dealer_id }}">
</td>
<td>{{ $product->name }}</td>
<td class="text-right">{{ number_format($currentStock, 2) }}</td>
<td>
<input type="number" class="form-control mutasi-quantity" name="mutasi_quantity[{{ $product->id }}_{{ $mechanic->dealer_id }}]" step="0.01" placeholder="0.00" disabled>
<input type="hidden" name="product_id_mutasi[]" value="{{ $product->id }}">
<input type="hidden" name="dealer_id_mutasi[]" value="{{ $mechanic->dealer_id }}">
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4 mb-3">
<button class="btn btn-warning btn-lg button-save d-block w-100 mt-2">
Simpan Mutasi
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -498,8 +780,307 @@
return true;
})
$("#date-work").datepicker()
$("#date-wash").datepicker()
$("#opnameForm").submit(function(e) {
e.preventDefault();
// Validate form
var hasValidStock = false;
var invalidRows = [];
$('.physical-stock').each(function(index) {
var value = $(this).val();
var row = $(this).closest('tr');
var productName = row.find('td:first').text().trim();
// Check if value is valid (including 0)
if (value !== '' && value !== null && value !== undefined) {
var numValue = parseFloat(value);
if (!isNaN(numValue) && numValue >= 0) {
hasValidStock = true;
// Ensure the value is properly formatted
$(this).val(numValue.toFixed(2));
} else {
invalidRows.push(productName + ' (nilai tidak valid)');
}
}
// Don't remove elements here - let them stay for re-editing
});
// Show error if no valid stock entries
if (!hasValidStock) {
resetSubmitButton();
highlightInvalidFields();
Swal.fire({
icon: 'warning',
title: 'Peringatan',
text: 'Minimal harus ada satu produk dengan stock fisik yang diisi dengan benar!'
});
return false;
}
// Show error if there are invalid entries
if (invalidRows.length > 0) {
resetSubmitButton();
highlightInvalidFields();
Swal.fire({
icon: 'warning',
title: 'Data Tidak Valid',
text: 'Perbaiki data berikut: ' + invalidRows.join(', ')
});
return false;
}
// Get opname date or use today as default
var opnameDate = $('#date-opname').val();
if (!opnameDate) {
// Set default to today if empty
var today = new Date().toISOString().split('T')[0];
$('#date-opname').val(today);
opnameDate = today;
}
// Validate date format (YYYY-MM-DD)
var datePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
if (!datePattern.test(opnameDate)) {
resetSubmitButton();
Swal.fire({
icon: 'warning',
title: 'Format Tanggal Salah',
text: 'Format tanggal harus YYYY-MM-DD (contoh: 2023-12-25)'
});
return false;
}
// Validate if date is valid
var dateParts = opnameDate.match(datePattern);
var year = parseInt(dateParts[1], 10);
var month = parseInt(dateParts[2], 10);
var day = parseInt(dateParts[3], 10);
var testDate = new Date(year, month - 1, day);
if (testDate.getDate() !== day || testDate.getMonth() !== (month - 1) || testDate.getFullYear() !== year) {
resetSubmitButton();
Swal.fire({
icon: 'warning',
title: 'Tanggal Tidak Valid',
text: 'Tanggal yang dimasukkan tidak valid!'
});
return false;
}
// Check if date is not in the future
var today = new Date();
today.setHours(23, 59, 59, 999); // Set to end of today
if (testDate > today) {
resetSubmitButton();
Swal.fire({
icon: 'warning',
title: 'Tanggal Tidak Valid',
text: 'Tanggal opname tidak boleh lebih dari hari ini!'
});
return false;
}
$(".button-save").attr("disabled", true);
$(".button-save").addClass("disabled");
$(".button-save").html('<i class="fa fa-spinner fa-spin"></i> Menyimpan...');
// Date format is already YYYY-MM-DD, no conversion needed
// Clean up empty rows before submit (remove hidden inputs for truly empty fields only)
$('.physical-stock').each(function() {
var value = $(this).val();
var row = $(this).closest('tr');
// Only remove hidden inputs if physical stock is truly empty (not 0)
// Keep 0 values as they are valid input
if (value === '' || value === null || value === undefined) {
row.find('input[name="product_id[]"]').remove();
row.find('input[name="dealer_id_stock[]"]').remove();
row.find('input[name="system_stock[]"]').remove();
// But keep the physical_stock input so it can be re-edited if form fails
}
});
// Submit form
this.submit();
})
$("#mutasiForm").submit(function(e) {
$(".button-save").attr("disabled");
$(".button-save").addClass("disabled");
return true;
})
$("#date-work").datepicker({
format: 'yyyy-mm-dd',
autoclose: true,
todayHighlight: true
})
$("#date-wash").datepicker({
format: 'yyyy-mm-dd',
autoclose: true,
todayHighlight: true
})
$("#date-opname").datepicker({
format: 'yyyy-mm-dd',
autoclose: true,
todayHighlight: true,
startDate: '-30d',
endDate: '+0d'
})
$("#date-mutasi").datepicker({
format: 'yyyy-mm-dd',
autoclose: true,
todayHighlight: true
})
// Calculate difference for opname
$(document).on('input change keyup', '.physical-stock', function() {
var systemStock = parseFloat($(this).data('system')) || 0;
var inputValue = $(this).val();
// Handle empty input - set to 0 for calculation
var physicalStock = 0;
if (inputValue !== '' && inputValue !== null && inputValue !== undefined) {
physicalStock = parseFloat(inputValue) || 0;
}
var difference = physicalStock - systemStock;
var differenceCell = $(this).closest('tr').find('.difference');
differenceCell.text(difference.toFixed(2));
// Add color coding for difference
if (difference > 0) {
differenceCell.removeClass('text-danger').addClass('text-success');
} else if (difference < 0) {
differenceCell.removeClass('text-success').addClass('text-danger');
} else {
differenceCell.removeClass('text-success text-danger');
}
// Update product counter
updateProductCounter();
});
// Function to update product counter
function updateProductCounter() {
var filledProducts = 0;
var totalProducts = $('.physical-stock').length;
$('.physical-stock').each(function() {
var value = $(this).val();
// Count as filled if it's a valid number (including 0)
if (value !== '' && value !== null && value !== undefined && !isNaN(parseFloat(value)) && parseFloat(value) >= 0) {
filledProducts++;
}
});
// Update button text to show progress
var buttonText = 'Simpan Opname';
if (filledProducts > 0) {
buttonText += ` (${filledProducts}/${totalProducts} produk)`;
}
if (!$('.button-save').hasClass('disabled')) {
$('.button-save').html(buttonText);
}
}
// Handle when input loses focus - don't auto-fill, let user decide
$(document).on('blur', '.physical-stock', function() {
// Trigger calculation even for empty values
$(this).trigger('input');
});
// Function to reset button state if validation fails
function resetSubmitButton() {
$(".button-save").attr("disabled", false);
$(".button-save").removeClass("disabled");
updateProductCounter(); // Update with current counter
}
// Function to show field error highlighting
function highlightInvalidFields() {
$('.physical-stock').each(function() {
var value = $(this).val();
var $input = $(this);
if (value !== '' && value !== null && value !== undefined) {
var numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) {
$input.addClass('is-invalid');
} else {
$input.removeClass('is-invalid');
}
} else {
$input.removeClass('is-invalid');
}
});
}
// Remove error styling when user starts typing
$(document).on('input', '.physical-stock', function() {
$(this).removeClass('is-invalid');
});
// Handle server-side errors - scroll to first error and highlight
$(document).ready(function() {
// Set default date for opname if empty
if ($('#date-opname').val() === '') {
var today = new Date().toISOString().split('T')[0];
$('#date-opname').val(today);
}
// Check if there are validation errors
if ($('.is-invalid').length > 0) {
// Scroll to first error
$('html, body').animate({
scrollTop: $('.is-invalid:first').offset().top - 100
}, 500);
// Show alert for validation errors
@if(session('error'))
Swal.fire({
icon: 'error',
title: 'Terjadi Kesalahan',
text: '{{ session("error") }}',
confirmButtonText: 'OK'
});
@endif
}
// Auto-dismiss alerts after 5 seconds
setTimeout(function() {
$('.alert').fadeOut('slow');
}, 5000);
});
// Handle opname date field - set default if becomes empty
$('#date-opname').on('blur', function() {
if ($(this).val() === '') {
var today = new Date().toISOString().split('T')[0];
$(this).val(today);
}
});
// Handle mutasi product selection
$(document).on('change', '.mutasi-product-checkbox', function() {
var quantityInput = $(this).closest('tr').find('.mutasi-quantity');
if ($(this).is(':checked')) {
quantityInput.prop('disabled', false);
} else {
quantityInput.prop('disabled', true).val('');
}
});
// Select all mutasi products
$('#select-all-mutasi').change(function() {
$('.mutasi-product-checkbox').prop('checked', this.checked).trigger('change');
});
function createTransaction(form) {
let work_ids;