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 @@
+
+
+
+
+
+
+
+
+
+
+
+ | Waktu |
+ Dari |
+ Ke |
+ Catatan |
+
+
+
+ @forelse($asset->movements()->latest()->get() as $movement)
+
+ | {{ optional($movement->performed_at)->format('d/m/Y H:i') ?: '-' }} |
+
+ {{ $movement->fromLocation?->name ?: '-' }} / {{ $movement->fromDepartment?->name ?: '-' }}
+ |
+
+ {{ $movement->toLocation?->name ?: '-' }} / {{ $movement->toDepartment?->name ?: '-' }}
+ |
+ {{ $movement->notes ?: '-' }} |
+
+ @empty
+
+ | Belum ada movement. |
+
+ @endforelse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Waktu |
+ Alasan |
+ Catatan |
+ Aksi |
+
+
+
+ @forelse($asset->disposals()->latest()->get() as $disposal)
+
+ | {{ optional($disposal->disposed_at)->format('d/m/Y H:i') ?: '-' }} |
+ {{ $disposal->reason ?: '-' }} |
+ {{ $disposal->notes ?: '-' }} |
+
+ @if(!$disposal->reversed_at)
+
+ @else
+ Reversed
+ @endif
+ |
+
+ @empty
+
+ | Belum ada disposal. |
+
+ @endforelse
+
+
+
+
+
+
+
@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');