create new menu histori stock audit

This commit is contained in:
2025-06-16 17:27:59 +07:00
parent 567e4aa5fc
commit aa233eb793
13 changed files with 1264 additions and 1 deletions

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Privilege;
class SetupStockAuditMenu extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'setup:stock-audit-menu';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Setup Stock Audit menu and privileges';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Setting up Stock Audit menu...');
// Check if menu already exists
$existingMenu = Menu::where('link', 'stock-audit.index')->first();
if ($existingMenu) {
$this->warn('Stock Audit menu already exists!');
return 0;
}
// Create Stock Audit menu
$menu = Menu::create([
'name' => 'Audit Histori Stock',
'link' => 'stock-audit.index',
'created_at' => now(),
'updated_at' => now()
]);
$this->info('Stock Audit menu created with ID: ' . $menu->id);
// Give all roles access to this menu
$roles = Role::all();
$privilegeCount = 0;
foreach($roles as $role) {
// Check if privilege already exists
$existingPrivilege = Privilege::where('role_id', $role->id)
->where('menu_id', $menu->id)
->first();
if (!$existingPrivilege) {
Privilege::create([
'role_id' => $role->id,
'menu_id' => $menu->id,
'create' => 0, // Stock audit is view-only
'update' => 0, // Stock audit is view-only
'delete' => 0, // Stock audit is view-only
'view' => 1, // Allow viewing
'created_at' => now(),
'updated_at' => now()
]);
$privilegeCount++;
}
}
$this->info("Created {$privilegeCount} privileges for Stock Audit menu.");
$this->info('Stock Audit menu setup completed successfully!');
return 0;
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\StockLog;
use App\Models\Menu;
use App\Models\Dealer;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
class StockAuditController extends Controller
{
public function index(Request $request)
{
$menu = Menu::where('link', 'stock-audit.index')->first();
$dealers = Dealer::all();
$products = Product::all();
if ($request->ajax()) {
Log::info('Stock audit ajax request received', [
'filters' => $request->only(['dealer', 'product', 'change_type', 'date']),
'user_id' => auth()->id(),
'user_dealer_id' => auth()->user()->dealer_id
]);
$data = StockLog::query()
->with([
'stock.product',
'stock.dealer',
'user.role',
'source'
])
->leftJoin('stocks', 'stock_logs.stock_id', '=', 'stocks.id')
->leftJoin('products', 'stocks.product_id', '=', 'products.id')
->leftJoin('dealers', 'stocks.dealer_id', '=', 'dealers.id')
->leftJoin('users', 'stock_logs.user_id', '=', 'users.id')
->select('stock_logs.*');
// Filter berdasarkan dealer jika user bukan admin
if (auth()->user()->dealer_id) {
$data->whereHas('stock', function($query) {
$query->where('dealer_id', auth()->user()->dealer_id);
});
}
// Apply filters from request
if ($request->filled('dealer')) {
$data->where('dealers.name', 'like', '%' . $request->dealer . '%');
}
if ($request->filled('product')) {
$data->where('products.name', 'like', '%' . $request->product . '%');
}
if ($request->filled('change_type')) {
$data->where('stock_logs.change_type', $request->change_type);
}
if ($request->filled('date')) {
$data->whereDate('stock_logs.created_at', $request->date);
}
return DataTables::of($data)
->addIndexColumn()
->addColumn('product_name', function($row) {
return $row->stock->product->name ?? '-';
})
->addColumn('dealer_name', function($row) {
return $row->stock->dealer->name ?? '-';
})
->addColumn('change_type', function($row) {
$changeType = $row->change_type;
$class = match($changeType->value) {
'increase' => 'text-success',
'decrease' => 'text-danger',
'adjustment' => 'text-warning',
'no_change' => 'text-muted',
default => 'text-dark'
};
return "<span class=\"font-weight-bold {$class}\">{$changeType->label()}</span>";
})
->addColumn('quantity_change', function($row) {
$change = $row->quantity_change;
if ($change > 0) {
return "<span class=\"text-success\">+{$change}</span>";
} elseif ($change < 0) {
return "<span class=\"text-danger\">{$change}</span>";
} else {
return "<span class=\"text-muted\">0</span>";
}
})
->addColumn('stock_before_after', function($row) {
return "{$row->previous_quantity}{$row->new_quantity}";
})
->addColumn('source_info', function($row) {
if ($row->source_type === 'App\\Models\\Mutation') {
$mutationNumber = $row->source ? $row->source->mutation_number : '-';
return "Mutasi: {$mutationNumber}";
} elseif ($row->source_type === 'App\\Models\\Opname') {
return "Opname";
} else {
return $row->source_type ?? '-';
}
})
->addColumn('user_name', function($row) {
return $row->user->name ?? '-';
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) {
$buttons = '<button type="button" class="btn btn-info btn-sm" onclick="showAuditDetail('.$row->id.')">
Detail
</button>';
return $buttons;
})
// Filtering
->filterColumn('product_name', function($query, $keyword) {
$query->where('products.name', 'like', "%{$keyword}%");
})
->filterColumn('dealer_name', function($query, $keyword) {
$query->where('dealers.name', 'like', "%{$keyword}%");
})
->filterColumn('change_type', function($query, $keyword) {
$query->where('stock_logs.change_type', 'like', "%{$keyword}%");
})
->filterColumn('source_info', function($query, $keyword) {
$query->where(function($q) use ($keyword) {
$q->where('stock_logs.source_type', 'like', "%{$keyword}%")
->orWhere('stock_logs.description', 'like', "%{$keyword}%");
});
})
->filterColumn('user_name', function($query, $keyword) {
$query->where('users.name', 'like', "%{$keyword}%");
})
->filterColumn('created_at', function($query, $keyword) {
$query->whereDate('stock_logs.created_at', 'like', "%{$keyword}%");
})
// Order column mapping
->orderColumn('product_name', function($query, $order) {
return $query->orderBy('products.name', $order);
})
->orderColumn('dealer_name', function($query, $order) {
return $query->orderBy('dealers.name', $order);
})
->orderColumn('user_name', function($query, $order) {
return $query->orderBy('users.name', $order);
})
->orderColumn('created_at', function($query, $order) {
return $query->orderBy('stock_logs.created_at', $order);
})
->orderColumn('quantity_change', function($query, $order) {
return $query->orderBy('stock_logs.quantity_change', $order);
})
->orderColumn('stock_before_after', function($query, $order) {
return $query->orderBy('stock_logs.previous_quantity', $order);
})
->orderColumn('change_type', function($query, $order) {
return $query->orderBy('stock_logs.change_type', $order);
})
->orderColumn('source_info', function($query, $order) {
return $query->orderBy('stock_logs.source_type', $order);
})
->rawColumns(['change_type', 'quantity_change', 'action'])
->make(true);
}
return view('warehouse_management.stock_audit.index', compact('menu', 'dealers', 'products'));
}
public function getDetail(StockLog $stockLog)
{
try {
$stockLog->load([
'stock.product',
'stock.dealer',
'user.role',
'source'
]);
// Format data untuk response
$stockLog->created_at_formatted = $stockLog->created_at->format('d M Y, H:i');
$stockLog->change_type_label = $stockLog->change_type->label();
// Detail source berdasarkan tipe
$sourceDetail = null;
if ($stockLog->source) {
if ($stockLog->source_type === 'App\\Models\\Mutation') {
$mutation = $stockLog->source;
$mutation->load(['fromDealer', 'toDealer', 'requestedBy', 'approvedBy']);
// Format approved_at date if exists
if ($mutation->approved_at) {
$mutation->approved_at_formatted = $mutation->approved_at->format('d M Y, H:i');
}
$sourceDetail = [
'type' => 'mutation',
'data' => $mutation
];
} elseif ($stockLog->source_type === 'App\\Models\\StockOpname') {
$opname = $stockLog->source;
$opname->load(['dealer', 'user']);
$sourceDetail = [
'type' => 'opname',
'data' => $opname
];
}
}
return response()->json([
'success' => true,
'data' => $stockLog,
'source_detail' => $sourceDetail
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal memuat detail audit: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -30,6 +30,10 @@ class MenuSeeder extends Seeder
[ [
'name' => 'Stock Opname', 'name' => 'Stock Opname',
'link' => 'opnames.index' 'link' => 'opnames.index'
],
[
'name' => 'Histori Stock',
'link' => 'stock-audit.index'
] ]
]; ];

257
docs/STOCK_AUDIT.md Normal file
View File

@@ -0,0 +1,257 @@
# Audit Histori Stock
## Deskripsi
Fitur Audit Histori Stock memungkinkan untuk melacak semua perubahan stock yang terjadi di sistem. Setiap kali ada perubahan stock (penambahan, pengurangan, penyesuaian), sistem akan mencatat detail perubahan tersebut untuk keperluan audit.
## Fitur Utama
### 1. Tracking Otomatis
- Sistem otomatis mencatat setiap perubahan stock
- Mencatat stock sebelum dan sesudah perubahan
- Mencatat sumber perubahan (mutasi, opname, dll)
- Mencatat user yang melakukan perubahan
- Mencatat timestamp perubahan
### 2. Filter dan Pencarian
- Filter berdasarkan dealer
- Filter berdasarkan produk
- Filter berdasarkan jenis perubahan
- Filter berdasarkan tanggal
- Pencarian realtime pada semua kolom
### 3. Detail Audit
- Informasi lengkap perubahan stock
- Detail sumber perubahan (mutasi/opname)
- History user yang melakukan aksi
- Catatan dan keterangan perubahan
### 4. Export Data
- Export ke Excel
- Export ke PDF
- Data yang diekspor dapat disesuaikan
## Jenis Perubahan Stock
### 1. Penambahan (Increase)
- Stock bertambah dari transaksi
- Biasanya dari mutasi masuk atau opname correction
### 2. Pengurangan (Decrease)
- Stock berkurang dari transaksi
- Biasanya dari mutasi keluar atau penjualan
### 3. Penyesuaian (Adjustment)
- Penyesuaian stock dari opname
- Koreksi stock manual
### 4. Tidak Ada Perubahan (No Change)
- Record dibuat tapi tidak ada perubahan quantity
- Biasanya untuk tracking purpose
## Cara Menggunakan
### 1. Akses Menu
```
Warehouse -> Stock Audit
```
### 2. Menggunakan Filter
```javascript
// Filter dealer
$("#filter-dealer").val("Nama Dealer");
// Filter produk
$("#filter-product").val("Nama Produk");
// Filter jenis perubahan
$("#filter-change-type").val("increase"); // increase, decrease, adjustment, no_change
// Filter tanggal
$("#filter-date").val("2024-01-15");
// Reset semua filter
$("#reset-filters").click();
```
### 3. Melihat Detail
```javascript
// Klik tombol Detail pada baris data
showAuditDetail(stockLogId);
```
## Setup dan Instalasi
### 1. Setup Menu dan Privileges
```bash
php artisan setup:stock-audit-menu
```
### 2. Atau Menggunakan Seeder
```bash
php artisan db:seed --class=StockAuditMenuSeeder
```
## Struktur Data
### Model yang Terlibat
- **StockLog**: Record audit perubahan stock
- **Stock**: Data stock utama
- **Product**: Data produk
- **Dealer**: Data dealer
- **User**: Data user
- **Mutation**: Data mutasi stock
- **StockOpname**: Data opname stock
### Relasi Database
```php
StockLog belongsTo Stock
StockLog belongsTo User
StockLog morphTo Source (Mutation, StockOpname, etc)
Stock belongsTo Product
Stock belongsTo Dealer
```
## API Endpoints
### 1. Index (List Data)
```
GET /warehouse/stock-audit
```
### 2. Detail Audit
```
GET /warehouse/stock-audit/{stockLog}/detail
```
## Kustomisasi
### 1. Menambah Jenis Perubahan
Edit enum `StockChangeType`:
```php
// app/Enums/StockChangeType.php
case NEW_TYPE = 'new_type';
public function label(): string
{
return match($this) {
// ... existing cases
self::NEW_TYPE => 'Label Baru',
};
}
```
### 2. Menambah Filter Custom
Edit controller dan view untuk menambah filter baru:
```php
// Controller
->filterColumn('new_field', function($query, $keyword) {
$query->where('new_field', 'like', "%{$keyword}%");
})
// View
<select class="form-select" id="filter-new-field">
<option value="">Semua</option>
// ... options
</select>
```
### 3. Kustomisasi Export
Edit DataTables buttons untuk menyesuaikan kolom export:
```javascript
exportOptions: {
columns: [1, 2, 3, 4, 5, 6, 7, 8]; // Sesuaikan kolom yang ingin diekspor
}
```
## Troubleshooting
### 1. Menu Tidak Muncul
- Pastikan menu sudah di-setup dengan benar
- Cek privileges user untuk menu stock-audit.index
- Cek role user memiliki akses view = 1
### 2. Data Tidak Muncul
- Cek apakah ada data StockLog di database
- Cek filter yang aktive
- Cek permission user untuk melihat data dealer tertentu
### 3. Detail Tidak Loading
- Cek URL endpoint `/warehouse/stock-audit/{id}/detail`
- Cek network tab di browser untuk error response
- Cek log Laravel untuk error detail
## Keamanan
### 1. Filter Berdasarkan Role
- User dengan `dealer_id` hanya melihat data dealer mereka
- Admin dapat melihat semua data
### 2. View-Only Access
- Menu ini adalah read-only
- Tidak ada aksi create, update, atau delete
- Hanya viewing dan export yang diizinkan
### 3. Audit Trail
- Setiap akses audit log dapat di-track
- User activity dapat dimonitor
- Data tidak dapat dimanipulasi
## Performance Tips
### 1. Index Database
Pastikan ada index pada kolom yang sering difilter:
```sql
-- Index untuk performance
CREATE INDEX idx_stock_logs_created_at ON stock_logs(created_at);
CREATE INDEX idx_stock_logs_change_type ON stock_logs(change_type);
CREATE INDEX idx_stock_logs_stock_id ON stock_logs(stock_id);
```
### 2. Pagination
- DataTables menggunakan server-side processing
- Default page length: 25 records
- Dapat disesuaikan sesuai kebutuhan
### 3. Caching
Jika data sangat besar, pertimbangkan untuk menambah caching:
```php
// Cache dealer dan product data
$dealers = Cache::remember('dealers_for_audit', 3600, function () {
return Dealer::all();
});
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@
"/js/warehouse_management/opnames/detail.js": "/js/warehouse_management/opnames/detail.js", "/js/warehouse_management/opnames/detail.js": "/js/warehouse_management/opnames/detail.js",
"/js/warehouse_management/mutations/index.js": "/js/warehouse_management/mutations/index.js", "/js/warehouse_management/mutations/index.js": "/js/warehouse_management/mutations/index.js",
"/js/warehouse_management/mutations/create.js": "/js/warehouse_management/mutations/create.js", "/js/warehouse_management/mutations/create.js": "/js/warehouse_management/mutations/create.js",
"/js/warehouse_management/stock_audit/index.js": "/js/warehouse_management/stock_audit/index.js",
"/css/app.css": "/css/app.css", "/css/app.css": "/css/app.css",
"/js/vendor/jquery.dataTables.min.js": "/js/vendor/jquery.dataTables.min.js", "/js/vendor/jquery.dataTables.min.js": "/js/vendor/jquery.dataTables.min.js",
"/js/vendor/dataTables.bootstrap4.min.js": "/js/vendor/dataTables.bootstrap4.min.js", "/js/vendor/dataTables.bootstrap4.min.js": "/js/vendor/dataTables.bootstrap4.min.js",

View File

@@ -0,0 +1,419 @@
console.log("Stock audit JS loaded");
// Helper function to format date
function formatDate(dateString) {
if (!dateString) return "-";
const date = new Date(dateString);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"Mei",
"Jun",
"Jul",
"Agu",
"Sep",
"Okt",
"Nov",
"Des",
];
const day = date.getDate().toString().padStart(2, "0");
const month = months[date.getMonth()];
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day} ${month} ${year}, ${hours}:${minutes}`;
}
$(document).ready(function () {
console.log("Initializing stock audit table...");
// Initialize Select2 without any event handlers
$(".select2").select2({
placeholder: "Pilih...",
allowClear: true,
width: "100%",
});
// Initialize Datepicker
$(".datepicker").datepicker({
format: "yyyy-mm-dd",
autoclose: true,
todayHighlight: true,
orientation: "bottom auto",
language: "id",
clearBtn: true,
container: "body",
});
const $table = $("#stock-audit-table");
const indexRoute = $table.data("route");
console.log("Table route:", indexRoute);
let table = $table.DataTable({
processing: true,
serverSide: true,
language: {
processing:
'<div class="d-flex justify-content-center"><div class="spinner-border text-primary" role="status"><span class="sr-only">Memproses...</span></div></div>',
loadingRecords: "Memuat data...",
zeroRecords: "Tidak ada data yang ditemukan",
emptyTable: "Tidak ada data tersedia",
},
ajax: {
url: indexRoute,
data: function (d) {
d.dealer = $("#filter-dealer").val();
d.product = $("#filter-product").val();
d.change_type = $("#filter-change-type").val();
d.date = $("#filter-date").val();
console.log("Ajax data with ordering:", d);
console.log("Order info:", d.order);
console.log("Columns info:", d.columns);
},
error: function (xhr, error, thrown) {
console.error("Ajax error:", error);
console.error("Response:", xhr.responseText);
},
},
columns: [
{
data: "DT_RowIndex",
name: "DT_RowIndex",
orderable: false,
searchable: false,
width: "5%",
},
{
data: "product_name",
name: "product_name",
orderable: true,
},
{
data: "dealer_name",
name: "dealer_name",
orderable: true,
},
{
data: "change_type",
name: "change_type",
orderable: true,
},
{
data: "quantity_change",
name: "quantity_change",
className: "text-center",
orderable: true,
},
{
data: "stock_before_after",
name: "stock_before_after",
className: "text-center",
orderable: true,
},
{
data: "source_info",
name: "source_info",
orderable: true,
},
{
data: "user_name",
name: "user_name",
orderable: true,
},
{
data: "created_at",
name: "created_at",
orderable: true,
},
{
data: "action",
name: "action",
orderable: false,
searchable: false,
width: "10%",
},
],
order: [[8, "desc"]], // Order by created_at desc (column index 8)
pageLength: 25,
responsive: true,
ordering: true, // Enable column ordering
orderMulti: false, // Single column ordering only
});
console.log("Table initialized:", table);
// Add loading indicator for ordering
table.on("processing.dt", function (e, settings, processing) {
if (processing) {
console.log("DataTable processing started (ordering/filtering)");
} else {
console.log("DataTable processing finished");
}
});
// Debug order events
table.on("order.dt", function () {
console.log("Order changed:", table.order());
});
// Manual modal close handlers
$(document).on(
"click",
"#modal-close-btn, #modal-close-footer-btn",
function () {
console.log("Manual close button clicked");
$("#auditDetailModal").modal("hide");
}
);
// Modal backdrop click handler
$(document).on("click", "#auditDetailModal", function (e) {
if (e.target === this) {
console.log("Modal backdrop clicked");
$("#auditDetailModal").modal("hide");
}
});
// ESC key handler
$(document).on("keydown", function (e) {
if (e.keyCode === 27 && $("#auditDetailModal").hasClass("show")) {
console.log("ESC key pressed");
$("#auditDetailModal").modal("hide");
}
});
// Modal hidden event handler
$("#auditDetailModal").on("hidden.bs.modal", function () {
console.log("Modal hidden");
// Reset modal content
$("#modal-loading").show();
$("#modal-error").hide();
$("#modal-content").hide();
});
// Apply filters button - only way to trigger table reload
$("#apply-filters").click(function () {
console.log("Apply filters clicked, reloading table...");
console.log("Current filter values:", {
dealer: $("#filter-dealer").val(),
product: $("#filter-product").val(),
change_type: $("#filter-change-type").val(),
date: $("#filter-date").val(),
});
table.ajax.reload();
});
// Allow Enter key to apply filters on datepicker
$("#filter-date").keypress(function (e) {
if (e.which == 13) {
// Enter key
console.log("Enter pressed on date filter, applying filters...");
table.ajax.reload();
}
});
// Reset filters
$("#reset-filters").click(function () {
console.log("Resetting filters...");
// Reset select2 elements properly
$("#filter-dealer").val(null).trigger("change.select2");
$("#filter-product").val(null).trigger("change.select2");
$("#filter-change-type").val(null).trigger("change.select2");
// Reset datepicker properly
$("#filter-date").val("").datepicker("update");
console.log("Filters reset, values after reset:", {
dealer: $("#filter-dealer").val(),
product: $("#filter-product").val(),
change_type: $("#filter-change-type").val(),
date: $("#filter-date").val(),
});
// Reload table after reset
console.log("Reloading table after reset...");
table.ajax.reload();
});
});
window.showAuditDetail = function (id) {
console.log("Showing audit detail for ID:", id);
// Reset modal states first
$("#modal-loading").show();
$("#modal-error").hide();
$("#modal-content").hide();
// Show modal
$("#auditDetailModal").modal("show");
$.ajax({
url: `/warehouse/stock-audit/${id}/detail`,
method: "GET",
success: function (response) {
console.log("Detail response:", response);
$("#modal-loading").hide();
if (response.success) {
populateModalContent(response.data, response.source_detail);
$("#modal-content").show();
} else {
$("#error-message").text(response.message);
$("#modal-error").show();
}
},
error: function (xhr) {
console.error("Detail AJAX error:", xhr);
$("#modal-loading").hide();
$("#error-message").text("Gagal memuat detail audit");
$("#modal-error").show();
},
});
};
function populateModalContent(audit, sourceDetail) {
console.log("Populating modal content:", audit);
// Populate basic stock information
$("#product-name").text(audit.stock.product.name);
$("#dealer-name").text(audit.stock.dealer.name);
$("#previous-quantity").text(audit.previous_quantity);
$("#new-quantity").text(audit.new_quantity);
$("#user-name").text(audit.user ? audit.user.name : "-");
$("#created-at").text(audit.created_at_formatted);
$("#description").text(audit.description || "-");
// Set quantity change with styling
let quantityChangeClass = "";
let quantityChangeSign = "";
if (audit.quantity_change > 0) {
quantityChangeClass = "text-success";
quantityChangeSign = "+";
} else if (audit.quantity_change < 0) {
quantityChangeClass = "text-danger";
quantityChangeSign = "";
} else {
quantityChangeClass = "text-muted";
quantityChangeSign = "";
}
$("#quantity-change").html(
`<span class="${quantityChangeClass}">${quantityChangeSign}${audit.quantity_change}</span>`
);
// Set change type with styling
let changeTypeClass = "";
switch (audit.change_type) {
case "increase":
changeTypeClass = "text-success";
break;
case "decrease":
changeTypeClass = "text-danger";
break;
case "adjustment":
changeTypeClass = "text-warning";
break;
default:
changeTypeClass = "text-muted";
}
$("#change-type").html(
`<span class="font-weight-bold ${changeTypeClass}">${audit.change_type_label}</span>`
);
// Handle source detail
if (sourceDetail) {
$("#source-detail").show();
if (sourceDetail.type === "mutation") {
let mutation = sourceDetail.data;
$("#source-title").text(
`Mutasi Stock: ${mutation.mutation_number}`
);
let mutationContent = `
<div class="row">
<div class="col-md-6">
<table class="table table-sm">
<tr>
<td><strong>Dari Dealer:</strong></td>
<td>${
mutation.from_dealer
? mutation.from_dealer.name
: "-"
}</td>
</tr>
<tr>
<td><strong>Ke Dealer:</strong></td>
<td>${
mutation.to_dealer
? mutation.to_dealer.name
: "-"
}</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>${mutation.status}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<table class="table table-sm">
<tr>
<td><strong>Diminta oleh:</strong></td>
<td>${
mutation.requested_by
? mutation.requested_by.name
: "-"
}</td>
</tr>
<tr>
<td><strong>Disetujui oleh:</strong></td>
<td>${
mutation.approved_by
? mutation.approved_by.name
: "-"
}</td>
</tr>
<tr>
<td><strong>Tanggal Disetujui:</strong></td>
<td>${
mutation.approved_at_formatted || "-"
}</td>
</tr>
</table>
</div>
</div>
`;
$("#source-content").html(mutationContent);
} else if (sourceDetail.type === "opname") {
let opname = sourceDetail.data;
$("#source-title").text("Opname");
let opnameContent = `
<table class="table table-sm">
<tr>
<td><strong>Dealer:</strong></td>
<td>${opname.dealer ? opname.dealer.name : "-"}</td>
</tr>
<tr>
<td><strong>User:</strong></td>
<td>${opname.user ? opname.user.name : "-"}</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>${opname.status || "-"}</td>
</tr>
</table>
`;
$("#source-content").html(opnameContent);
}
} else {
$("#source-detail").hide();
}
}

View File

@@ -146,6 +146,15 @@
</li> </li>
@endcan @endcan
@can('view', $menus['stock-audit.index'])
<li class="kt-menu__item" aria-haspopup="true">
<a href="{{ route('stock-audit.index') }}" class="kt-menu__link">
<i class="fa fa-list" style="display: flex; align-items: center; margin-right: 10px;"></i>
<span class="kt-menu__link-text">Histori Stock</span>
</a>
</li>
@endcan
{{-- Section Header --}} {{-- Section Header --}}
<div class="kt-menu__section" style="padding: 10px 20px; font-weight: bold;"> <div class="kt-menu__section" style="padding: 10px 20px; font-weight: bold;">
<i class="kt-menu__section-icon fa fa-box"></i> <i class="kt-menu__section-icon fa fa-box"></i>

View File

@@ -0,0 +1,220 @@
@extends('layouts.backapp')
@section('styles')
<style>
.filter-buttons {
display: flex;
gap: 10px;
}
.filter-buttons .btn {
white-space: nowrap;
}
.datepicker {
width: 100% !important;
max-width: 100%;
}
.datepicker-dropdown {
width: auto !important;
min-width: 250px;
max-width: 300px;
}
/* Ensure input field follows parent width */
input.datepicker {
width: 100% !important;
box-sizing: border-box;
}
/* Fix for select2 dropdown width */
.select2-container {
width: 100% !important;
}
</style>
@endsection
@section('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">
Histori Stock
</h3>
</div>
</div>
<div class="kt-portlet__body">
<!-- Filter Section -->
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Filter Dealer</label>
<select class="form-control select2" id="filter-dealer">
<option value="">Semua Dealer</option>
@foreach($dealers as $dealer)
<option value="{{ $dealer->name }}">{{ $dealer->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-3">
<label class="form-label">Filter Produk</label>
<select class="form-control select2" id="filter-product">
<option value="">Semua Produk</option>
@foreach($products as $product)
<option value="{{ $product->name }}">{{ $product->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-3">
<label class="form-label">Jenis Perubahan</label>
<select class="form-control select2" id="filter-change-type">
<option value="">Semua Jenis</option>
<option value="increase">Penambahan</option>
<option value="decrease">Pengurangan</option>
<option value="no_change">Tidak Ada Perubahan</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Filter Tanggal</label>
<input type="text" class="form-control datepicker" id="filter-date" placeholder="Pilih tanggal">
</div>
</div>
<!-- Action Buttons Row -->
<div class="row mb-3">
<div class="col-md-12">
<div class="filter-buttons">
<button type="button" class="btn btn-primary btn-sm" id="apply-filters">
Filter
</button>
<button type="button" class="btn btn-secondary btn-sm" id="reset-filters">
Reset
</button>
</div>
</div>
</div>
<div class="table-responsive">
<table id="stock-audit-table" class="table table-striped table-bordered"
data-route="{{ route('stock-audit.index') }}">
<thead>
<tr>
<th>No</th>
<th>Produk</th>
<th>Dealer</th>
<th>Jenis Perubahan</th>
<th>Perubahan Qty</th>
<th>Stock (Sebelum Sesudah)</th>
<th>Sumber</th>
<th>User</th>
<th>Tanggal</th>
<th>Aksi</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="modal fade" id="auditDetailModal" tabindex="-1" role="dialog" aria-labelledby="auditDetailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="auditDetailModalLabel">Detail Audit Stock</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" id="modal-close-btn">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" id="auditDetailContent">
<!-- Loading State -->
<div id="modal-loading" class="text-center">
<div class="spinner-border" role="status">
<span class="sr-only">Memuat...</span>
</div>
<p class="mt-2">Memuat data audit...</p>
</div>
<!-- Error State -->
<div id="modal-error" class="alert alert-danger" style="display: none;">
<strong>Error:</strong> <span id="error-message">Gagal memuat detail audit</span>
</div>
<!-- Content Area -->
<div id="modal-content" style="display: none;">
<!-- Stock Information -->
<div class="row">
<div class="col-md-6">
<h6 class="font-weight-bold">Informasi Stock</h6>
<table class="table table-sm">
<tr>
<td><strong>Produk:</strong></td>
<td id="product-name">-</td>
</tr>
<tr>
<td><strong>Dealer:</strong></td>
<td id="dealer-name">-</td>
</tr>
<tr></tr>
<td><strong>Stock Sebelum:</strong></td>
<td id="previous-quantity">-</td>
</tr>
<tr>
<td><strong>Stock Sesudah:</strong></td>
<td id="new-quantity">-</td>
</tr>
<tr>
<td><strong>Perubahan:</strong></td>
<td id="quantity-change">-</td>
</tr>
<tr>
<td><strong>Jenis Perubahan:</strong></td>
<td id="change-type">-</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h6 class="font-weight-bold">Informasi Sistem</h6>
<table class="table table-sm">
<tr>
<td><strong>User:</strong></td>
<td id="user-name">-</td>
</tr>
<tr>
<td><strong>Tanggal:</strong></td>
<td id="created-at">-</td>
</tr>
<tr>
<td><strong>Deskripsi:</strong></td>
<td id="description">-</td>
</tr>
</table>
</div>
</div>
<!-- Source Detail (will be shown/hidden based on data) -->
<div id="source-detail" style="display: none;">
<hr>
<h6 class="font-weight-bold">Detail Sumber Perubahan</h6>
<div class="card">
<div class="card-header">
<h6 class="mb-0" id="source-title">-</h6>
</div>
<div class="card-body" id="source-content">
<!-- Content will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="modal-close-footer-btn">Tutup</button>
</div>
</div>
</div>
</div>
@endsection
@section('javascripts')
<script src="{{ mix('js/warehouse_management/stock_audit/index.js') }}"></script>
@endsection

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\WarehouseManagement\ProductCategoriesController;
use App\Http\Controllers\WarehouseManagement\ProductsController; use App\Http\Controllers\WarehouseManagement\ProductsController;
use App\Http\Controllers\WorkController; use App\Http\Controllers\WorkController;
use App\Http\Controllers\WarehouseManagement\MutationsController; use App\Http\Controllers\WarehouseManagement\MutationsController;
use App\Http\Controllers\WarehouseManagement\StockAuditController;
use App\Models\Menu; use App\Models\Menu;
use App\Models\Privilege; use App\Models\Privilege;
use App\Models\Role; use App\Models\Role;
@@ -257,6 +258,11 @@ Route::group(['middleware' => 'auth'], function() {
Route::post('{mutation}/complete', 'complete')->name('complete'); Route::post('{mutation}/complete', 'complete')->name('complete');
Route::post('{mutation}/cancel', 'cancel')->name('cancel'); Route::post('{mutation}/cancel', 'cancel')->name('cancel');
}); });
Route::prefix('stock-audit')->name('stock-audit.')->controller(StockAuditController::class)->group(function () {
Route::get('/', 'index')->name('index');
Route::get('{stockLog}/detail', 'getDetail')->name('detail');
});
}); });
}); });

View File

@@ -42,6 +42,10 @@ mix.js("resources/js/app.js", "public/js")
"resources/js/warehouse_management/mutations/create.js", "resources/js/warehouse_management/mutations/create.js",
"public/js/warehouse_management/mutations" "public/js/warehouse_management/mutations"
) )
.js(
"resources/js/warehouse_management/stock_audit/index.js",
"public/js/warehouse_management/stock_audit"
)
// Copy vendor libraries from node_modules // Copy vendor libraries from node_modules
.copy( .copy(
"node_modules/datatables.net/js/jquery.dataTables.min.js", "node_modules/datatables.net/js/jquery.dataTables.min.js",