diff --git a/app/Http/Controllers/ApprovalController.php b/app/Http/Controllers/ApprovalController.php new file mode 100644 index 0000000..9d231a6 --- /dev/null +++ b/app/Http/Controllers/ApprovalController.php @@ -0,0 +1,27 @@ +approvals->approve($approval, $request->input('notes')); + return back()->with('success', 'Permintaan disetujui.'); + } + + public function reject(Request $request, AssetApprovalRequest $approval): RedirectResponse + { + $this->approvals->reject($approval, $request->input('notes')); + return back()->with('success', 'Permintaan ditolak.'); + } +} diff --git a/app/Http/Controllers/AssetMaintenanceController.php b/app/Http/Controllers/AssetMaintenanceController.php index 28a2847..f637ba0 100644 --- a/app/Http/Controllers/AssetMaintenanceController.php +++ b/app/Http/Controllers/AssetMaintenanceController.php @@ -4,12 +4,17 @@ namespace App\Http\Controllers; use App\Models\Asset; use App\Models\AssetMaintenance; +use App\Services\Asset\AssetService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; class AssetMaintenanceController extends Controller { + public function __construct(private readonly AssetService $assets) + { + } + public function index(): View { $maintenances = AssetMaintenance::with('asset')->latest()->paginate(config('system.ui.table_page_size', 20)); @@ -26,11 +31,11 @@ class AssetMaintenanceController extends Controller 'description' => ['required', 'string', 'max:255'], 'vendor' => ['nullable', 'string', 'max:255'], 'cost' => ['nullable', 'numeric', 'min:0'], - 'status' => ['required', 'string'], 'notes' => ['nullable', 'string'], ]); - AssetMaintenance::create($data); + $asset = Asset::findOrFail($data['asset_id']); + $this->assets->maintenance($asset, $data); return back()->with('success', 'Data perawatan berhasil dicatat.'); } diff --git a/app/Models/AssetApprovalRequest.php b/app/Models/AssetApprovalRequest.php new file mode 100644 index 0000000..32f5db4 --- /dev/null +++ b/app/Models/AssetApprovalRequest.php @@ -0,0 +1,59 @@ + 'array', + 'approved_at' => 'datetime', + 'rejected_at' => 'datetime', + ]; + + public function approvable() + { + return $this->morphTo(); + } + + public function requester() + { + return $this->belongsTo(User::class, 'requested_by'); + } + + public function approver() + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function rejector() + { + return $this->belongsTo(User::class, 'rejected_by'); + } + + public function isPending(): bool + { + return $this->status === 'pending'; + } +} diff --git a/app/Models/AssetDisposal.php b/app/Models/AssetDisposal.php index 0b3bf83..4febd7a 100644 --- a/app/Models/AssetDisposal.php +++ b/app/Models/AssetDisposal.php @@ -22,11 +22,20 @@ class AssetDisposal extends Model 'reversed_at', 'reversed_by', 'reversed_notes', + 'status', + 'requested_by', + 'approved_by', + 'approved_at', + 'rejected_by', + 'rejected_at', + 'decision_notes', ]; protected $casts = [ 'disposed_at' => 'datetime', 'reversed_at' => 'datetime', + 'approved_at' => 'datetime', + 'rejected_at' => 'datetime', ]; public function asset() @@ -48,4 +57,9 @@ class AssetDisposal extends Model { return $this->belongsTo(Department::class, 'previous_department_id'); } + + public function approval() + { + return $this->morphOne(AssetApprovalRequest::class, 'approvable'); + } } diff --git a/app/Models/AssetMaintenance.php b/app/Models/AssetMaintenance.php index 41a901d..9f0e437 100644 --- a/app/Models/AssetMaintenance.php +++ b/app/Models/AssetMaintenance.php @@ -18,15 +18,28 @@ class AssetMaintenance extends Model 'cost', 'status', 'notes', + 'requested_by', + 'approved_by', + 'approved_at', + 'rejected_by', + 'rejected_at', + 'decision_notes', ]; protected $casts = [ 'performed_at' => 'datetime', 'cost' => 'decimal:2', + 'approved_at' => 'datetime', + 'rejected_at' => 'datetime', ]; public function asset() { return $this->belongsTo(Asset::class); } + + public function approval() + { + return $this->morphOne(AssetApprovalRequest::class, 'approvable'); + } } diff --git a/app/Models/AssetMovement.php b/app/Models/AssetMovement.php index 3e63680..91dc57a 100644 --- a/app/Models/AssetMovement.php +++ b/app/Models/AssetMovement.php @@ -21,10 +21,19 @@ class AssetMovement extends Model 'notes', 'moved_by', 'performed_at', + 'status', + 'requested_by', + 'approved_by', + 'approved_at', + 'rejected_by', + 'rejected_at', + 'decision_notes', ]; protected $casts = [ 'performed_at' => 'datetime', + 'approved_at' => 'datetime', + 'rejected_at' => 'datetime', ]; public function asset() @@ -61,4 +70,9 @@ class AssetMovement extends Model { return $this->belongsTo(AssetUser::class, 'to_asset_user_id'); } + + public function approval() + { + return $this->morphOne(AssetApprovalRequest::class, 'approvable'); + } } diff --git a/app/Notifications/ApprovalDecisionNotification.php b/app/Notifications/ApprovalDecisionNotification.php new file mode 100644 index 0000000..3b49b06 --- /dev/null +++ b/app/Notifications/ApprovalDecisionNotification.php @@ -0,0 +1,39 @@ +approved ? 'disetujui' : 'ditolak'; + $approvable = $this->approval->approvable; + $assetCode = method_exists($approvable, 'asset') ? optional($approvable->asset)->code : null; + + return (new MailMessage) + ->subject("Permintaan {$this->approval->type} {$status}") + ->line("Permintaan {$this->approval->type} Anda telah {$status}.") + ->lineIf($assetCode, "Asset: {$assetCode}") + ->lineIf($this->notes, "Catatan: {$this->notes}") + ->action('Buka Dashboard', url('/dashboard/index')); + } +} diff --git a/app/Notifications/ApprovalRequestedNotification.php b/app/Notifications/ApprovalRequestedNotification.php new file mode 100644 index 0000000..ed031fb --- /dev/null +++ b/app/Notifications/ApprovalRequestedNotification.php @@ -0,0 +1,37 @@ +approval->approvable; + $title = ucfirst($this->approval->type) . ' membutuhkan persetujuan'; + $assetCode = method_exists($approvable, 'asset') ? optional($approvable->asset)->code : null; + + return (new MailMessage) + ->subject($title) + ->line($title) + ->lineIf($assetCode, "Asset: {$assetCode}") + ->line('Diminta oleh: '.optional($this->approval->requester)->name) + ->line('Klik tombol di bawah untuk membuka dashboard dan meninjau.') + ->action('Buka Dashboard', url('/dashboard/index')); + } +} diff --git a/app/Services/Asset/AssetService.php b/app/Services/Asset/AssetService.php index 715e9bb..32bdd6e 100644 --- a/app/Services/Asset/AssetService.php +++ b/app/Services/Asset/AssetService.php @@ -10,6 +10,8 @@ use App\Models\AssetMovement; use App\Models\AssetDisposal; use App\Models\AssetAudit; use App\Models\AssetChangelog; +use App\Models\AssetMaintenance; +use App\Services\Logging\ActivityLogger; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -17,7 +19,10 @@ use SimpleSoftwareIO\QrCode\Facades\QrCode; class AssetService { - public function __construct(private readonly SystemSettingService $settings) + public function __construct( + private readonly SystemSettingService $settings, + private readonly ActivityLogger $logger + ) { } @@ -123,7 +128,9 @@ class AssetService */ public function move(Asset $asset, array $data): AssetMovement { - return DB::transaction(function () use ($asset, $data) { + $requireApproval = config('system.workflow.require_approval.movement', false); + + return DB::transaction(function () use ($asset, $data, $requireApproval) { $movement = AssetMovement::create([ 'asset_id' => $asset->id, 'from_location_id' => $asset->asset_location_id, @@ -135,13 +142,23 @@ class AssetService 'notes' => $data['notes'] ?? null, 'moved_by' => auth()->id(), 'performed_at' => $data['performed_at'] ?? now(), + 'status' => $requireApproval ? 'pending' : 'approved', + 'requested_by' => auth()->id(), ]); - $asset->update([ - 'asset_location_id' => $data['to_location_id'] ?? $asset->asset_location_id, - 'department_id' => $data['to_department_id'] ?? $asset->department_id, - 'asset_user_id' => $data['to_asset_user_id'] ?? $asset->asset_user_id, - ]); + if (!$requireApproval) { + $asset->update([ + 'asset_location_id' => $data['to_location_id'] ?? $asset->asset_location_id, + 'department_id' => $data['to_department_id'] ?? $asset->department_id, + 'asset_user_id' => $data['to_asset_user_id'] ?? $asset->asset_user_id, + ]); + $movement->update([ + 'approved_by' => auth()->id(), + 'approved_at' => now(), + ]); + } else { + app(\App\Services\Workflow\ApprovalService::class)->create('movement', $movement); + } $this->logHistory($asset, 'movement', 'Perpindahan aset', $movement->toArray()); @@ -154,23 +171,34 @@ class AssetService */ public function dispose(Asset $asset, array $data): AssetDisposal { - return DB::transaction(function () use ($asset, $data) { + $requireApproval = config('system.workflow.require_approval.disposal', false); + + return DB::transaction(function () use ($asset, $data, $requireApproval) { $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(), + 'disposed_by' => $requireApproval ? null : auth()->id(), + 'disposed_at' => $requireApproval ? null : ($data['disposed_at'] ?? now()), 'previous_status_id' => $asset->asset_status_id, 'previous_location_id' => $asset->asset_location_id, 'previous_department_id' => $asset->department_id, + 'status' => $requireApproval ? 'pending' : 'approved', + 'requested_by' => auth()->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, - ]); + if (!$requireApproval) { + $disposedStatus = AssetStatus::where('code', 'DISPOSED')->first(); + $asset->update([ + 'asset_status_id' => $disposedStatus?->id ?? $asset->asset_status_id, + ]); + $disposal->update([ + 'approved_by' => auth()->id(), + 'approved_at' => now(), + ]); + } else { + app(\App\Services\Workflow\ApprovalService::class)->create('disposal', $disposal); + } $this->logHistory($asset, 'disposal', 'Aset didispose', $disposal->toArray()); @@ -227,4 +255,35 @@ class AssetService return $audit; }); } + + /** + * Catat maintenance aset; jika butuh persetujuan, status pending. + */ + public function maintenance(Asset $asset, array $data): AssetMaintenance + { + $requireApproval = config('system.workflow.require_approval.maintenance', false); + + return DB::transaction(function () use ($asset, $data, $requireApproval) { + $maintenance = AssetMaintenance::create([ + 'asset_id' => $asset->id, + 'performed_at' => $data['performed_at'] ?? now(), + 'description' => $data['description'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'cost' => $data['cost'] ?? null, + 'status' => $requireApproval ? 'pending' : 'approved', + 'notes' => $data['notes'] ?? null, + 'requested_by' => auth()->id(), + 'approved_by' => $requireApproval ? null : auth()->id(), + 'approved_at' => $requireApproval ? null : now(), + ]); + + if ($requireApproval) { + app(\App\Services\Workflow\ApprovalService::class)->create('maintenance', $maintenance); + } + + $this->logHistory($asset, 'maintenance', 'Perawatan aset', $maintenance->toArray()); + + return $maintenance; + }); + } } diff --git a/app/Services/Workflow/ApprovalService.php b/app/Services/Workflow/ApprovalService.php new file mode 100644 index 0000000..6216f0d --- /dev/null +++ b/app/Services/Workflow/ApprovalService.php @@ -0,0 +1,153 @@ + $model->getKey(), + 'approvable_type' => get_class($model), + 'type' => $type, + 'status' => 'pending', + 'requested_by' => auth()->id(), + 'current_step' => 1, + 'required_steps' => config("system.workflow.required_steps.{$type}", 1), + ]); + $this->notifyApprovers($approval); + return $approval; + } + + public function approve(AssetApprovalRequest $approval, ?string $notes = null): void + { + DB::transaction(function () use ($approval, $notes) { + if ($approval->status !== 'pending') { + return; + } + $approval->update([ + 'status' => 'approved', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + 'decision_notes' => $notes, + 'current_step' => $approval->required_steps, + ]); + + $this->applyApprovedAction($approval); + + $this->logger->audit([ + 'action' => 'approval_approved', + 'model' => class_basename($approval->approvable_type), + 'model_id' => $approval->approvable_id, + 'changes' => ['approval_id' => $approval->id, 'notes' => $notes], + ]); + + $this->notifyDecision($approval, true, $notes); + }); + } + + public function reject(AssetApprovalRequest $approval, ?string $notes = null): void + { + if ($approval->status !== 'pending') { + return; + } + + $approval->update([ + 'status' => 'rejected', + 'rejected_by' => auth()->id(), + 'rejected_at' => now(), + 'decision_notes' => $notes, + ]); + + $this->logger->audit([ + 'action' => 'approval_rejected', + 'model' => class_basename($approval->approvable_type), + 'model_id' => $approval->approvable_id, + 'changes' => ['approval_id' => $approval->id, 'notes' => $notes], + ]); + + $this->notifyDecision($approval, false, $notes); + } + + private function applyApprovedAction(AssetApprovalRequest $approval): void + { + $approvable = $approval->approvable; + + if ($approvable instanceof AssetMovement) { + $asset = $approvable->asset; + $asset->update([ + 'asset_location_id' => $approvable->to_location_id ?? $asset->asset_location_id, + 'department_id' => $approvable->to_department_id ?? $asset->department_id, + 'asset_user_id' => $approvable->to_asset_user_id ?? $asset->asset_user_id, + ]); + $approvable->update([ + 'status' => 'approved', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + ]); + } + + if ($approvable instanceof AssetDisposal) { + $disposedStatus = optional($approvable->asset)->status?->code === 'DISPOSED' + ? $approvable->asset->asset_status_id + : \App\Models\AssetStatus::where('code', 'DISPOSED')->value('id'); + + $approvable->asset->update([ + 'asset_status_id' => $disposedStatus ?? $approvable->asset->asset_status_id, + ]); + $approvable->update([ + 'status' => 'approved', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + ]); + } + + if ($approvable instanceof AssetMaintenance) { + $approvable->update([ + 'status' => 'approved', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + ]); + } + } + + private function notifyApprovers(AssetApprovalRequest $approval): void + { + $approvers = Role::where('name', 'super-admin')->first()?->users ?? collect(); + if ($approvers->isEmpty()) { + return; + } + + Notification::send($approvers, new ApprovalRequestedNotification($approval)); + $this->logger->audit([ + 'action' => 'notify_approval_request', + 'changes' => ['approval_id' => $approval->id], + ]); + } + + private function notifyDecision(AssetApprovalRequest $approval, bool $approved, ?string $notes = null): void + { + if ($approval->requested_by) { + $requester = \App\Models\User::find($approval->requested_by); + if ($requester) { + $requester->notify(new ApprovalDecisionNotification($approval, $approved, $notes)); + } + } + } +} diff --git a/database/migrations/2025_12_11_000000_create_asset_approval_requests_table.php b/database/migrations/2025_12_11_000000_create_asset_approval_requests_table.php new file mode 100644 index 0000000..14866b5 --- /dev/null +++ b/database/migrations/2025_12_11_000000_create_asset_approval_requests_table.php @@ -0,0 +1,72 @@ +uuid('id')->primary(); + $table->uuidMorphs('approvable'); + $table->string('type'); // movement, disposal, maintenance + $table->string('status')->default('pending'); // pending, approved, rejected + $table->unsignedTinyInteger('current_step')->default(1); + $table->unsignedTinyInteger('required_steps')->default(1); + $table->uuid('requested_by')->nullable(); + $table->uuid('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->uuid('rejected_by')->nullable(); + $table->timestamp('rejected_at')->nullable(); + $table->text('decision_notes')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + + Schema::table('asset_movements', function (Blueprint $table) { + $table->string('status')->default('approved')->after('performed_at'); + $table->uuid('requested_by')->nullable()->after('status'); + $table->uuid('approved_by')->nullable()->after('requested_by'); + $table->timestamp('approved_at')->nullable()->after('approved_by'); + $table->uuid('rejected_by')->nullable()->after('approved_at'); + $table->timestamp('rejected_at')->nullable()->after('rejected_by'); + $table->text('decision_notes')->nullable()->after('rejected_at'); + }); + + Schema::table('asset_disposals', function (Blueprint $table) { + $table->string('status')->default('approved')->after('reversed_notes'); + $table->uuid('requested_by')->nullable()->after('status'); + $table->uuid('approved_by')->nullable()->after('requested_by'); + $table->timestamp('approved_at')->nullable()->after('approved_by'); + $table->uuid('rejected_by')->nullable()->after('approved_at'); + $table->timestamp('rejected_at')->nullable()->after('rejected_by'); + $table->text('decision_notes')->nullable()->after('rejected_at'); + }); + + Schema::table('asset_maintenances', function (Blueprint $table) { + $table->string('status')->default('approved')->after('notes'); + $table->uuid('requested_by')->nullable()->after('status'); + $table->uuid('approved_by')->nullable()->after('requested_by'); + $table->timestamp('approved_at')->nullable()->after('approved_by'); + $table->uuid('rejected_by')->nullable()->after('approved_at'); + $table->timestamp('rejected_at')->nullable()->after('rejected_by'); + $table->text('decision_notes')->nullable()->after('rejected_at'); + }); + } + + public function down(): void + { + Schema::table('asset_maintenances', function (Blueprint $table) { + $table->dropColumn(['status','requested_by','approved_by','approved_at','rejected_by','rejected_at','decision_notes']); + }); + Schema::table('asset_disposals', function (Blueprint $table) { + $table->dropColumn(['status','requested_by','approved_by','approved_at','rejected_by','rejected_at','decision_notes']); + }); + Schema::table('asset_movements', function (Blueprint $table) { + $table->dropColumn(['status','requested_by','approved_by','approved_at','rejected_by','rejected_at','decision_notes']); + }); + Schema::dropIfExists('asset_approval_requests'); + } +}; diff --git a/routes/web.php b/routes/web.php index 74d6faa..4471f79 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,6 +101,9 @@ Route::middleware(['auth','maintenance.readonly','session.timeout','audit.reques Route::resource('asset-maintenances', AssetMaintenanceController::class)->only(['index','store','update','destroy']) ->middleware('permission:maintenance.manage'); + + Route::post('approvals/{approval}/approve', [\App\Http\Controllers\ApprovalController::class, 'approve'])->name('approvals.approve')->middleware('permission:assets.manage'); + Route::post('approvals/{approval}/reject', [\App\Http\Controllers\ApprovalController::class, 'reject'])->name('approvals.reject')->middleware('permission:assets.manage'); }); Route::get('login', [AuthController::class, 'showLogin'])->name('login');