diff --git a/app/Http/Controllers/HR/LeavePromotionController.php b/app/Http/Controllers/HR/LeavePromotionController.php new file mode 100644 index 00000000..34dd0f15 --- /dev/null +++ b/app/Http/Controllers/HR/LeavePromotionController.php @@ -0,0 +1,70 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('hr.leave-promotions.index')); + } + + $year = (int) ($request->get('year', now()->year)); + + $candidates = $this->leaveService->getPromotionCandidates($year); + + $stats = [ + 'total' => $candidates->count(), + 'not_sent' => $candidates->where('promotion_status', 'not_sent')->count(), + 'first_sent' => $candidates->where('promotion_status', 'first_sent')->count(), + 'completed' => $candidates->where('promotion_status', 'completed')->count(), + ]; + + return view('hr.leave-promotions.index', [ + 'candidates' => $candidates, + 'stats' => $stats, + 'year' => $year, + ]); + } + + public function store(Request $request) + { + $request->validate([ + 'employee_ids' => 'required|array|min:1', + 'employee_ids.*' => 'integer', + 'notice_type' => 'required|in:1st,2nd', + 'deadline' => 'required_if:notice_type,1st|date', + 'designated_dates' => 'nullable|array', + 'designated_dates.*' => 'date', + ]); + + $year = (int) ($request->get('year', now()->year)); + $noticeType = $request->get('notice_type'); + $employeeIds = $request->get('employee_ids'); + + $result = $this->leaveService->sendPromotionNotices( + employeeIds: $employeeIds, + noticeType: $noticeType, + year: $year, + deadline: $request->get('deadline'), + designatedDates: $request->get('designated_dates', []), + ); + + return response()->json([ + 'success' => true, + 'message' => count($result['created']).'건의 연차촉진 통지서가 생성되었습니다.', + 'created' => $result['created'], + 'skipped' => $result['skipped'], + ]); + } +} diff --git a/app/Services/HR/LeaveService.php b/app/Services/HR/LeaveService.php index 7605c9c0..8e0b4fe4 100644 --- a/app/Services/HR/LeaveService.php +++ b/app/Services/HR/LeaveService.php @@ -803,6 +803,188 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection ->get(['id', 'user_id', 'display_name', 'department_id']); } + // ========================================================================= + // 연차촉진 관리 + // ========================================================================= + + /** + * 연차촉진 대상자 조회 — 잔여 연차가 있는 직원 + 통지 발송 현황 + */ + public function getPromotionCandidates(int $year): Collection + { + $tenantId = session('selected_tenant_id'); + + // 1. 연차 현황 조회 (잔여 연차 > 0인 직원) + $balances = $this->getBalanceSummary($year, 'name', 'asc', 'active'); + + // 2. 기존 발송된 통지서 조회 (approvals 테이블) + $promotionApprovals = Approval::query() + ->where('tenant_id', $tenantId) + ->whereHas('form', function ($q) { + $q->whereIn('code', ['leave_promotion_1st', 'leave_promotion_2nd']); + }) + ->with('form:id,code') + ->get(['id', 'form_id', 'content', 'status', 'created_at']); + + // 3. employee_id 기준으로 발송 현황 매핑 + $sentMap = []; + foreach ($promotionApprovals as $approval) { + $employeeId = $approval->content['employee_id'] ?? null; + $noticeYear = $approval->content['year'] ?? null; + if (! $employeeId || $noticeYear != $year) { + continue; + } + $code = $approval->form?->code; + if (! isset($sentMap[$employeeId])) { + $sentMap[$employeeId] = ['1st' => null, '2nd' => null]; + } + if ($code === 'leave_promotion_1st') { + $sentMap[$employeeId]['1st'] = $approval; + } elseif ($code === 'leave_promotion_2nd') { + $sentMap[$employeeId]['2nd'] = $approval; + } + } + + // 4. 잔여일수 > 0 필터링 + 통지 상태 결합 + return $balances + ->filter(fn ($b) => ($b->total_days - $b->used_days) > 0) + ->map(function ($balance) use ($sentMap) { + $userId = $balance->user_id; + $sent = $sentMap[$userId] ?? ['1st' => null, '2nd' => null]; + + $balance->first_notice = $sent['1st']; + $balance->second_notice = $sent['2nd']; + $balance->promotion_status = match (true) { + $sent['2nd'] !== null => 'completed', + $sent['1st'] !== null => 'first_sent', + default => 'not_sent', + }; + + return $balance; + }) + ->values(); + } + + /** + * 연차촉진 통지서 일괄 생성 (결재 문서로 생성 + 자동 상신) + */ + public function sendPromotionNotices(array $employeeIds, string $noticeType, int $year, ?string $deadline = null, array $designatedDates = []): array + { + $tenantId = session('selected_tenant_id'); + $formCode = $noticeType === '1st' ? 'leave_promotion_1st' : 'leave_promotion_2nd'; + + $form = ApprovalForm::where('code', $formCode) + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->first(); + + if (! $form) { + throw new \RuntimeException('연차촉진 통지서 결재 양식이 등록되지 않았습니다.'); + } + + // 기본 결재선 + $line = ApprovalLine::where('tenant_id', $tenantId) + ->where('is_default', true) + ->first(); + + if (! $line) { + throw new \RuntimeException('기본 결재선을 설정해주세요.'); + } + + // 대상 직원 + 잔여연차 조회 + $balances = LeaveBalance::query() + ->forTenant($tenantId) + ->forYear($year) + ->whereIn('user_id', $employeeIds) + ->get() + ->keyBy('user_id'); + + $employees = Employee::query() + ->with(['user:id,name', 'department:id,name']) + ->forTenant($tenantId) + ->whereIn('user_id', $employeeIds) + ->get() + ->keyBy('user_id'); + + // 이미 발송된 직원 확인 + $existingApprovals = Approval::query() + ->where('tenant_id', $tenantId) + ->where('form_id', $form->id) + ->get(['id', 'content']) + ->filter(function ($a) use ($year) { + return ($a->content['year'] ?? null) == $year; + }) + ->pluck('content.employee_id') + ->filter() + ->toArray(); + + $approvalService = app(\App\Services\ApprovalService::class); + $steps = collect($line->steps)->map(fn ($s) => [ + 'user_id' => $s['user_id'], + 'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval', + ])->toArray(); + + $created = []; + $skipped = []; + + foreach ($employeeIds as $userId) { + // 이미 발송된 직원 스킵 + if (in_array($userId, $existingApprovals)) { + $skipped[] = $userId; + + continue; + } + + $employee = $employees->get($userId); + $balance = $balances->get($userId); + + if (! $employee || ! $balance) { + $skipped[] = $userId; + + continue; + } + + $remaining = $balance->total_days - $balance->used_days; + $empName = $employee->display_name ?? $employee->user?->name ?? ''; + $deptName = $employee->department?->name ?? ''; + + // 통지서 content 구성 + $content = [ + 'employee_id' => $userId, + 'employee_name' => $empName, + 'department' => $deptName, + 'position' => $employee->position_key ?? '', + 'year' => $year, + 'total_days' => $balance->total_days, + 'used_days' => $balance->used_days, + 'remaining_days' => $remaining, + ]; + + if ($noticeType === '1st') { + $content['deadline'] = $deadline; + $title = "연차촉진 1차 통지 - {$empName} ({$year}년 잔여 {$remaining}일)"; + } else { + $content['designated_dates'] = $designatedDates; + $title = "연차촉진 2차 통지 - {$empName} ({$year}년 잔여 {$remaining}일)"; + } + + $approval = $approvalService->createApproval([ + 'form_id' => $form->id, + 'line_id' => $line->id, + 'title' => $title, + 'content' => $content, + 'body' => '', + 'is_urgent' => false, + 'steps' => $steps, + ]); + + $approvalService->submit($approval->id); + $created[] = $userId; + } + + return ['created' => $created, 'skipped' => $skipped]; + } + // ========================================================================= // Private // ========================================================================= diff --git a/resources/views/hr/leave-promotions/index.blade.php b/resources/views/hr/leave-promotions/index.blade.php new file mode 100644 index 00000000..f9b35633 --- /dev/null +++ b/resources/views/hr/leave-promotions/index.blade.php @@ -0,0 +1,385 @@ +@extends('layouts.app') + +@section('title', '연차촉진 관리') + +@section('content') +
+ {{-- 페이지 헤더 --}} +
+
+

연차촉진 관리

+

연차유급휴가 사용촉진 통지서를 일괄 발송하고 현황을 관리합니다.

+
+
+ {{-- 연도 선택 --}} + + + +
+
+ + {{-- 통계 카드 --}} +
+
+
전체 대상
+
{{ $stats['total'] }}명
+
+
+
미발송
+
{{ $stats['not_sent'] }}명
+
+
+
1차 발송
+
{{ $stats['first_sent'] }}명
+
+
+
2차 발송 (완료)
+
{{ $stats['completed'] }}명
+
+
+ + {{-- 대상자 테이블 --}} +
+
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + @forelse ($candidates as $candidate) + @php + $remaining = $candidate->total_days - $candidate->used_days; + $rate = $candidate->total_days > 0 ? round($candidate->used_days / $candidate->total_days * 100) : 0; + @endphp + + + + + + + + + + + + + @empty + + + + @endforelse + +
+ 선택 + 사원명부서발생일수사용일수잔여일수사용률상태1차 통지2차 통지
+ + {{ $candidate->employee?->display_name ?? '-' }}{{ $candidate->employee?->department?->name ?? '-' }}{{ $candidate->total_days }}일{{ $candidate->used_days }}일{{ $remaining }}일 +
+
+
+
+ {{ $rate }}% +
+
+ @switch($candidate->promotion_status) + @case('not_sent') + 미발송 + @break + @case('first_sent') + 1차 발송 + @break + @case('completed') + 완료 + @break + @endswitch + + @if ($candidate->first_notice) + + {{ $candidate->first_notice->created_at->format('m/d') }} + + @else + - + @endif + + @if ($candidate->second_notice) + + {{ $candidate->second_notice->created_at->format('m/d') }} + + @else + - + @endif +
+ 잔여 연차가 있는 대상자가 없습니다. +
+
+
+
+ +{{-- 일괄 발송 모달 --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/routes/web.php b/routes/web.php index 07e82b72..3b9e3520 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1056,6 +1056,12 @@ Route::get('/help', [\App\Http\Controllers\HR\LeaveController::class, 'helpGuide'])->name('help'); }); + // 연차촉진 관리 + Route::prefix('leave-promotions')->name('leave-promotions.')->group(function () { + Route::get('/', [\App\Http\Controllers\HR\LeavePromotionController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\HR\LeavePromotionController::class, 'store'])->name('store'); + }); + // 근태관리 통합 (신규) Route::get('/attendance', [\App\Http\Controllers\HR\AttendanceIntegratedController::class, 'index'])->name('attendance.index');