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') +
휴가 신청, 승인/반려 및 연차 현황을 관리합니다.
+| 사원 | +부서 | +부여 | +사용 | +잔여 | +소진율 | +
|---|---|---|---|---|---|
|
+
+
+
+ {{ mb_substr($displayName, 0, 1) }}
+
+ {{ $displayName }}
+ |
+
+ {{-- 부서 --}}
+ + {{ $department?->name ?? '-' }} + | + + {{-- 부여 --}} ++ {{ $balance->total_days }}일 + | + + {{-- 사용 --}} ++ {{ $balance->used_days }}일 + | + + {{-- 잔여 --}} ++ + {{ $remaining }}일 + + | + + {{-- 소진율 --}} +
+
+
+
+
+
+ {{ $rate }}%
+ |
+
|
+
+
+
+ {{ $year }}년 연차 정보가 없습니다. + |
+ |||||
| 사원 | +부서 | + @foreach($typeMap as $typeLabel) +{{ $typeLabel }} | + @endforeach +합계 | +
|---|---|---|---|
| {{ $displayName }} | +{{ $department?->name ?? '-' }} | + @foreach($typeMap as $typeKey => $typeLabel) ++ @php $val = $userTypeMap->get($typeKey)?->total_days ?? 0; @endphp + {{ $val > 0 ? $val : '-' }} + | + @endforeach ++ {{ $userTotal }}일 + | +
| 사원 | +부서 | +유형 | +기간 | +일수 | +사유 | +상태 | +처리자 | +액션 | +
|---|---|---|---|---|---|---|---|---|
|
+
+
+
+ {{ 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
+ |
+
|
+
+
+
+ 휴가 신청 내역이 없습니다. + |
+ ||||||||