diff --git a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php index b7d68c42..09dd9f31 100644 --- a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php @@ -334,7 +334,18 @@ public function update(Request $request, int $id): JsonResponse public function destroy(Request $request, int $id): JsonResponse|Response { try { - $result = $this->attendanceService->deleteAttendance($id); + $force = $request->boolean('force'); + + if ($force) { + if (! auth()->user()->isSuperAdmin()) { + return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403); + } + $result = $this->attendanceService->forceDeleteAttendance($id); + $message = '근태가 영구 삭제되었습니다.'; + } else { + $result = $this->attendanceService->deleteAttendance($id); + $message = '근태가 삭제되었습니다.'; + } if (! $result) { return response()->json([ @@ -354,7 +365,7 @@ public function destroy(Request $request, int $id): JsonResponse|Response return response()->json([ 'success' => true, - 'message' => '근태가 삭제되었습니다.', + 'message' => $message, ]); } catch (\Throwable $e) { report($e); diff --git a/app/Http/Controllers/Api/Admin/HR/LeaveController.php b/app/Http/Controllers/Api/Admin/HR/LeaveController.php index b1e90ad3..6218469f 100644 --- a/app/Http/Controllers/Api/Admin/HR/LeaveController.php +++ b/app/Http/Controllers/Api/Admin/HR/LeaveController.php @@ -177,12 +177,27 @@ public function cancel(Request $request, int $id): JsonResponse } /** - * pending 상태 신청 삭제 + * 신청 삭제 (일반: pending만 / 슈퍼관리자: 모든 상태, force 영구삭제) */ public function destroy(Request $request, int $id): JsonResponse { try { - $leave = $this->leaveService->deletePendingLeave($id); + $force = $request->boolean('force'); + $isSuperAdmin = auth()->user()->isSuperAdmin(); + + if ($force) { + if (! $isSuperAdmin) { + return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403); + } + $leave = $this->leaveService->forceDeleteLeave($id); + $message = '신청이 영구 삭제되었습니다.'; + } elseif ($isSuperAdmin) { + $leave = $this->leaveService->deleteLeave($id); + $message = '신청이 삭제되었습니다.'; + } else { + $leave = $this->leaveService->deletePendingLeave($id); + $message = '신청이 삭제되었습니다.'; + } if (! $leave) { return response()->json([ @@ -193,7 +208,7 @@ public function destroy(Request $request, int $id): JsonResponse return response()->json([ 'success' => true, - 'message' => '신청이 삭제되었습니다.', + 'message' => $message, ]); } catch (\Throwable $e) { report($e); diff --git a/app/Services/HR/AttendanceService.php b/app/Services/HR/AttendanceService.php index dc832eac..1aace6bf 100644 --- a/app/Services/HR/AttendanceService.php +++ b/app/Services/HR/AttendanceService.php @@ -288,6 +288,26 @@ public function deleteAttendance(int $id): bool return true; } + /** + * 근태 영구삭제 (슈퍼관리자 전용) + */ + public function forceDeleteAttendance(int $id): bool + { + $tenantId = session('selected_tenant_id'); + + $attendance = Attendance::withTrashed() + ->forTenant($tenantId) + ->find($id); + + if (! $attendance) { + return false; + } + + $attendance->forceDelete(); + + return true; + } + /** * 일괄 삭제 */ diff --git a/app/Services/HR/LeaveService.php b/app/Services/HR/LeaveService.php index 8ae5a7c0..d40299e5 100644 --- a/app/Services/HR/LeaveService.php +++ b/app/Services/HR/LeaveService.php @@ -275,6 +275,89 @@ public function deletePendingLeave(int $id): ?Leave }); } + /** + * 휴가/신청 삭제 (슈퍼관리자 전용 — 모든 상태 허용) + */ + public function deleteLeave(int $id): ?Leave + { + $tenantId = session('selected_tenant_id'); + + $leave = Leave::query() + ->forTenant($tenantId) + ->find($id); + + if (! $leave) { + return null; + } + + return DB::transaction(function () use ($leave, $tenantId) { + // 연결된 결재 취소 시도 + if ($leave->approval_id) { + $approval = Approval::find($leave->approval_id); + if ($approval && in_array($approval->status, ['draft', 'pending'])) { + try { + app(ApprovalService::class)->cancel($approval->id); + } catch (\Throwable $e) { + report($e); + } + } + } + + // 승인된 연차 차감 복원 + if ($leave->status === 'approved' && $leave->is_deductible) { + $balance = LeaveBalance::query() + ->forTenant($tenantId) + ->forUser($leave->user_id) + ->forYear($leave->start_date->year) + ->first(); + $balance?->restoreLeave($leave->days); + } + + $leave->update(['deleted_by' => auth()->id()]); + $leave->delete(); + + return $leave; + }); + } + + /** + * 휴가/신청 영구삭제 (슈퍼관리자 전용) + */ + public function forceDeleteLeave(int $id): ?Leave + { + $tenantId = session('selected_tenant_id'); + + $leave = Leave::withTrashed() + ->forTenant($tenantId) + ->find($id); + + if (! $leave) { + return null; + } + + return DB::transaction(function () use ($leave, $tenantId) { + // 승인된 연차 차감 복원 (아직 soft-deleted 아닌 경우) + if ($leave->status === 'approved' && $leave->is_deductible && ! $leave->trashed()) { + $balance = LeaveBalance::query() + ->forTenant($tenantId) + ->forUser($leave->user_id) + ->forYear($leave->start_date->year) + ->first(); + $balance?->restoreLeave($leave->days); + } + + // 연결된 결재 정리 + if ($leave->approval_id) { + $approval = Approval::withTrashed()->find($leave->approval_id); + $approval?->forceDelete(); + } + + $leave->forceDelete(); + + return $leave; + }); + } + /** * 결재 승인에 의한 휴가/근태신청/사유서 자동 승인 */ diff --git a/resources/views/hr/attendance-integrated/index.blade.php b/resources/views/hr/attendance-integrated/index.blade.php index 06537fae..92f3d093 100644 --- a/resources/views/hr/attendance-integrated/index.blade.php +++ b/resources/views/hr/attendance-integrated/index.blade.php @@ -408,6 +408,52 @@ function deleteLeave(id) { if (data.success) { showToast(data.message, 'success'); loadRequests(); + if (tabLoaded.balance) loadBalance(); + } else { + showToast(data.message || '삭제 실패', 'error'); + } + }); +} + +function forceDeleteLeave(id) { + if (!confirm('영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없으며, 연결된 결재 기록도 함께 삭제됩니다.')) return; + + fetch('/api/admin/hr/leaves/' + id + '?force=1', { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + }, + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + loadRequests(); + if (tabLoaded.balance) loadBalance(); + } else { + showToast(data.message || '영구 삭제 실패', 'error'); + } + }); +} + +function deleteAttendance(id, force) { + const msg = force + ? '영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.' + : '근태 기록을 삭제하시겠습니까?'; + if (!confirm(msg)) return; + + const url = '/api/admin/hr/attendances/' + id + (force ? '?force=1' : ''); + fetch(url, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + }, + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + loadAttendances(); } else { showToast(data.message || '삭제 실패', 'error'); } diff --git a/resources/views/hr/attendances/partials/table.blade.php b/resources/views/hr/attendances/partials/table.blade.php index 03b05cef..165c0f29 100644 --- a/resources/views/hr/attendances/partials/table.blade.php +++ b/resources/views/hr/attendances/partials/table.blade.php @@ -15,6 +15,9 @@ 퇴근 비고 GPS + @if(auth()->user()->isSuperAdmin()) + 관리 + @endif @@ -92,10 +95,29 @@ class="text-emerald-600 hover:text-emerald-800" title="GPS 정보 보기"> @endif + + @if(auth()->user()->isSuperAdmin()) + +
+ + +
+ + @endif @empty - +
diff --git a/resources/views/hr/leaves/partials/table.blade.php b/resources/views/hr/leaves/partials/table.blade.php index 9e5f4c9e..b7a799a7 100644 --- a/resources/views/hr/leaves/partials/table.blade.php +++ b/resources/views/hr/leaves/partials/table.blade.php @@ -151,6 +151,19 @@ class="px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 r @elseif(!$leave->approval_id && $leave->status !== 'pending' && $leave->status !== 'approved') - @endif + + @if(auth()->user()->isSuperAdmin()) + + + @endif