feat: [hr] 슈퍼관리자 근태/신청 삭제 및 영구삭제 기능 추가

- AttendanceService: forceDeleteAttendance 메서드 추가
- LeaveService: deleteLeave(모든 상태), forceDeleteLeave 메서드 추가
- Controller: force 파라미터 + 슈퍼관리자 권한 분기
- 근태 테이블: 슈퍼관리자에게 삭제/영구삭제 버튼 표시
- 신청 테이블: 슈퍼관리자에게 삭제/영구삭제 버튼 표시
This commit is contained in:
김보곤
2026-03-04 00:15:41 +09:00
parent 6b7eb29ebe
commit 9f45a82940
7 changed files with 216 additions and 6 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
/**
* 일괄 삭제
*/

View File

@@ -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;
});
}
/**
* 결재 승인에 의한 휴가/근태신청/사유서 자동 승인
*/

View File

@@ -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');
}

View File

@@ -15,6 +15,9 @@
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">퇴근</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">비고</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">GPS</th>
@if(auth()->user()->isSuperAdmin())
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">관리</th>
@endif
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
@@ -92,10 +95,29 @@ class="text-emerald-600 hover:text-emerald-800" title="GPS 정보 보기">
</span>
@endif
</td>
@if(auth()->user()->isSuperAdmin())
<td class="px-4 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-1">
<button type="button" onclick="deleteAttendance({{ $attendance->id }}, false)"
class="p-1 text-gray-400 hover:text-red-600 transition-colors" title="삭제">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
<button type="button" onclick="deleteAttendance({{ $attendance->id }}, true)"
class="p-1 text-gray-400 hover:text-red-700 transition-colors" title="영구삭제">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</button>
</div>
</td>
@endif
</tr>
@empty
<tr>
<td colspan="8" class="px-6 py-12 text-center">
<td colspan="{{ auth()->user()->isSuperAdmin() ? 9 : 8 }}" class="px-6 py-12 text-center">
<div class="flex flex-col items-center gap-2">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>

View File

@@ -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')
<span class="text-xs text-gray-400">-</span>
@endif
@if(auth()->user()->isSuperAdmin())
<button type="button" onclick="deleteLeave({{ $leave->id }})"
class="px-2.5 py-1 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded transition-colors"
title="삭제">
삭제
</button>
<button type="button" onclick="forceDeleteLeave({{ $leave->id }})"
class="px-2.5 py-1 text-xs font-medium text-red-800 bg-red-100 hover:bg-red-200 rounded transition-colors"
title="영구삭제 (복구 불가)">
영구삭제
</button>
@endif
</div>
</td>
</tr>