From 8c4b6a2786c835466d088662aa1cd271620271a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 26 Feb 2026 22:34:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[leave]=20=ED=9C=B4=EA=B0=80=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20Phase=201=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Leave, LeavePolicy, LeaveGrant 모델 생성 - LeaveBalance 헬퍼 메서드 추가 (useLeave, restoreLeave, canUse) - LeaveService 핵심 로직 (신청, 승인, 반려, 취소, 잔여연차, 통계) - API 컨트롤러 (목록, 등록, 승인/반려/취소, 잔여연차, 통계, CSV 내보내기) - 뷰 컨트롤러 + 라우트 등록 (web, api) - Blade 뷰 (index + 3개 탭 partials: table, balance, stats) --- .../Api/Admin/HR/LeaveController.php | 270 +++++++++ app/Http/Controllers/HR/LeaveController.php | 31 + app/Models/HR/Leave.php | 155 +++++ app/Models/HR/LeaveBalance.php | 56 ++ app/Models/HR/LeaveGrant.php | 88 +++ app/Models/HR/LeavePolicy.php | 74 +++ app/Services/HR/LeaveService.php | 431 ++++++++++++++ resources/views/hr/leaves/index.blade.php | 545 ++++++++++++++++++ .../hr/leaves/partials/balance.blade.php | 88 +++ .../views/hr/leaves/partials/stats.blade.php | 89 +++ .../views/hr/leaves/partials/table.blade.php | 136 +++++ routes/api.php | 13 + routes/web.php | 5 + 13 files changed, 1981 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/HR/LeaveController.php create mode 100644 app/Http/Controllers/HR/LeaveController.php create mode 100644 app/Models/HR/Leave.php create mode 100644 app/Models/HR/LeaveGrant.php create mode 100644 app/Models/HR/LeavePolicy.php create mode 100644 app/Services/HR/LeaveService.php create mode 100644 resources/views/hr/leaves/index.blade.php create mode 100644 resources/views/hr/leaves/partials/balance.blade.php create mode 100644 resources/views/hr/leaves/partials/stats.blade.php create mode 100644 resources/views/hr/leaves/partials/table.blade.php diff --git a/app/Http/Controllers/Api/Admin/HR/LeaveController.php b/app/Http/Controllers/Api/Admin/HR/LeaveController.php new file mode 100644 index 00000000..682cfb5e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/HR/LeaveController.php @@ -0,0 +1,270 @@ +leaveService->getLeaves( + $request->all(), + $request->integer('per_page', 20) + ); + + if ($request->header('HX-Request')) { + return response(view('hr.leaves.partials.table', compact('leaves'))); + } + + return response()->json([ + 'success' => true, + 'data' => $leaves->items(), + 'meta' => [ + 'current_page' => $leaves->currentPage(), + 'last_page' => $leaves->lastPage(), + 'per_page' => $leaves->perPage(), + 'total' => $leaves->total(), + ], + ]); + } + + /** + * 휴가 신청 등록 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + 'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'reason' => 'nullable|string|max:1000', + ]); + + try { + $leave = $this->leaveService->storeLeave($validated); + + return response()->json([ + 'success' => true, + 'message' => '휴가 신청이 등록되었습니다.', + 'data' => $leave, + ], 201); + } catch (\RuntimeException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 422); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '휴가 등록 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 승인 + */ + public function approve(Request $request, int $id): JsonResponse + { + try { + $leave = $this->leaveService->approve($id); + + if (! $leave) { + return response()->json([ + 'success' => false, + 'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '승인 처리되었습니다.', + 'data' => $leave, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '승인 처리 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 반려 + */ + public function reject(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'reject_reason' => 'nullable|string|max:1000', + ]); + + try { + $leave = $this->leaveService->reject($id, $validated['reject_reason'] ?? null); + + if (! $leave) { + return response()->json([ + 'success' => false, + 'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '반려 처리되었습니다.', + 'data' => $leave, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '반려 처리 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 취소 + */ + public function cancel(Request $request, int $id): JsonResponse + { + try { + $leave = $this->leaveService->cancel($id); + + if (! $leave) { + return response()->json([ + 'success' => false, + 'message' => '승인된 휴가 신청을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '취소 처리되었습니다. 연차가 복원되었습니다.', + 'data' => $leave, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '취소 처리 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 잔여연차 목록 (HTMX → HTML) + */ + public function balance(Request $request): JsonResponse|Response + { + $year = $request->integer('year', now()->year); + $balances = $this->leaveService->getBalanceSummary($year); + + if ($request->header('HX-Request')) { + return response(view('hr.leaves.partials.balance', compact('balances', 'year'))); + } + + return response()->json([ + 'success' => true, + 'data' => $balances, + ]); + } + + /** + * 개별 사원 잔여연차 (JSON) + */ + public function userBalance(Request $request, int $userId): JsonResponse + { + $year = $request->integer('year', now()->year); + $balance = $this->leaveService->getUserBalance($userId, $year); + + return response()->json([ + 'success' => true, + 'data' => $balance ? [ + 'total_days' => $balance->total_days, + 'used_days' => $balance->used_days, + 'remaining_days' => $balance->remaining, + ] : null, + ]); + } + + /** + * 사용현황 통계 (HTMX → HTML) + */ + public function stats(Request $request): JsonResponse|Response + { + $year = $request->integer('year', now()->year); + $stats = $this->leaveService->getUsageStats($year); + + if ($request->header('HX-Request')) { + return response(view('hr.leaves.partials.stats', compact('stats'))); + } + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * CSV 내보내기 + */ + public function export(Request $request): StreamedResponse + { + $filters = $request->all(); + $leaves = $this->leaveService->getExportData($filters); + + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="leaves_'.now()->format('Ymd_His').'.csv"', + ]; + + return response()->stream(function () use ($leaves) { + $output = fopen('php://output', 'w'); + fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF)); // BOM + + fputcsv($output, ['사원', '부서', '유형', '시작일', '종료일', '일수', '사유', '상태', '승인자', '신청일']); + + foreach ($leaves as $leave) { + $profile = $leave->user?->tenantProfiles?->first(); + fputcsv($output, [ + $leave->user?->name ?? '-', + $profile?->department?->name ?? '-', + $leave->type_label, + $leave->start_date->format('Y-m-d'), + $leave->end_date->format('Y-m-d'), + $leave->days, + $leave->reason ?? '-', + $leave->status_label, + $leave->approver?->name ?? '-', + $leave->created_at->format('Y-m-d H:i'), + ]); + } + + fclose($output); + }, 200, $headers); + } +} diff --git a/app/Http/Controllers/HR/LeaveController.php b/app/Http/Controllers/HR/LeaveController.php new file mode 100644 index 00000000..4f4264bd --- /dev/null +++ b/app/Http/Controllers/HR/LeaveController.php @@ -0,0 +1,31 @@ +leaveService->getActiveEmployees(); + $departments = $this->leaveService->getDepartments(); + $typeMap = Leave::TYPE_MAP; + $statusMap = Leave::STATUS_MAP; + + return view('hr.leaves.index', [ + 'employees' => $employees, + 'departments' => $departments, + 'typeMap' => $typeMap, + 'statusMap' => $statusMap, + ]); + } +} diff --git a/app/Models/HR/Leave.php b/app/Models/HR/Leave.php new file mode 100644 index 00000000..59c16146 --- /dev/null +++ b/app/Models/HR/Leave.php @@ -0,0 +1,155 @@ + 'int', + 'user_id' => 'int', + 'approved_by' => 'int', + 'created_by' => 'int', + 'updated_by' => 'int', + 'deleted_by' => 'int', + 'start_date' => 'date', + 'end_date' => 'date', + 'days' => 'float', + 'approved_at' => 'datetime', + ]; + + // ========================================================================= + // 상수 + // ========================================================================= + + public const TYPE_MAP = [ + 'annual' => '연차', + 'half_am' => '오전반차', + 'half_pm' => '오후반차', + 'sick' => '병가', + 'family' => '경조사', + 'maternity' => '출산', + 'parental' => '육아', + ]; + + public const STATUS_MAP = [ + 'pending' => '대기', + 'approved' => '승인', + 'rejected' => '반려', + 'cancelled' => '취소', + ]; + + public const STATUS_COLORS = [ + 'pending' => 'amber', + 'approved' => 'emerald', + 'rejected' => 'red', + 'cancelled' => 'gray', + ]; + + public const DEDUCTIBLE_TYPES = ['annual', 'half_am', 'half_pm']; + + // ========================================================================= + // 관계 + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Accessor + // ========================================================================= + + public function getTypeLabelAttribute(): string + { + return self::TYPE_MAP[$this->leave_type] ?? $this->leave_type; + } + + public function getStatusLabelAttribute(): string + { + return self::STATUS_MAP[$this->status] ?? $this->status; + } + + public function getStatusColorAttribute(): string + { + return self::STATUS_COLORS[$this->status] ?? 'gray'; + } + + public function getIsDeductibleAttribute(): bool + { + return in_array($this->leave_type, self::DEDUCTIBLE_TYPES); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where($this->table.'.tenant_id', $tenantId); + } + + return $query; + } + + public function scopeBetweenDates($query, string $startDate, string $endDate) + { + return $query->where(function ($q) use ($startDate, $endDate) { + $q->where('start_date', '<=', $endDate) + ->where('end_date', '>=', $startDate); + }); + } + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeForYear($query, int $year) + { + return $query->whereYear('start_date', $year); + } + + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } +} diff --git a/app/Models/HR/LeaveBalance.php b/app/Models/HR/LeaveBalance.php index d93c6825..3934e8a2 100644 --- a/app/Models/HR/LeaveBalance.php +++ b/app/Models/HR/LeaveBalance.php @@ -35,4 +35,60 @@ public function getRemainingAttribute(): float { return $this->remaining_days ?? ($this->total_days - $this->used_days); } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where($this->table.'.tenant_id', $tenantId); + } + + return $query; + } + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeForYear($query, int $year) + { + return $query->where('year', $year); + } + + public function scopeCurrentYear($query) + { + return $query->where('year', now()->year); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + public function useLeave(float $days): bool + { + if (! $this->canUse($days)) { + return false; + } + + $this->increment('used_days', $days); + $this->refresh(); + + return true; + } + + public function restoreLeave(float $days): void + { + $this->decrement('used_days', $days); + $this->refresh(); + } + + public function canUse(float $days): bool + { + return ($this->total_days - $this->used_days) >= $days; + } } diff --git a/app/Models/HR/LeaveGrant.php b/app/Models/HR/LeaveGrant.php new file mode 100644 index 00000000..30b6cb4c --- /dev/null +++ b/app/Models/HR/LeaveGrant.php @@ -0,0 +1,88 @@ + 'int', + 'user_id' => 'int', + 'created_by' => 'int', + 'grant_date' => 'date', + 'grant_days' => 'float', + ]; + + public const TYPE_MAP = [ + 'annual' => '정기연차', + 'monthly' => '월차', + 'reward' => '포상', + 'condolence' => '경조사', + 'other' => '기타', + ]; + + // ========================================================================= + // 관계 + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Accessor + // ========================================================================= + + public function getGrantTypeLabelAttribute(): string + { + return self::TYPE_MAP[$this->grant_type] ?? $this->grant_type; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where($this->table.'.tenant_id', $tenantId); + } + + return $query; + } + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeForYear($query, int $year) + { + return $query->whereYear('grant_date', $year); + } +} diff --git a/app/Models/HR/LeavePolicy.php b/app/Models/HR/LeavePolicy.php new file mode 100644 index 00000000..a3b8e325 --- /dev/null +++ b/app/Models/HR/LeavePolicy.php @@ -0,0 +1,74 @@ + 'int', + 'fiscal_start_month' => 'int', + 'fiscal_start_day' => 'int', + 'default_annual_leave' => 'int', + 'additional_leave_per_year' => 'int', + 'max_annual_leave' => 'int', + 'carry_over_enabled' => 'boolean', + 'carry_over_max_days' => 'int', + 'carry_over_expiry_months' => 'int', + ]; + + public const STANDARD_TYPE_MAP = [ + 'fiscal' => '회계연도', + 'hire' => '입사일', + ]; + + // ========================================================================= + // 관계 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + // ========================================================================= + // Accessor + // ========================================================================= + + public function getStandardTypeLabelAttribute(): string + { + return self::STANDARD_TYPE_MAP[$this->standard_type] ?? $this->standard_type; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where($this->table.'.tenant_id', $tenantId); + } + + return $query; + } +} diff --git a/app/Services/HR/LeaveService.php b/app/Services/HR/LeaveService.php new file mode 100644 index 00000000..d4a49198 --- /dev/null +++ b/app/Services/HR/LeaveService.php @@ -0,0 +1,431 @@ +with([ + 'user', + 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), + 'user.tenantProfiles.department', + 'approver', + ]) + ->forTenant($tenantId); + + if (! empty($filters['q'])) { + $search = $filters['q']; + $query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%")); + } + + if (! empty($filters['user_id'])) { + $query->where('user_id', $filters['user_id']); + } + + if (! empty($filters['department_id'])) { + $deptId = $filters['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } + + if (! empty($filters['leave_type'])) { + $query->where('leave_type', $filters['leave_type']); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['date_from']) && ! empty($filters['date_to'])) { + $query->betweenDates($filters['date_from'], $filters['date_to']); + } elseif (! empty($filters['date_from'])) { + $query->where('start_date', '>=', $filters['date_from']); + } elseif (! empty($filters['date_to'])) { + $query->where('end_date', '<=', $filters['date_to']); + } + + return $query + ->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected', 'cancelled')") + ->orderBy('created_at', 'desc') + ->paginate($perPage); + } + + /** + * 휴가 신청 등록 + */ + public function storeLeave(array $data): Leave + { + $tenantId = session('selected_tenant_id'); + $days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']); + + // 연차 차감 대상이면 잔여일수 검증 + if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) { + $balance = LeaveBalance::query() + ->forTenant($tenantId) + ->forUser($data['user_id']) + ->forYear(now()->year) + ->first(); + + if (! $balance || ! $balance->canUse($days)) { + throw new \RuntimeException('잔여 연차가 부족합니다.'); + } + } + + return Leave::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'leave_type' => $data['leave_type'], + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'days' => $days, + 'reason' => $data['reason'] ?? null, + 'status' => 'pending', + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + } + + /** + * 승인 → LeaveBalance 차감 → Attendance 자동 생성 + */ + public function approve(int $id): ?Leave + { + $tenantId = session('selected_tenant_id'); + + $leave = Leave::query() + ->forTenant($tenantId) + ->withStatus('pending') + ->find($id); + + if (! $leave) { + return null; + } + + return DB::transaction(function () use ($leave, $tenantId) { + $leave->update([ + 'status' => 'approved', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + 'updated_by' => auth()->id(), + ]); + + // 연차 차감 대상이면 LeaveBalance 차감 + if ($leave->is_deductible) { + $balance = LeaveBalance::query() + ->forTenant($tenantId) + ->forUser($leave->user_id) + ->forYear($leave->start_date->year) + ->first(); + + if ($balance) { + $balance->useLeave($leave->days); + } + } + + // 기간 내 영업일마다 Attendance(vacation) 자동 생성 + $this->createAttendanceRecords($leave, $tenantId); + + return $leave->fresh(['user', 'approver']); + }); + } + + /** + * 반려 + */ + public function reject(int $id, ?string $reason = null): ?Leave + { + $tenantId = session('selected_tenant_id'); + + $leave = Leave::query() + ->forTenant($tenantId) + ->withStatus('pending') + ->find($id); + + if (! $leave) { + return null; + } + + $leave->update([ + 'status' => 'rejected', + 'approved_by' => auth()->id(), + 'approved_at' => now(), + 'reject_reason' => $reason, + 'updated_by' => auth()->id(), + ]); + + return $leave->fresh(['user', 'approver']); + } + + /** + * 취소 → LeaveBalance 복원 → Attendance 삭제 + */ + public function cancel(int $id): ?Leave + { + $tenantId = session('selected_tenant_id'); + + $leave = Leave::query() + ->forTenant($tenantId) + ->withStatus('approved') + ->find($id); + + if (! $leave) { + return null; + } + + return DB::transaction(function () use ($leave, $tenantId) { + $leave->update([ + 'status' => 'cancelled', + 'updated_by' => auth()->id(), + ]); + + // 연차 차감 대상이면 LeaveBalance 복원 + if ($leave->is_deductible) { + $balance = LeaveBalance::query() + ->forTenant($tenantId) + ->forUser($leave->user_id) + ->forYear($leave->start_date->year) + ->first(); + + if ($balance) { + $balance->restoreLeave($leave->days); + } + } + + // 해당 기간 vacation Attendance soft delete + $this->deleteAttendanceRecords($leave, $tenantId); + + return $leave->fresh(['user']); + }); + } + + /** + * 전체 사원 잔여연차 요약 + */ + public function getBalanceSummary(?int $year = null): Collection + { + $tenantId = session('selected_tenant_id'); + $year = $year ?? now()->year; + + return LeaveBalance::query() + ->with([ + 'user', + 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), + 'user.tenantProfiles.department', + ]) + ->forTenant($tenantId) + ->forYear($year) + ->orderBy('user_id') + ->get(); + } + + /** + * 개별 사원 잔여연차 + */ + public function getUserBalance(int $userId, ?int $year = null): ?LeaveBalance + { + $tenantId = session('selected_tenant_id'); + $year = $year ?? now()->year; + + return LeaveBalance::query() + ->forTenant($tenantId) + ->forUser($userId) + ->forYear($year) + ->first(); + } + + /** + * 유형별/사원별 사용 통계 + */ + public function getUsageStats(?int $year = null): array + { + $tenantId = session('selected_tenant_id'); + $year = $year ?? now()->year; + + // 유형별 집계 + $byType = Leave::query() + ->forTenant($tenantId) + ->forYear($year) + ->withStatus('approved') + ->select('leave_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(days) as total_days')) + ->groupBy('leave_type') + ->get() + ->keyBy('leave_type'); + + // 사원별 유형 크로스 테이블 + $byUser = Leave::query() + ->forTenant($tenantId) + ->forYear($year) + ->withStatus('approved') + ->with([ + 'user', + 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), + 'user.tenantProfiles.department', + ]) + ->select('user_id', 'leave_type', DB::raw('SUM(days) as total_days')) + ->groupBy('user_id', 'leave_type') + ->get() + ->groupBy('user_id'); + + return [ + 'by_type' => $byType, + 'by_user' => $byUser, + 'year' => $year, + ]; + } + + /** + * 일수 자동 계산 (반차 = 0.5, 연차/기타 = 영업일수) + */ + public function calculateDays(string $type, string $start, string $end): float + { + if (in_array($type, ['half_am', 'half_pm'])) { + return 0.5; + } + + $period = CarbonPeriod::create($start, $end); + $businessDays = 0; + + foreach ($period as $date) { + if (! $date->isWeekend()) { + $businessDays++; + } + } + + return (float) $businessDays; + } + + /** + * CSV 내보내기용 데이터 + */ + public function getExportData(array $filters = []): Collection + { + $tenantId = session('selected_tenant_id'); + + $query = Leave::query() + ->with([ + 'user', + 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), + 'user.tenantProfiles.department', + 'approver', + ]) + ->forTenant($tenantId); + + if (! empty($filters['status'])) { + $query->withStatus($filters['status']); + } + + if (! empty($filters['leave_type'])) { + $query->where('leave_type', $filters['leave_type']); + } + + if (! empty($filters['date_from']) && ! empty($filters['date_to'])) { + $query->betweenDates($filters['date_from'], $filters['date_to']); + } + + return $query->orderBy('start_date', 'desc')->get(); + } + + /** + * 부서 목록 + */ + public function getDepartments(): \Illuminate\Database\Eloquent\Collection + { + $tenantId = session('selected_tenant_id'); + + return Department::query() + ->where('is_active', true) + ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'name', 'code']); + } + + /** + * 활성 사원 목록 (드롭다운용) + */ + public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection + { + $tenantId = session('selected_tenant_id'); + + return \App\Models\HR\Employee::query() + ->with('user:id,name') + ->forTenant($tenantId) + ->activeEmployees() + ->orderBy('display_name') + ->get(['id', 'user_id', 'display_name', 'department_id']); + } + + // ========================================================================= + // Private + // ========================================================================= + + /** + * 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성 + */ + private function createAttendanceRecords(Leave $leave, int $tenantId): void + { + $period = CarbonPeriod::create($leave->start_date, $leave->end_date); + + foreach ($period as $date) { + if ($date->isWeekend()) { + continue; + } + + Attendance::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $leave->user_id, + 'base_date' => $date->toDateString(), + ], + [ + 'status' => 'vacation', + 'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null, + 'updated_by' => auth()->id(), + ] + ); + } + } + + /** + * 취소 시 해당 기간 vacation Attendance soft delete + */ + private function deleteAttendanceRecords(Leave $leave, int $tenantId): void + { + Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $leave->user_id) + ->where('status', 'vacation') + ->whereBetween('base_date', [ + $leave->start_date->toDateString(), + $leave->end_date->toDateString(), + ]) + ->update(['deleted_by' => auth()->id()]); + + Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $leave->user_id) + ->where('status', 'vacation') + ->whereBetween('base_date', [ + $leave->start_date->toDateString(), + $leave->end_date->toDateString(), + ]) + ->delete(); + } +} diff --git a/resources/views/hr/leaves/index.blade.php b/resources/views/hr/leaves/index.blade.php new file mode 100644 index 00000000..50b9f2a3 --- /dev/null +++ b/resources/views/hr/leaves/index.blade.php @@ -0,0 +1,545 @@ +@extends('layouts.app') + +@section('title', '휴가관리') + +@section('content') +
+ {{-- 페이지 헤더 --}} +
+
+

휴가관리

+

휴가 신청, 승인/반려 및 연차 현황을 관리합니다.

+
+
+ + +
+
+ + {{-- 탭 네비게이션 --}} +
+ + + +
+ + {{-- 탭 콘텐츠 영역 --}} +
+ {{-- 휴가신청 탭 --}} +
+
+ {{-- 필터 --}} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + {{-- HTMX 테이블 영역 --}} +
+
+
+
+
+
+
+ + {{-- 잔여연차 탭 --}} + + + {{-- 사용현황 탭 --}} + +
+
+ +{{-- 휴가 신청 모달 --}} + + +{{-- 반려 사유 모달 --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/hr/leaves/partials/balance.blade.php b/resources/views/hr/leaves/partials/balance.blade.php new file mode 100644 index 00000000..611dbe45 --- /dev/null +++ b/resources/views/hr/leaves/partials/balance.blade.php @@ -0,0 +1,88 @@ +{{-- 잔여연차 현황 (HTMX로 로드) --}} + + + + + + + + + + + + + + + @forelse($balances as $balance) + @php + $profile = $balance->user?->tenantProfiles?->first(); + $department = $profile?->department; + $displayName = $profile?->display_name ?? $balance->user?->name ?? '-'; + $remaining = $balance->total_days - $balance->used_days; + $rate = $balance->total_days > 0 ? round(($balance->used_days / $balance->total_days) * 100) : 0; + $barColor = $rate >= 90 ? 'bg-red-500' : ($rate >= 70 ? 'bg-amber-500' : 'bg-blue-500'); + @endphp + + {{-- 사원 --}} + + + {{-- 부서 --}} + + + {{-- 부여 --}} + + + {{-- 사용 --}} + + + {{-- 잔여 --}} + + + {{-- 소진율 --}} + + + @empty + + + + @endforelse + +
사원부서부여사용잔여소진율
+
+
+ {{ mb_substr($displayName, 0, 1) }} +
+ {{ $displayName }} +
+
+ {{ $department?->name ?? '-' }} + + {{ $balance->total_days }}일 + + {{ $balance->used_days }}일 + + + {{ $remaining }}일 + + +
+
+
+
+ {{ $rate }}% +
+
+
+ + + +

{{ $year }}년 연차 정보가 없습니다.

+
+
+
+ +@if($balances->count()) +
+ 총 {{ $balances->count() }}명 | 부여합계: {{ $balances->sum('total_days') }}일 | 사용합계: {{ $balances->sum('used_days') }}일 | 잔여합계: {{ $balances->sum('total_days') - $balances->sum('used_days') }}일 +
+@endif diff --git a/resources/views/hr/leaves/partials/stats.blade.php b/resources/views/hr/leaves/partials/stats.blade.php new file mode 100644 index 00000000..21951a8e --- /dev/null +++ b/resources/views/hr/leaves/partials/stats.blade.php @@ -0,0 +1,89 @@ +{{-- 사용현황 통계 (HTMX로 로드) --}} +@php + use App\Models\HR\Leave; + + $byType = $stats['by_type'] ?? collect(); + $byUser = $stats['by_user'] ?? collect(); + $year = $stats['year'] ?? now()->year; + $typeMap = Leave::TYPE_MAP; + $totalApproved = $byType->sum('count'); + $totalDays = $byType->sum('total_days'); +@endphp + +
+ {{-- 유형별 집계 카드 --}} +
+

{{ $year }}년 유형별 현황

+
+ {{-- 전체 합계 --}} +
+
전체
+
{{ $totalApproved }}건
+
{{ $totalDays }}일
+
+ + @foreach($typeMap as $typeKey => $typeLabel) + @php + $typeData = $byType->get($typeKey); + $count = $typeData->count ?? 0; + $days = $typeData->total_days ?? 0; + @endphp +
+
{{ $typeLabel }}
+
{{ $count }}건
+
{{ $days }}일
+
+ @endforeach +
+
+ + {{-- 사원별 유형 크로스 테이블 --}} + @if($byUser->isNotEmpty()) +
+

사원별 사용현황 (승인된 휴가)

+ + + + + + + @foreach($typeMap as $typeLabel) + + @endforeach + + + + + @foreach($byUser as $userId => $userLeaves) + @php + $firstLeave = $userLeaves->first(); + $profile = $firstLeave?->user?->tenantProfiles?->first(); + $department = $profile?->department; + $displayName = $profile?->display_name ?? $firstLeave?->user?->name ?? '-'; + $userTypeMap = $userLeaves->keyBy('leave_type'); + $userTotal = $userLeaves->sum('total_days'); + @endphp + + + + @foreach($typeMap as $typeKey => $typeLabel) + + @endforeach + + + @endforeach + +
사원부서{{ $typeLabel }}합계
{{ $displayName }}{{ $department?->name ?? '-' }} + @php $val = $userTypeMap->get($typeKey)?->total_days ?? 0; @endphp + {{ $val > 0 ? $val : '-' }} + + {{ $userTotal }}일 +
+
+
+ @else +
+ {{ $year }}년 승인된 휴가가 없습니다. +
+ @endif +
diff --git a/resources/views/hr/leaves/partials/table.blade.php b/resources/views/hr/leaves/partials/table.blade.php new file mode 100644 index 00000000..01ea5ab0 --- /dev/null +++ b/resources/views/hr/leaves/partials/table.blade.php @@ -0,0 +1,136 @@ +{{-- 휴가 목록 테이블 (HTMX로 로드) --}} +@php + use App\Models\HR\Leave; +@endphp + + + + + + + + + + + + + + + + + + @forelse($leaves as $leave) + @php + $profile = $leave->user?->tenantProfiles?->first(); + $department = $profile?->department; + $displayName = $profile?->display_name ?? $leave->user?->name ?? '-'; + $color = Leave::STATUS_COLORS[$leave->status] ?? 'gray'; + $statusLabel = Leave::STATUS_MAP[$leave->status] ?? $leave->status; + $typeLabel = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type; + @endphp + + {{-- 사원 --}} + + + {{-- 부서 --}} + + + {{-- 유형 --}} + + + {{-- 기간 --}} + + + {{-- 일수 --}} + + + {{-- 사유 --}} + + + {{-- 상태 --}} + + + {{-- 처리자 --}} + + + {{-- 액션 --}} + + + @empty + + + + @endforelse + +
사원부서유형기간일수사유상태처리자액션
+
+
+ {{ mb_substr($displayName, 0, 1) }} +
+ {{ $displayName }} +
+
+ {{ $department?->name ?? '-' }} + + {{ $typeLabel }} + + {{ $leave->start_date->format('m-d') }} + @if($leave->start_date->ne($leave->end_date)) + ~ {{ $leave->end_date->format('m-d') }} + @endif + + {{ $leave->days == intval($leave->days) ? intval($leave->days) : $leave->days }}일 + + {{ $leave->reason ?? '-' }} + @if($leave->reject_reason) + + 반려: {{ Str::limit($leave->reject_reason, 20) }} + + @endif + + + {{ $statusLabel }} + + + @if($leave->approver) + {{ $leave->approver->name }} + {{ $leave->approved_at?->format('m-d H:i') }} + @else + - + @endif + + @if($leave->status === 'pending') +
+ + +
+ @elseif($leave->status === 'approved') + + @else + - + @endif +
+
+ + + +

휴가 신청 내역이 없습니다.

+
+
+
+ +{{-- 페이지네이션 --}} +@if($leaves->hasPages()) +
+ {{ $leaves->links() }} +
+@endif diff --git a/routes/api.php b/routes/api.php index df141363..e00728fd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1085,3 +1085,16 @@ Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'approve'])->name('approve'); Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'reject'])->name('reject'); }); + +// 휴가관리 API +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/leaves')->name('api.admin.hr.leaves.')->group(function () { + Route::get('/balance', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'balance'])->name('balance'); + Route::get('/balance/{userId}', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'userBalance'])->name('user-balance'); + Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'stats'])->name('stats'); + Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'export'])->name('export'); + Route::get('/', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'store'])->name('store'); + Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'approve'])->name('approve'); + Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'reject'])->name('reject'); + Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'cancel'])->name('cancel'); +}); diff --git a/routes/web.php b/routes/web.php index 5eb42a1f..dede1aee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -904,6 +904,11 @@ Route::prefix('attendances')->name('attendances.')->group(function () { Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index'); }); + + // 휴가관리 + Route::prefix('leaves')->name('leaves.')->group(function () { + Route::get('/', [\App\Http\Controllers\HR\LeaveController::class, 'index'])->name('index'); + }); }); /*