Add soft delete, archive, and bulk import/export for assets
This commit is contained in:
@@ -14,9 +14,11 @@ use App\Models\Unit;
|
||||
use App\Models\VendorContract;
|
||||
use App\Models\Warranty;
|
||||
use App\Services\Asset\AssetService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AssetController extends Controller
|
||||
{
|
||||
@@ -36,6 +38,7 @@ class AssetController extends Controller
|
||||
'asset_user_id',
|
||||
'person_in_charge_id',
|
||||
'warranty_id',
|
||||
'scope',
|
||||
]);
|
||||
|
||||
$assets = Asset::with(['status', 'class', 'category', 'unit', 'department', 'personInCharge', 'user', 'location', 'warranty'])
|
||||
@@ -55,6 +58,10 @@ class AssetController extends Controller
|
||||
->when($filters['asset_user_id'] ?? null, fn($q, $v) => $q->where('asset_user_id', $v))
|
||||
->when($filters['person_in_charge_id'] ?? null, fn($q, $v) => $q->where('person_in_charge_id', $v))
|
||||
->when($filters['warranty_id'] ?? null, fn($q, $v) => $q->where('warranty_id', $v))
|
||||
->when(($filters['scope'] ?? '') === 'archived', fn($q) => $q->whereNotNull('archived_at'))
|
||||
->when(($filters['scope'] ?? '') === 'trashed', fn($q) => $q->onlyTrashed())
|
||||
->when(($filters['scope'] ?? '') === 'all', fn($q) => $q->withTrashed())
|
||||
->when(!in_array($filters['scope'] ?? '', ['archived','trashed','all']), fn($q) => $q->whereNull('archived_at'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('system.ui.table_page_size', 15))
|
||||
->withQueryString();
|
||||
@@ -72,6 +79,7 @@ class AssetController extends Controller
|
||||
'warranties' => Warranty::orderBy('duration_months')->get(),
|
||||
'vendorContracts' => VendorContract::orderBy('vendor_name')->get(),
|
||||
'filters' => $filters,
|
||||
'importPreview' => session('import_preview'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -113,6 +121,179 @@ class AssetController extends Controller
|
||||
return view('assets.history', compact('asset'));
|
||||
}
|
||||
|
||||
public function archive(Asset $asset): RedirectResponse
|
||||
{
|
||||
$asset->update([
|
||||
'archived_at' => now(),
|
||||
'archived_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return back()->with('success', "Aset {$asset->code} diarsipkan.");
|
||||
}
|
||||
|
||||
public function unarchive(Asset $asset): RedirectResponse
|
||||
{
|
||||
$asset->update([
|
||||
'archived_at' => null,
|
||||
'archived_by' => null,
|
||||
]);
|
||||
|
||||
return back()->with('success', "Aset {$asset->code} dikembalikan dari arsip.");
|
||||
}
|
||||
|
||||
public function destroy(Asset $asset): RedirectResponse
|
||||
{
|
||||
$asset->delete();
|
||||
return back()->with('success', "Aset {$asset->code} dihapus (soft delete).");
|
||||
}
|
||||
|
||||
public function restore(string $asset): RedirectResponse
|
||||
{
|
||||
$model = Asset::onlyTrashed()->findOrFail($asset);
|
||||
$model->restore();
|
||||
return back()->with('success', "Aset {$model->code} berhasil di-restore.");
|
||||
}
|
||||
|
||||
public function exportCsv(Request $request): JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
$filters = $request->all();
|
||||
$assets = Asset::with(['status','category','location'])
|
||||
->when($filters['scope'] ?? null, function ($q, $scope) {
|
||||
if ($scope === 'archived') $q->whereNotNull('archived_at');
|
||||
if ($scope === 'trashed') $q->onlyTrashed();
|
||||
if ($scope === 'all') $q->withTrashed();
|
||||
})
|
||||
->limit(2000)
|
||||
->get();
|
||||
|
||||
$filename = 'assets-export-'.now()->format('Ymd_His').'.csv';
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'csv');
|
||||
$fh = fopen($tmp, 'w');
|
||||
fputcsv($fh, [
|
||||
'code','name','serial_number','status','category','location',
|
||||
'rfid_tag','nfc_tag','is_consumable','quantity','available_quantity','is_pool','archived_at'
|
||||
]);
|
||||
foreach ($assets as $asset) {
|
||||
fputcsv($fh, [
|
||||
$asset->code,
|
||||
$asset->name,
|
||||
$asset->serial_number,
|
||||
$asset->status?->name,
|
||||
$asset->category?->name,
|
||||
$asset->location?->name,
|
||||
$asset->rfid_tag,
|
||||
$asset->nfc_tag,
|
||||
$asset->is_consumable ? '1':'0',
|
||||
$asset->quantity,
|
||||
$asset->available_quantity,
|
||||
$asset->is_pool ? '1':'0',
|
||||
$asset->archived_at,
|
||||
]);
|
||||
}
|
||||
fclose($fh);
|
||||
return response()->download($tmp, $filename)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
public function import(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => ['required','file','mimes:csv,txt'],
|
||||
'action' => ['nullable','in:preview,import'],
|
||||
]);
|
||||
|
||||
$action = $request->input('action', 'preview');
|
||||
$path = $request->file('file')->getRealPath();
|
||||
$handle = fopen($path, 'r');
|
||||
if (!$handle) {
|
||||
return back()->withErrors(['file' => 'File tidak bisa dibaca']);
|
||||
}
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
$rows = [];
|
||||
$line = 1;
|
||||
$existingCodes = Asset::pluck('id','code')->toArray();
|
||||
$existingSerials = Asset::pluck('id','serial_number')->filter()->toArray();
|
||||
$statuses = AssetStatus::pluck('id','name')->toArray();
|
||||
$categories = AssetCategory::pluck('id','name')->toArray();
|
||||
$locations = AssetLocation::pluck('id','name')->toArray();
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$line++;
|
||||
$row = array_combine($header, $data);
|
||||
if (!$row) {
|
||||
$rows[] = ['line' => $line, 'status' => 'error', 'message' => 'Header tidak sesuai', 'data' => $data];
|
||||
continue;
|
||||
}
|
||||
$code = $row['code'] ?? null;
|
||||
$serial = $row['serial_number'] ?? null;
|
||||
$name = $row['name'] ?? null;
|
||||
$errors = [];
|
||||
if (!$name) $errors[] = 'Nama wajib';
|
||||
|
||||
$statusId = $statuses[$row['status'] ?? ''] ?? null;
|
||||
$categoryId = $categories[$row['category'] ?? ''] ?? null;
|
||||
$locationId = $locations[$row['location'] ?? ''] ?? null;
|
||||
|
||||
$targetId = $code && isset($existingCodes[$code]) ? $existingCodes[$code] : null;
|
||||
if (!$targetId && $serial && isset($existingSerials[$serial])) {
|
||||
$targetId = $existingSerials[$serial];
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'line' => $line,
|
||||
'status' => $errors ? 'invalid' : ($targetId ? 'update' : 'create'),
|
||||
'errors' => $errors,
|
||||
'payload' => [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'serial_number' => $serial,
|
||||
'asset_status_id' => $statusId,
|
||||
'asset_category_id' => $categoryId,
|
||||
'asset_location_id' => $locationId,
|
||||
'rfid_tag' => $row['rfid_tag'] ?? null,
|
||||
'nfc_tag' => $row['nfc_tag'] ?? null,
|
||||
'is_consumable' => !empty($row['is_consumable']),
|
||||
'quantity' => (int) ($row['quantity'] ?? 1),
|
||||
'available_quantity' => (int) ($row['available_quantity'] ?? $row['quantity'] ?? 1),
|
||||
'is_pool' => !empty($row['is_pool']),
|
||||
],
|
||||
'target_id' => $targetId,
|
||||
];
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
if ($action === 'preview') {
|
||||
session()->flash('import_preview', [
|
||||
'summary' => [
|
||||
'create' => collect($rows)->where('status','create')->count(),
|
||||
'update' => collect($rows)->where('status','update')->count(),
|
||||
'invalid' => collect($rows)->where('status','invalid')->count(),
|
||||
],
|
||||
'rows' => collect($rows)->take(50)->toArray(),
|
||||
]);
|
||||
return back()->with('success', 'Preview import siap. Silakan cek tabel preview.');
|
||||
}
|
||||
|
||||
// commit import
|
||||
$created = 0; $updated = 0; $invalid = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ($row['status'] === 'invalid') { $invalid++; continue; }
|
||||
$payload = $row['payload'];
|
||||
if ($row['target_id']) {
|
||||
Asset::where('id', $row['target_id'])->update($payload);
|
||||
$updated++;
|
||||
} else {
|
||||
if (!$payload['code']) {
|
||||
$payload['code'] = 'IMP-'.Str::upper(Str::random(6));
|
||||
}
|
||||
Asset::create($payload + ['qr_token' => Str::uuid()]);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', "Import selesai. Create: {$created}, Update: {$updated}, Invalid: {$invalid}");
|
||||
}
|
||||
|
||||
private function validateRequest(Request $request, ?string $assetId = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
|
||||
@@ -5,11 +5,12 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Models\User;
|
||||
|
||||
class Asset extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
@@ -44,6 +45,9 @@ class Asset extends Model
|
||||
'quantity',
|
||||
'available_quantity',
|
||||
'is_pool',
|
||||
'archived_at',
|
||||
'archived_by',
|
||||
'retention_until',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -54,6 +58,8 @@ class Asset extends Model
|
||||
'metadata' => 'array',
|
||||
'is_consumable' => 'boolean',
|
||||
'is_pool' => 'boolean',
|
||||
'archived_at' => 'datetime',
|
||||
'retention_until' => 'date',
|
||||
];
|
||||
|
||||
public function status()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('assets', function (Blueprint $table) {
|
||||
$table->timestamp('archived_at')->nullable()->after('updated_at');
|
||||
$table->uuid('archived_by')->nullable()->after('archived_at');
|
||||
$table->date('retention_until')->nullable()->after('archived_by');
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('assets', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
$table->dropColumn(['archived_at','archived_by','retention_until']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,9 +6,17 @@
|
||||
<h1 class="page-title fw-medium fs-20 mb-0">Data Aset</h1>
|
||||
<p class="text-muted mb-0">Kelola aset, QR token, dan metadata terkait.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-wave" data-bs-toggle="modal" data-bs-target="#createAssetModal">
|
||||
<i class="ri-add-line me-1"></i>Tambah Aset
|
||||
</button>
|
||||
<div class="btn-list">
|
||||
<a href="{{ route('assets.export', request()->query()) }}" class="btn btn-outline-secondary btn-wave">
|
||||
<i class="ri-download-2-line me-1"></i>Export CSV
|
||||
</a>
|
||||
<button class="btn btn-outline-primary btn-wave" data-bs-toggle="modal" data-bs-target="#importAssetModal">
|
||||
<i class="ri-upload-2-line me-1"></i>Import CSV
|
||||
</button>
|
||||
<button class="btn btn-primary btn-wave" data-bs-toggle="modal" data-bs-target="#createAssetModal">
|
||||
<i class="ri-add-line me-1"></i>Tambah Aset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card custom-card">
|
||||
@@ -85,6 +93,14 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="scope" class="form-select">
|
||||
<option value="">Status Data</option>
|
||||
<option value="archived" @selected(($filters['scope'] ?? '')==='archived')>Arsip</option>
|
||||
<option value="trashed" @selected(($filters['scope'] ?? '')==='trashed')>Terhapus (soft delete)</option>
|
||||
<option value="all" @selected(($filters['scope'] ?? '')==='all')>Semua (termasuk arsip & terhapus)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-12 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-wave">Filter</button>
|
||||
<a href="{{ route('assets.index') }}" class="btn btn-outline-secondary">Reset</a>
|
||||
@@ -108,6 +124,61 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($importPreview))
|
||||
<div class="alert alert-info">
|
||||
<strong>Preview Import:</strong> Create {{ $importPreview['summary']['create'] ?? 0 }}, Update {{ $importPreview['summary']['update'] ?? 0 }}, Invalid {{ $importPreview['summary']['invalid'] ?? 0 }}.
|
||||
<form class="d-inline" action="{{ route('assets.import') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="import">
|
||||
@if(session()->has('_old_input.file'))
|
||||
{{-- file tidak bisa dipersist di old input; user perlu upload ulang untuk commit --}}
|
||||
@endif
|
||||
<button class="btn btn-sm btn-primary ms-2">Import Sekarang (unggah ulang file yang sama)</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Status</th>
|
||||
<th>Nama</th>
|
||||
<th>Kode</th>
|
||||
<th>SN</th>
|
||||
<th>RFID</th>
|
||||
<th>NFC</th>
|
||||
<th>Qty</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($importPreview['rows'] as $row)
|
||||
<tr>
|
||||
<td>{{ $row['line'] }}</td>
|
||||
<td>
|
||||
@if($row['status']==='invalid')
|
||||
<span class="badge bg-danger">Invalid</span>
|
||||
@elseif($row['status']==='update')
|
||||
<span class="badge bg-warning text-dark">Update</span>
|
||||
@else
|
||||
<span class="badge bg-success">Create</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['payload']['name'] ?? '-' }}</td>
|
||||
<td>{{ $row['payload']['code'] ?? '-' }}</td>
|
||||
<td>{{ $row['payload']['serial_number'] ?? '-' }}</td>
|
||||
<td>{{ $row['payload']['rfid_tag'] ?? '-' }}</td>
|
||||
<td>{{ $row['payload']['nfc_tag'] ?? '-' }}</td>
|
||||
<td>{{ $row['payload']['quantity'] ?? 1 }}</td>
|
||||
<td class="small text-muted">{{ implode(', ', $row['errors'] ?? []) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="text-muted small">Menampilkan maksimal 50 baris preview.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle text-nowrap">
|
||||
<thead class="table-light">
|
||||
@@ -158,6 +229,29 @@
|
||||
</button>
|
||||
<a href="{{ route('assets.show', $asset) }}" class="btn btn-sm btn-outline-success">Show</a>
|
||||
<a href="{{ route('assets.history', $asset) }}" class="btn btn-sm btn-outline-secondary">History</a>
|
||||
@if(!$asset->archived_at)
|
||||
<form action="{{ route('assets.archive', $asset) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
<button class="btn btn-sm btn-outline-warning" onclick="return confirm('Arsipkan aset ini?')">Archive</button>
|
||||
</form>
|
||||
@else
|
||||
<form action="{{ route('assets.unarchive', $asset) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
<button class="btn btn-sm btn-outline-info">Unarchive</button>
|
||||
</form>
|
||||
@endif
|
||||
@if($asset->trashed())
|
||||
<form action="{{ route('assets.restore', $asset->id) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
<button class="btn btn-sm btn-outline-primary">Restore</button>
|
||||
</form>
|
||||
@else
|
||||
<form action="{{ route('assets.destroy', $asset) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Hapus (soft delete)?')">Delete</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -219,6 +313,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Modal import --}}
|
||||
<div class="modal fade" id="importAssetModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import Aset (CSV/Excel)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="{{ route('assets.import') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<p class="text-muted">Unggah file CSV (Excel bisa ekspor ke CSV). Header yang didukung: <code>code,name,serial_number,status,category,location,rfid_tag,nfc_tag,is_consumable,quantity,available_quantity,is_pool</code>. Baris tanpa nama akan ditandai invalid.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">File</label>
|
||||
<input type="file" name="file" class="form-control" accept=".csv,.txt" required>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="action" id="importPreview" value="preview" checked>
|
||||
<label class="form-check-label" for="importPreview">Preview (tidak menyimpan)</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="action" id="importNow" value="import">
|
||||
<label class="form-check-label" for="importNow">Import sekarang (buat/update langsung)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Batal</button>
|
||||
<button type="submit" class="btn btn-primary">Proses</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
|
||||
@@ -93,9 +93,14 @@ Route::middleware('db.ready')->group(function () {
|
||||
Route::resource('vendor-contracts', VendorController::class)->only(['index', 'store', 'update', 'destroy'])
|
||||
->middleware('permission:assets.manage');
|
||||
|
||||
Route::get('assets/{asset}/history', [AssetController::class, 'history'])->name('assets.history')->middleware('permission:assets.view');
|
||||
Route::resource('assets', AssetController::class)->only(['index', 'store', 'update', 'show'])
|
||||
->middleware(['permission:assets.view|assets.manage']);
|
||||
Route::get('assets/{asset}/history', [AssetController::class, 'history'])->name('assets.history')->middleware('permission:assets.view');
|
||||
Route::resource('assets', AssetController::class)->only(['index', 'store', 'update', 'show', 'destroy'])
|
||||
->middleware(['permission:assets.view|assets.manage']);
|
||||
Route::post('assets/{asset}/archive', [AssetController::class, 'archive'])->name('assets.archive')->middleware('permission:assets.manage');
|
||||
Route::post('assets/{asset}/unarchive', [AssetController::class, 'unarchive'])->name('assets.unarchive')->middleware('permission:assets.manage');
|
||||
Route::post('assets/{asset}/restore', [AssetController::class, 'restore'])->name('assets.restore')->middleware('permission:assets.manage');
|
||||
Route::get('assets-export', [AssetController::class, 'exportCsv'])->name('assets.export')->middleware('permission:assets.view');
|
||||
Route::post('assets-import', [AssetController::class, 'import'])->name('assets.import')->middleware('permission:assets.manage');
|
||||
Route::post('assets/{asset}/photos', [AssetPhotoController::class, 'store'])->name('assets.photos.store')
|
||||
->middleware('permission:assets.manage');
|
||||
Route::delete('asset-photos/{asset_photo}', [AssetPhotoController::class, 'destroy'])->name('assets.photos.destroy')
|
||||
|
||||
Reference in New Issue
Block a user