From 3c7cb120415cc63d59ceec2b5f4808bb253c3229 Mon Sep 17 00:00:00 2001 From: Arya Dwi Putra Date: Wed, 3 Dec 2025 08:34:45 +0700 Subject: [PATCH] Add soft delete, archive, and bulk import/export for assets --- app/Http/Controllers/AssetController.php | 181 ++++++++++++++++++ app/Models/Asset.php | 8 +- ..._add_archive_and_softdeletes_to_assets.php | 26 +++ resources/views/assets/index.blade.php | 134 ++++++++++++- routes/web.php | 11 +- 5 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2025_12_14_000000_add_archive_and_softdeletes_to_assets.php diff --git a/app/Http/Controllers/AssetController.php b/app/Http/Controllers/AssetController.php index 3f53f3e..623e00d 100644 --- a/app/Http/Controllers/AssetController.php +++ b/app/Http/Controllers/AssetController.php @@ -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([ diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 85f0859..4a1659c 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -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() diff --git a/database/migrations/2025_12_14_000000_add_archive_and_softdeletes_to_assets.php b/database/migrations/2025_12_14_000000_add_archive_and_softdeletes_to_assets.php new file mode 100644 index 0000000..aa5aa86 --- /dev/null +++ b/database/migrations/2025_12_14_000000_add_archive_and_softdeletes_to_assets.php @@ -0,0 +1,26 @@ +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']); + }); + } +}; diff --git a/resources/views/assets/index.blade.php b/resources/views/assets/index.blade.php index 65ae54e..c03af95 100644 --- a/resources/views/assets/index.blade.php +++ b/resources/views/assets/index.blade.php @@ -6,9 +6,17 @@

Data Aset

Kelola aset, QR token, dan metadata terkait.

- +
+ + Export CSV + + + +
@@ -85,6 +93,14 @@ @endforeach
+
+ +
Reset @@ -108,6 +124,61 @@
@endif + @if(!empty($importPreview)) +
+ Preview Import: Create {{ $importPreview['summary']['create'] ?? 0 }}, Update {{ $importPreview['summary']['update'] ?? 0 }}, Invalid {{ $importPreview['summary']['invalid'] ?? 0 }}. +
+ @csrf + + @if(session()->has('_old_input.file')) + {{-- file tidak bisa dipersist di old input; user perlu upload ulang untuk commit --}} + @endif + +
+
+
+ + + + + + + + + + + + + + + + @foreach($importPreview['rows'] as $row) + + + + + + + + + + + + @endforeach + +
#StatusNamaKodeSNRFIDNFCQtyCatatan
{{ $row['line'] }} + @if($row['status']==='invalid') + Invalid + @elseif($row['status']==='update') + Update + @else + Create + @endif + {{ $row['payload']['name'] ?? '-' }}{{ $row['payload']['code'] ?? '-' }}{{ $row['payload']['serial_number'] ?? '-' }}{{ $row['payload']['rfid_tag'] ?? '-' }}{{ $row['payload']['nfc_tag'] ?? '-' }}{{ $row['payload']['quantity'] ?? 1 }}{{ implode(', ', $row['errors'] ?? []) }}
+
Menampilkan maksimal 50 baris preview.
+
+ @endif +
@@ -158,6 +229,29 @@ ShowHistory + @if(!$asset->archived_at) + + @csrf + + + @else + + @csrf + + + @endif + @if($asset->trashed()) + + @csrf + + + @else + + @csrf + @method('DELETE') + + + @endif @@ -219,6 +313,40 @@ + + {{-- Modal import --}} + @endsection @section('scripts') diff --git a/routes/web.php b/routes/web.php index a1790e3..7ae26de 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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')