diff --git a/app/Http/Controllers/AssetController.php b/app/Http/Controllers/AssetController.php index 207b85d..aa42f2c 100644 --- a/app/Http/Controllers/AssetController.php +++ b/app/Http/Controllers/AssetController.php @@ -61,7 +61,12 @@ class AssetController extends Controller public function show(Asset $asset): View { - $asset->load(['status','class','category','unit','department','personInCharge','user','location','warranty']); + $asset->load([ + 'status','class','category','unit','department', + 'personInCharge','user','location','warranty', + 'movements.fromLocation','movements.toLocation','movements.fromDepartment','movements.toDepartment', + 'disposals', + ]); return view('assets.show', compact('asset')); } diff --git a/app/Http/Controllers/AssetTransactionController.php b/app/Http/Controllers/AssetTransactionController.php new file mode 100644 index 0000000..83e084a --- /dev/null +++ b/app/Http/Controllers/AssetTransactionController.php @@ -0,0 +1,54 @@ +validate([ + 'to_location_id' => ['nullable', 'uuid', 'exists:asset_locations,id'], + 'to_department_id' => ['nullable', 'uuid', 'exists:departments,id'], + 'notes' => ['nullable', 'string'], + 'performed_at' => ['nullable', 'date'], + ]); + + $this->assets->move($asset, $data); + + return back()->with('success', 'Movement aset berhasil dicatat.'); + } + + public function storeDisposal(Request $request, Asset $asset): RedirectResponse + { + $data = $request->validate([ + 'reason' => ['required', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'disposed_at' => ['nullable', 'date'], + ]); + + $this->assets->dispose($asset, $data); + + return back()->with('success', 'Disposal aset berhasil dicatat.'); + } + + public function reverseDisposal(Request $request, AssetDisposal $disposal): RedirectResponse + { + $data = $request->validate([ + 'notes' => ['nullable', 'string'], + ]); + + $this->assets->reverseDisposal($disposal, $data['notes'] ?? null); + + return back()->with('success', 'Reverse disposal berhasil.'); + } +} diff --git a/app/Models/Asset.php b/app/Models/Asset.php index b0fdba5..ff0b48c 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -87,4 +87,14 @@ class Asset extends Model { return $this->hasMany(AssetHistory::class); } + + public function movements() + { + return $this->hasMany(AssetMovement::class); + } + + public function disposals() + { + return $this->hasMany(AssetDisposal::class); + } } diff --git a/app/Models/AssetDisposal.php b/app/Models/AssetDisposal.php new file mode 100644 index 0000000..0b3bf83 --- /dev/null +++ b/app/Models/AssetDisposal.php @@ -0,0 +1,51 @@ + 'datetime', + 'reversed_at' => 'datetime', + ]; + + public function asset() + { + return $this->belongsTo(Asset::class); + } + + public function previousStatus() + { + return $this->belongsTo(AssetStatus::class, 'previous_status_id'); + } + + public function previousLocation() + { + return $this->belongsTo(AssetLocation::class, 'previous_location_id'); + } + + public function previousDepartment() + { + return $this->belongsTo(Department::class, 'previous_department_id'); + } +} diff --git a/app/Models/AssetMovement.php b/app/Models/AssetMovement.php new file mode 100644 index 0000000..06c9ee2 --- /dev/null +++ b/app/Models/AssetMovement.php @@ -0,0 +1,52 @@ + 'datetime', + ]; + + public function asset() + { + return $this->belongsTo(Asset::class); + } + + public function fromLocation() + { + return $this->belongsTo(AssetLocation::class, 'from_location_id'); + } + + public function toLocation() + { + return $this->belongsTo(AssetLocation::class, 'to_location_id'); + } + + public function fromDepartment() + { + return $this->belongsTo(Department::class, 'from_department_id'); + } + + public function toDepartment() + { + return $this->belongsTo(Department::class, 'to_department_id'); + } +} diff --git a/app/Services/Asset/AssetService.php b/app/Services/Asset/AssetService.php index 500b1de..307b443 100644 --- a/app/Services/Asset/AssetService.php +++ b/app/Services/Asset/AssetService.php @@ -5,6 +5,9 @@ namespace App\Services\Asset; use App\Models\Asset; use App\Models\AssetHistory; use App\Services\System\SystemSettingService; +use App\Models\AssetStatus; +use App\Models\AssetMovement; +use App\Models\AssetDisposal; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -71,7 +74,7 @@ class AssetService return sprintf('%s-%05d', $prefix, $nextNumber); } - private function logHistory(Asset $asset, string $action, string $description, array $payload = []): void + public function logHistory(Asset $asset, string $action, string $description, array $payload = []): void { AssetHistory::create([ 'asset_id' => $asset->id, @@ -96,4 +99,90 @@ class AssetService $asset->updateQuietly(['qr_path' => $path]); } + + /** + * Catat movement aset lalu update lokasi/departemen. + */ + public function move(Asset $asset, array $data): AssetMovement + { + return DB::transaction(function () use ($asset, $data) { + $movement = AssetMovement::create([ + 'asset_id' => $asset->id, + 'from_location_id' => $asset->asset_location_id, + 'to_location_id' => $data['to_location_id'] ?? null, + 'from_department_id' => $asset->department_id, + 'to_department_id' => $data['to_department_id'] ?? null, + 'notes' => $data['notes'] ?? null, + 'moved_by' => auth()->id(), + 'performed_at' => $data['performed_at'] ?? now(), + ]); + + $asset->update([ + 'asset_location_id' => $data['to_location_id'] ?? $asset->asset_location_id, + 'department_id' => $data['to_department_id'] ?? $asset->department_id, + ]); + + $this->logHistory($asset, 'movement', 'Perpindahan aset', $movement->toArray()); + + return $movement; + }); + } + + /** + * Catat disposal dan ubah status aset (jika ada status DISPOSED). + */ + public function dispose(Asset $asset, array $data): AssetDisposal + { + return DB::transaction(function () use ($asset, $data) { + $disposal = AssetDisposal::create([ + 'asset_id' => $asset->id, + 'reason' => $data['reason'] ?? null, + 'notes' => $data['notes'] ?? null, + 'disposed_by' => auth()->id(), + 'disposed_at' => $data['disposed_at'] ?? now(), + 'previous_status_id' => $asset->asset_status_id, + 'previous_location_id' => $asset->asset_location_id, + 'previous_department_id' => $asset->department_id, + ]); + + // Ubah status aset ke DISPOSED bila tersedia + $disposedStatus = AssetStatus::where('code', 'DISPOSED')->first(); + $asset->update([ + 'asset_status_id' => $disposedStatus?->id ?? $asset->asset_status_id, + ]); + + $this->logHistory($asset, 'disposal', 'Aset didispose', $disposal->toArray()); + + return $disposal; + }); + } + + /** + * Reverse disposal dan kembalikan status/lokasi/departemen sebelumnya. + */ + public function reverseDisposal(AssetDisposal $disposal, ?string $notes = null): Asset + { + return DB::transaction(function () use ($disposal, $notes) { + $asset = $disposal->asset; + + $asset->update([ + 'asset_status_id' => $disposal->previous_status_id, + 'asset_location_id' => $disposal->previous_location_id, + 'department_id' => $disposal->previous_department_id, + ]); + + $disposal->update([ + 'reversed_at' => now(), + 'reversed_by' => auth()->id(), + 'reversed_notes' => $notes, + ]); + + $this->logHistory($asset, 'reverse_disposal', 'Aset di-restore dari disposal', [ + 'disposal_id' => $disposal->id, + 'notes' => $notes, + ]); + + return $asset; + }); + } } diff --git a/database/migrations/2025_12_04_000000_create_asset_transactions.php b/database/migrations/2025_12_04_000000_create_asset_transactions.php new file mode 100644 index 0000000..e55e690 --- /dev/null +++ b/database/migrations/2025_12_04_000000_create_asset_transactions.php @@ -0,0 +1,46 @@ +uuid('id')->primary(); + $table->foreignUuid('asset_id')->constrained('assets')->cascadeOnDelete(); + $table->foreignUuid('from_location_id')->nullable()->constrained('asset_locations')->nullOnDelete(); + $table->foreignUuid('to_location_id')->nullable()->constrained('asset_locations')->nullOnDelete(); + $table->foreignUuid('from_department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->foreignUuid('to_department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->uuid('moved_by')->nullable(); + $table->timestamp('performed_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + Schema::create('asset_disposals', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('asset_id')->constrained('assets')->cascadeOnDelete(); + $table->string('reason')->nullable(); + $table->text('notes')->nullable(); + $table->uuid('disposed_by')->nullable(); + $table->timestamp('disposed_at')->nullable(); + $table->foreignUuid('previous_status_id')->nullable()->constrained('asset_statuses')->nullOnDelete(); + $table->foreignUuid('previous_location_id')->nullable()->constrained('asset_locations')->nullOnDelete(); + $table->foreignUuid('previous_department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->timestamp('reversed_at')->nullable(); + $table->uuid('reversed_by')->nullable(); + $table->text('reversed_notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('asset_disposals'); + Schema::dropIfExists('asset_movements'); + } +}; diff --git a/database/seeders/AssetTransactionSeeder.php b/database/seeders/AssetTransactionSeeder.php new file mode 100644 index 0000000..c061296 --- /dev/null +++ b/database/seeders/AssetTransactionSeeder.php @@ -0,0 +1,39 @@ +get(); + $locations = AssetLocation::take(2)->pluck('id')->toArray(); + $departments = Department::take(2)->pluck('id')->toArray(); + + foreach ($assets as $asset) { + if (!empty($locations)) { + $service->move($asset, [ + 'to_location_id' => $locations[array_rand($locations)], + 'to_department_id' => $departments[array_rand($departments)] ?? null, + 'notes' => 'Seed movement', + ]); + } + } + + // Dispose satu aset untuk contoh reverse + if ($assets->isNotEmpty()) { + $disposal = $service->dispose($assets->first(), [ + 'reason' => 'Rusak berat', + 'notes' => 'Sample disposal', + ]); + $service->reverseDisposal($disposal, 'Sample reverse'); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 8cad9f6..7bf21f4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder RbacSeeder::class, MasterDataSeeder::class, AssetSeeder::class, + AssetTransactionSeeder::class, ]); } } diff --git a/resources/views/assets/show.blade.php b/resources/views/assets/show.blade.php index f95d092..24da211 100644 --- a/resources/views/assets/show.blade.php +++ b/resources/views/assets/show.blade.php @@ -59,4 +59,137 @@ + +
+
+
+
+
Movement
+
+
+
+ @csrf +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + @forelse($asset->movements()->latest()->get() as $movement) + + + + + + + @empty + + + + @endforelse + +
WaktuDariKeCatatan
{{ optional($movement->performed_at)->format('d/m/Y H:i') ?: '-' }} + {{ $movement->fromLocation?->name ?: '-' }} / {{ $movement->fromDepartment?->name ?: '-' }} + + {{ $movement->toLocation?->name ?: '-' }} / {{ $movement->toDepartment?->name ?: '-' }} + {{ $movement->notes ?: '-' }}
Belum ada movement.
+
+
+
+
+
+
+
+
Disposal
+
+
+
+ @csrf +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + @forelse($asset->disposals()->latest()->get() as $disposal) + + + + + + + @empty + + + + @endforelse + +
WaktuAlasanCatatanAksi
{{ optional($disposal->disposed_at)->format('d/m/Y H:i') ?: '-' }}{{ $disposal->reason ?: '-' }}{{ $disposal->notes ?: '-' }} + @if(!$disposal->reversed_at) +
+ @csrf + + +
+ @else + Reversed + @endif +
Belum ada disposal.
+
+
+
+
+
@endsection diff --git a/routes/web.php b/routes/web.php index 904011a..a7eff43 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\Master\AssetCategoryController; use App\Http\Controllers\Master\AssetLocationController; use App\Http\Controllers\Master\WarrantyController; use App\Http\Controllers\AssetController; +use App\Http\Controllers\AssetTransactionController; use App\Http\Controllers\Controller; Route::get('/', function () { @@ -48,3 +49,6 @@ Route::resource('asset-locations', AssetLocationController::class)->only(['index Route::resource('warranties', WarrantyController::class)->only(['index', 'store', 'update', 'destroy']); Route::get('assets/{asset}/history', [AssetController::class, 'history'])->name('assets.history'); Route::resource('assets', AssetController::class)->only(['index', 'store', 'update', 'show']); +Route::post('assets/{asset}/movements', [AssetTransactionController::class, 'storeMovement'])->name('assets.movements.store'); +Route::post('assets/{asset}/disposals', [AssetTransactionController::class, 'storeDisposal'])->name('assets.disposals.store'); +Route::post('asset-disposals/{disposal}/reverse', [AssetTransactionController::class, 'reverseDisposal'])->name('assets.disposals.reverse');