From 6cdcc293cfe099aa0e2942dab8920a582dacde1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Mar 2026 23:50:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[hr]=20=EA=B7=BC=ED=83=9C=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20+=20=ED=9C=B4=EA=B0=80=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Leave 모델 확장: 6개 유형 추가 (출장/재택/외근/조퇴/지각사유서/결근사유서) - LeaveService: 유형별 결재양식 자동 선택, 유형별 Attendance 반영 분기 - ApprovalService: 콜백 3개 결재양식코드로 확장 - AttendanceIntegratedController: 통합 화면 컨트롤러 - 통합 UI: 근태현황/신청결재/연차잔여 3탭 + 신규 신청 드롭다운 - AttendanceRequest 모델/서비스/컨트롤러/뷰 삭제 (Leave로 일원화) - AttendanceService: deductLeaveBalance 제거 (Leave 시스템으로 일원화) --- .../Admin/HR/AttendanceRequestController.php | 141 ------- .../Api/Admin/HR/LeaveController.php | 32 +- .../HR/AttendanceIntegratedController.php | 54 +++ app/Models/HR/AttendanceRequest.php | 93 ----- app/Models/HR/Leave.php | 39 ++ app/Services/ApprovalService.php | 24 +- app/Services/HR/AttendanceRequestService.php | 148 -------- app/Services/HR/AttendanceService.php | 30 +- app/Services/HR/LeaveService.php | 197 +++++++--- .../hr/attendance-integrated/index.blade.php | 356 ++++++++++++++++++ .../partials/modal-leave.blade.php | 94 +++++ .../partials/stats.blade.php | 27 ++ .../partials/tab-attendance.blade.php | 55 +++ .../attendances/partials/requests.blade.php | 110 ------ routes/api.php | 11 +- routes/web.php | 7 +- 16 files changed, 826 insertions(+), 592 deletions(-) delete mode 100644 app/Http/Controllers/Api/Admin/HR/AttendanceRequestController.php create mode 100644 app/Http/Controllers/HR/AttendanceIntegratedController.php delete mode 100644 app/Models/HR/AttendanceRequest.php delete mode 100644 app/Services/HR/AttendanceRequestService.php create mode 100644 resources/views/hr/attendance-integrated/index.blade.php create mode 100644 resources/views/hr/attendance-integrated/partials/modal-leave.blade.php create mode 100644 resources/views/hr/attendance-integrated/partials/stats.blade.php create mode 100644 resources/views/hr/attendance-integrated/partials/tab-attendance.blade.php delete mode 100644 resources/views/hr/attendances/partials/requests.blade.php diff --git a/app/Http/Controllers/Api/Admin/HR/AttendanceRequestController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceRequestController.php deleted file mode 100644 index 4a623798..00000000 --- a/app/Http/Controllers/Api/Admin/HR/AttendanceRequestController.php +++ /dev/null @@ -1,141 +0,0 @@ -requestService->getRequests( - $request->all(), - $request->integer('per_page', 20) - ); - - if ($request->header('HX-Request')) { - return response(view('hr.attendances.partials.requests', compact('requests'))); - } - - return response()->json([ - 'success' => true, - 'data' => $requests->items(), - 'meta' => [ - 'current_page' => $requests->currentPage(), - 'last_page' => $requests->lastPage(), - 'per_page' => $requests->perPage(), - 'total' => $requests->total(), - ], - ]); - } - - /** - * 신청 등록 - */ - public function store(Request $request): JsonResponse - { - $validated = $request->validate([ - 'user_id' => 'required|integer|exists:users,id', - 'request_type' => 'required|string|in:vacation,businessTrip,remote,fieldWork', - 'start_date' => 'required|date', - 'end_date' => 'required|date|after_or_equal:start_date', - 'reason' => 'nullable|string|max:1000', - 'json_details' => 'nullable|array', - ]); - - try { - $attendanceRequest = $this->requestService->storeRequest($validated); - - return response()->json([ - 'success' => true, - 'message' => '근태 신청이 등록되었습니다.', - 'data' => $attendanceRequest, - ], 201); - } 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 { - $attendanceRequest = $this->requestService->approve($id); - - if (! $attendanceRequest) { - return response()->json([ - 'success' => false, - 'message' => '대기 중인 신청을 찾을 수 없습니다.', - ], 404); - } - - return response()->json([ - 'success' => true, - 'message' => '승인 처리되었습니다. 근태 레코드가 자동 생성되었습니다.', - 'data' => $attendanceRequest, - ]); - } 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 { - $attendanceRequest = $this->requestService->reject($id, $validated['reject_reason'] ?? null); - - if (! $attendanceRequest) { - return response()->json([ - 'success' => false, - 'message' => '대기 중인 신청을 찾을 수 없습니다.', - ], 404); - } - - return response()->json([ - 'success' => true, - 'message' => '반려 처리되었습니다.', - 'data' => $attendanceRequest, - ]); - } catch (\Throwable $e) { - report($e); - - return response()->json([ - 'success' => false, - 'message' => '반려 처리 중 오류가 발생했습니다.', - 'error' => config('app.debug') ? $e->getMessage() : null, - ], 500); - } - } -} diff --git a/app/Http/Controllers/Api/Admin/HR/LeaveController.php b/app/Http/Controllers/Api/Admin/HR/LeaveController.php index 0b7006fb..b1e90ad3 100644 --- a/app/Http/Controllers/Api/Admin/HR/LeaveController.php +++ b/app/Http/Controllers/Api/Admin/HR/LeaveController.php @@ -48,7 +48,7 @@ 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', + 'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental,business_trip,remote,field_work,early_leave,late_reason,absent_reason', 'start_date' => 'required|date', 'end_date' => 'required|date|after_or_equal:start_date', 'reason' => 'nullable|string|max:1000', @@ -176,6 +176,36 @@ public function cancel(Request $request, int $id): JsonResponse } } + /** + * pending 상태 신청 삭제 + */ + public function destroy(Request $request, int $id): JsonResponse + { + try { + $leave = $this->leaveService->deletePendingLeave($id); + + if (! $leave) { + return response()->json([ + 'success' => false, + 'message' => '삭제 가능한 신청을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '신청이 삭제되었습니다.', + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '삭제 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + /** * 잔여연차 목록 (HTMX → HTML) */ diff --git a/app/Http/Controllers/HR/AttendanceIntegratedController.php b/app/Http/Controllers/HR/AttendanceIntegratedController.php new file mode 100644 index 00000000..ef1f586c --- /dev/null +++ b/app/Http/Controllers/HR/AttendanceIntegratedController.php @@ -0,0 +1,54 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('hr.attendance.index')); + } + + $stats = $this->attendanceService->getMonthlyStats(); + $departments = $this->leaveService->getDepartments(); + $employees = $this->leaveService->getActiveEmployees(); + $statusMap = Attendance::STATUS_MAP; + $leaveTypeMap = Leave::TYPE_MAP; + $leaveStatusMap = Leave::STATUS_MAP; + + // 결재선 목록 + $approvalLines = \App\Models\Approvals\ApprovalLine::query() + ->where('tenant_id', session('selected_tenant_id')) + ->orderBy('name') + ->get(['id', 'name', 'is_default']); + + return view('hr.attendance-integrated.index', [ + 'stats' => $stats, + 'departments' => $departments, + 'employees' => $employees, + 'statusMap' => $statusMap, + 'leaveTypeMap' => $leaveTypeMap, + 'leaveStatusMap' => $leaveStatusMap, + 'approvalLines' => $approvalLines, + ]); + } +} diff --git a/app/Models/HR/AttendanceRequest.php b/app/Models/HR/AttendanceRequest.php deleted file mode 100644 index 2e0fb75c..00000000 --- a/app/Models/HR/AttendanceRequest.php +++ /dev/null @@ -1,93 +0,0 @@ - 'int', - 'user_id' => 'int', - 'approved_by' => 'int', - 'start_date' => 'date', - 'end_date' => 'date', - 'approved_at' => 'datetime', - 'json_details' => 'array', - ]; - - public const TYPE_MAP = [ - 'vacation' => '휴가', - 'businessTrip' => '출장', - 'remote' => '재택', - 'fieldWork' => '외근', - ]; - - public const STATUS_MAP = [ - 'pending' => '대기', - 'approved' => '승인', - 'rejected' => '반려', - ]; - - public const STATUS_COLORS = [ - 'pending' => 'amber', - 'approved' => 'emerald', - 'rejected' => 'red', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class, 'user_id'); - } - - public function approver(): BelongsTo - { - return $this->belongsTo(User::class, 'approved_by'); - } - - public function getTypeLabelAttribute(): string - { - return self::TYPE_MAP[$this->request_type] ?? $this->request_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 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/Models/HR/Leave.php b/app/Models/HR/Leave.php index b94c9f68..ce4986bf 100644 --- a/app/Models/HR/Leave.php +++ b/app/Models/HR/Leave.php @@ -58,6 +58,45 @@ class Leave extends Model 'family' => '경조사', 'maternity' => '출산', 'parental' => '육아', + 'business_trip' => '출장', + 'remote' => '재택근무', + 'field_work' => '외근', + 'early_leave' => '조퇴', + 'late_reason' => '지각사유서', + 'absent_reason' => '결근사유서', + ]; + + // 그룹 상수 + public const VACATION_TYPES = ['annual', 'half_am', 'half_pm', 'sick', 'family', 'maternity', 'parental']; + + public const ATTENDANCE_REQUEST_TYPES = ['business_trip', 'remote', 'field_work', 'early_leave']; + + public const REASON_REPORT_TYPES = ['late_reason', 'absent_reason']; + + // 유형 → 결재양식코드 매핑 + public const FORM_CODE_MAP = [ + 'annual' => 'leave', 'half_am' => 'leave', 'half_pm' => 'leave', + 'sick' => 'leave', 'family' => 'leave', 'maternity' => 'leave', 'parental' => 'leave', + 'business_trip' => 'attendance_request', 'remote' => 'attendance_request', + 'field_work' => 'attendance_request', 'early_leave' => 'attendance_request', + 'late_reason' => 'reason_report', 'absent_reason' => 'reason_report', + ]; + + // 유형 → 근태상태 매핑 (승인 시 Attendance에 반영할 상태) + public const ATTENDANCE_STATUS_MAP = [ + 'annual' => 'vacation', 'half_am' => 'vacation', 'half_pm' => 'vacation', + 'sick' => 'vacation', 'family' => 'vacation', 'maternity' => 'vacation', 'parental' => 'vacation', + 'business_trip' => 'businessTrip', 'remote' => 'remote', 'field_work' => 'fieldWork', + 'early_leave' => null, + 'late_reason' => null, + 'absent_reason' => null, + ]; + + // 그룹별 라벨 + public const GROUP_LABELS = [ + 'vacation' => '휴가', + 'attendance_request' => '근태신청', + 'reason_report' => '사유서', ]; public const STATUS_MAP = [ diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 032ac1eb..8dda23a0 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -711,11 +711,19 @@ public function getBadgeCounts(int $userId): array // ========================================================================= /** - * 결재 최종 승인 시 연동 처리 (휴가 등) + * 휴가/근태신청/사유서 관련 결재 양식인지 확인 + */ + private function isLeaveRelatedForm(?string $code): bool + { + return in_array($code, ['leave', 'attendance_request', 'reason_report']); + } + + /** + * 결재 최종 승인 시 연동 처리 (휴가/근태신청/사유서) */ private function handleApprovalCompleted(Approval $approval): void { - if (! $approval->form || $approval->form->code !== 'leave') { + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { return; } @@ -726,11 +734,11 @@ private function handleApprovalCompleted(Approval $approval): void } /** - * 결재 반려 시 연동 처리 (휴가 등) + * 결재 반려 시 연동 처리 (휴가/근태신청/사유서) */ private function handleApprovalRejected(Approval $approval, string $comment): void { - if (! $approval->form || $approval->form->code !== 'leave') { + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { return; } @@ -745,11 +753,11 @@ private function handleApprovalRejected(Approval $approval, string $comment): vo } /** - * 결재 삭제 시 연동 처리 (휴가 등) + * 결재 삭제 시 연동 처리 (휴가/근태신청/사유서) */ private function handleApprovalDeleted(Approval $approval): void { - if (! $approval->form || $approval->form->code !== 'leave') { + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { return; } @@ -763,11 +771,11 @@ private function handleApprovalDeleted(Approval $approval): void } /** - * 결재 회수 시 연동 처리 (휴가 등) + * 결재 회수 시 연동 처리 (휴가/근태신청/사유서) */ private function handleApprovalCancelled(Approval $approval): void { - if (! $approval->form || $approval->form->code !== 'leave') { + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { return; } diff --git a/app/Services/HR/AttendanceRequestService.php b/app/Services/HR/AttendanceRequestService.php deleted file mode 100644 index 8bfc534a..00000000 --- a/app/Services/HR/AttendanceRequestService.php +++ /dev/null @@ -1,148 +0,0 @@ -with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', 'approver']) - ->forTenant($tenantId) - ->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected')") - ->orderBy('created_at', 'desc'); - - if (! empty($filters['status'])) { - $query->where('status', $filters['status']); - } - - if (! empty($filters['user_id'])) { - $query->where('user_id', $filters['user_id']); - } - - return $query->paginate($perPage); - } - - /** - * 신청 등록 - */ - public function storeRequest(array $data): AttendanceRequest - { - $tenantId = session('selected_tenant_id'); - - return AttendanceRequest::create([ - 'tenant_id' => $tenantId, - 'user_id' => $data['user_id'], - 'request_type' => $data['request_type'], - 'start_date' => $data['start_date'], - 'end_date' => $data['end_date'], - 'reason' => $data['reason'] ?? null, - 'status' => 'pending', - 'json_details' => $data['json_details'] ?? null, - ]); - } - - /** - * 승인 처리 - */ - public function approve(int $id): ?AttendanceRequest - { - $tenantId = session('selected_tenant_id'); - - $request = AttendanceRequest::query() - ->forTenant($tenantId) - ->where('status', 'pending') - ->find($id); - - if (! $request) { - return null; - } - - return DB::transaction(function () use ($request, $tenantId) { - $request->update([ - 'status' => 'approved', - 'approved_by' => auth()->id(), - 'approved_at' => now(), - ]); - - // 승인 시 해당 기간의 근태 레코드 자동 생성 - $this->createAttendanceRecords($request, $tenantId); - - return $request->fresh(['user', 'approver']); - }); - } - - /** - * 반려 처리 - */ - public function reject(int $id, ?string $reason = null): ?AttendanceRequest - { - $tenantId = session('selected_tenant_id'); - - $request = AttendanceRequest::query() - ->forTenant($tenantId) - ->where('status', 'pending') - ->find($id); - - if (! $request) { - return null; - } - - $request->update([ - 'status' => 'rejected', - 'approved_by' => auth()->id(), - 'approved_at' => now(), - 'reject_reason' => $reason, - ]); - - return $request->fresh(['user', 'approver']); - } - - /** - * 승인 후 근태 레코드 자동 생성 - */ - private function createAttendanceRecords(AttendanceRequest $request, int $tenantId): void - { - $statusMap = [ - 'vacation' => 'vacation', - 'businessTrip' => 'businessTrip', - 'remote' => 'remote', - 'fieldWork' => 'fieldWork', - ]; - - $status = $statusMap[$request->request_type] ?? $request->request_type; - - $period = CarbonPeriod::create($request->start_date, $request->end_date); - - foreach ($period as $date) { - // 주말 제외 - if ($date->isWeekend()) { - continue; - } - - Attendance::updateOrCreate( - [ - 'tenant_id' => $tenantId, - 'user_id' => $request->user_id, - 'base_date' => $date->toDateString(), - ], - [ - 'status' => $status, - 'remarks' => $request->reason ? mb_substr($request->reason, 0, 100) : null, - 'updated_by' => auth()->id(), - ] - ); - } - } -} diff --git a/app/Services/HR/AttendanceService.php b/app/Services/HR/AttendanceService.php index cde236a8..dc832eac 100644 --- a/app/Services/HR/AttendanceService.php +++ b/app/Services/HR/AttendanceService.php @@ -4,7 +4,6 @@ use App\Models\HR\Attendance; use App\Models\HR\Employee; -use App\Models\HR\LeaveBalance; use App\Models\Tenants\Department; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -145,10 +144,7 @@ public function storeAttendance(array $data): Attendance $attendance->update(['created_by' => auth()->id()]); } - // 휴가 상태이면 연차 차감 - if (($data['status'] ?? '') === 'vacation') { - $this->deductLeaveBalance($tenantId, $data['user_id']); - } + // 휴가 상태: Leave 시스템에서 연차 차감 처리 (수동 등록 시에는 차감하지 않음) return $attendance->load('user'); }); @@ -202,9 +198,7 @@ public function bulkStore(array $data): array $updated++; } - if (($data['status'] ?? '') === 'vacation') { - $this->deductLeaveBalance($tenantId, $userId); - } + // 휴가 상태: Leave 시스템에서 연차 차감 처리 (일괄 등록 시에는 차감하지 않음) } }); @@ -508,26 +502,6 @@ public function getLeaveBalance(int $userId): ?LeaveBalance ->first(); } - /** - * 연차 차감 (remaining_days는 stored generated이므로 used_days만 업데이트) - */ - private function deductLeaveBalance(int $tenantId, int $userId): void - { - $year = now()->year; - - $balance = LeaveBalance::query() - ->where('tenant_id', $tenantId) - ->where('user_id', $userId) - ->where('year', $year) - ->first(); - - if ($balance && $balance->remaining_days > 0) { - $balance->update([ - 'used_days' => $balance->used_days + 1, - ]); - } - } - /** * 부서 목록 (드롭다운용) */ diff --git a/app/Services/HR/LeaveService.php b/app/Services/HR/LeaveService.php index fadd7195..8ae5a7c0 100644 --- a/app/Services/HR/LeaveService.php +++ b/app/Services/HR/LeaveService.php @@ -76,15 +76,22 @@ public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePa } /** - * 휴가 신청 등록 → 결재 자동 생성 + 상신 + * 휴가/근태신청/사유서 등록 → 결재 자동 생성 + 상신 */ public function storeLeave(array $data): Leave { $tenantId = session('selected_tenant_id'); - $days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']); + $leaveType = $data['leave_type']; + + // 사유서는 days=0, 그 외는 자동 계산 + if (in_array($leaveType, Leave::REASON_REPORT_TYPES)) { + $days = 0; + } else { + $days = $this->calculateDays($leaveType, $data['start_date'], $data['end_date']); + } // 연차 차감 대상이면 잔여일수 검증 - if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) { + if (in_array($leaveType, Leave::DEDUCTIBLE_TYPES)) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($data['user_id']) @@ -96,11 +103,11 @@ public function storeLeave(array $data): Leave } } - return DB::transaction(function () use ($data, $tenantId, $days) { + return DB::transaction(function () use ($data, $tenantId, $days, $leaveType) { $leave = Leave::create([ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], - 'leave_type' => $data['leave_type'], + 'leave_type' => $leaveType, 'start_date' => $data['start_date'], 'end_date' => $data['end_date'], 'days' => $days, @@ -110,7 +117,7 @@ public function storeLeave(array $data): Leave 'updated_by' => auth()->id(), ]); - // 결재 자동 생성 + 상신 (선택된 결재선 전달) + // 결재 자동 생성 + 상신 (유형에 맞는 결재양식 자동 선택) $approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null); $leave->update(['approval_id' => $approval->id]); @@ -119,7 +126,7 @@ public function storeLeave(array $data): Leave } /** - * 승인 → LeaveBalance 차감 → Attendance 자동 생성 + * 승인 → LeaveBalance 차감 → 유형별 Attendance 반영 */ public function approve(int $id): ?Leave { @@ -155,8 +162,8 @@ public function approve(int $id): ?Leave } } - // 기간 내 영업일마다 Attendance(vacation) 자동 생성 - $this->createAttendanceRecords($leave, $tenantId); + // 유형별 Attendance 반영 + $this->applyAttendanceByType($leave, $tenantId); return $leave->fresh(['user', 'approver']); }); @@ -269,13 +276,13 @@ public function deletePendingLeave(int $id): ?Leave } /** - * 결재 승인에 의한 휴가 자동 승인 + * 결재 승인에 의한 휴가/근태신청/사유서 자동 승인 */ public function approveByApproval(Leave $leave, Approval $approval): Leave { $tenantId = $leave->tenant_id; - // 최종 결재자 ID 찾기 (DB에서 fresh 조회, 기본 정렬 제거 후 역순) + // 최종 결재자 ID 찾기 $lastApprover = $approval->steps() ->where('status', 'approved') ->reorder('step_order', 'desc') @@ -288,7 +295,7 @@ public function approveByApproval(Leave $leave, Approval $approval): Leave 'updated_by' => auth()->id(), ]); - // 연차 차감 + // 연차 차감 (DEDUCTIBLE_TYPES만) if ($leave->is_deductible) { $balance = LeaveBalance::query() ->where('tenant_id', $tenantId) @@ -301,12 +308,74 @@ public function approveByApproval(Leave $leave, Approval $approval): Leave } } - // Attendance 생성 - $this->createAttendanceRecords($leave, $tenantId); + // 유형별 Attendance 반영 + $this->applyAttendanceByType($leave, $tenantId); return $leave->fresh(['user', 'approver']); } + /** + * 유형별 Attendance 반영 분기 + */ + private function applyAttendanceByType(Leave $leave, int $tenantId): void + { + $attendanceStatus = Leave::ATTENDANCE_STATUS_MAP[$leave->leave_type] ?? null; + + if ($attendanceStatus) { + // 휴가/출장/재택/외근 → Attendance 자동 생성 + $this->createAttendanceRecords($leave, $tenantId, $attendanceStatus); + } elseif ($leave->leave_type === 'early_leave') { + // 조퇴 → 기존 Attendance에 비고 기록 + $this->markEarlyLeave($leave, $tenantId); + } elseif (in_array($leave->leave_type, Leave::REASON_REPORT_TYPES)) { + // 지각/결근 사유서 → 기존 Attendance에 사유 기록 + $this->addReasonToAttendance($leave, $tenantId); + } + } + + /** + * 조퇴 승인 시 기존 Attendance에 비고 기록 + */ + private function markEarlyLeave(Leave $leave, int $tenantId): void + { + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $leave->user_id) + ->where('base_date', $leave->start_date->toDateString()) + ->first(); + + if ($attendance) { + $remarks = $attendance->remarks; + $reason = $leave->reason ? " ({$leave->reason})" : ''; + $attendance->update([ + 'remarks' => trim(($remarks ? $remarks.' / ' : '').'조퇴'.$reason), + 'updated_by' => auth()->id(), + ]); + } + } + + /** + * 지각/결근 사유서 승인 시 기존 Attendance에 사유 기록 + */ + private function addReasonToAttendance(Leave $leave, int $tenantId): void + { + $attendance = Attendance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $leave->user_id) + ->where('base_date', $leave->start_date->toDateString()) + ->first(); + + if ($attendance) { + $typeName = $leave->leave_type === 'late_reason' ? '지각사유' : '결근사유'; + $reason = $leave->reason ?? ''; + $remarks = $attendance->remarks; + $attendance->update([ + 'remarks' => trim(($remarks ? $remarks.' / ' : '')."[{$typeName}] {$reason}"), + 'updated_by' => auth()->id(), + ]); + } + } + /** * 결재 반려에 의한 휴가 자동 반려 */ @@ -631,20 +700,23 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection // ========================================================================= /** - * 휴가신청 결재 자동 생성 + 상신 + * 휴가/근태신청/사유서 결재 자동 생성 + 상신 */ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null): Approval { $approvalService = app(ApprovalService::class); - // 1. 휴가신청 양식 조회 - $form = ApprovalForm::where('code', 'leave') + // 1. 유형에 맞는 결재양식 조회 (FORM_CODE_MAP으로 동적 결정) + $formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave'; + $form = ApprovalForm::where('code', $formCode) ->where('tenant_id', $tenantId) ->where('is_active', true) ->first(); if (! $form) { - throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.'); + $formNames = ['leave' => '휴가신청', 'attendance_request' => '근태신청', 'reason_report' => '사유서']; + $formName = $formNames[$formCode] ?? $formCode; + throw new \RuntimeException("{$formName} 결재 양식이 등록되지 않았습니다."); } // 2. 결재선 조회: 지정된 ID 우선, 없으면 기본결재선 @@ -671,15 +743,21 @@ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approval 'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval', ])->toArray(); - // 5. 결재 생성 + // 5. 결재 제목 생성 (유형별 차별화) $typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type; $userName = $leave->user->name ?? ''; $period = $leave->start_date->format('n/j').'~'.$leave->end_date->format('n/j'); + $titlePrefix = match ($formCode) { + 'attendance_request' => '근태신청', + 'reason_report' => '사유서', + default => '휴가신청', + }; + $approval = $approvalService->createApproval([ 'form_id' => $form->id, 'line_id' => $line->id, - 'title' => "휴가신청 - {$userName} ({$typeName} {$period})", + 'title' => "{$titlePrefix} - {$userName} ({$typeName} {$period})", 'body' => $body, 'content' => [ 'leave_id' => $leave->id, @@ -701,51 +779,64 @@ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approval } /** - * 결재 본문 HTML 생성 + * 결재 본문 HTML 생성 (유형별 차별화) */ private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string { $user = $leave->user; $typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type; - $period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d'); - $daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일'; - - // 잔여연차 정보 - $balanceInfo = ''; - if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) { - $balance = LeaveBalance::query() - ->where('tenant_id', $tenantId) - ->where('user_id', $leave->user_id) - ->where('year', now()->year) - ->first(); - - if ($balance) { - $balanceInfo = $balance->remaining.'일' - .' (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')'; - } - } + $formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave'; // 부서 정보 $profile = $user?->tenantProfiles?->where('tenant_id', $tenantId)->first(); $deptName = $profile?->department?->name ?? ''; - // HTML 테이블 본문 - $rows = [ - ['신청자', e($user->name ?? '')], - ]; + // 공통 행 + $rows = [['신청자', e($user->name ?? '')]]; if ($deptName) { $rows[] = ['부서', e($deptName)]; } - $rows[] = ['휴가유형', $typeName]; - $rows[] = ['기간', $period.' ('.$daysStr.')']; - if ($leave->reason) { - $rows[] = ['사유', e($leave->reason)]; - } - if ($balanceInfo) { - $rows[] = ['잔여연차', $balanceInfo]; + $rows[] = ['유형', $typeName]; + + // 사유서: 대상일 + 사유 + if ($formCode === 'reason_report') { + $rows[] = ['대상일', $leave->start_date->format('Y-m-d')]; + if ($leave->reason) { + $rows[] = ['사유', e($leave->reason)]; + } + + $intro = '아래와 같이 사유서를 제출합니다.'; + } else { + // 휴가/근태신청: 기간 + 일수 + $period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d'); + $daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일'; + $rows[] = ['기간', $period.' ('.$daysStr.')']; + + if ($leave->reason) { + $rows[] = ['사유', e($leave->reason)]; + } + + // 잔여연차 (연차 차감 대상만) + if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) { + $balance = LeaveBalance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $leave->user_id) + ->where('year', now()->year) + ->first(); + + if ($balance) { + $rows[] = ['잔여연차', $balance->remaining.'일 (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')']; + } + } + + $intro = match ($formCode) { + 'attendance_request' => '아래와 같이 근태를 신청합니다.', + default => '아래와 같이 휴가를 신청합니다.', + }; } - $html = '

아래와 같이 휴가를 신청합니다.

'; + // HTML 테이블 생성 + $html = "

{$intro}

"; $html .= ''; $thStyle = 'style="padding:8px 12px; background:#f8f9fa; border:1px solid #dee2e6; text-align:left; width:120px; font-weight:600;"'; $tdStyle = 'style="padding:8px 12px; border:1px solid #dee2e6;"'; @@ -758,9 +849,9 @@ private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string } /** - * 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성 + * 승인 시 기간 내 영업일마다 Attendance 자동 생성 */ - private function createAttendanceRecords(Leave $leave, int $tenantId): void + private function createAttendanceRecords(Leave $leave, int $tenantId, string $status = 'vacation'): void { $period = CarbonPeriod::create($leave->start_date, $leave->end_date); @@ -776,7 +867,7 @@ private function createAttendanceRecords(Leave $leave, int $tenantId): void 'base_date' => $date->toDateString(), ], [ - 'status' => 'vacation', + 'status' => $status, 'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null, 'updated_by' => auth()->id(), ] diff --git a/resources/views/hr/attendance-integrated/index.blade.php b/resources/views/hr/attendance-integrated/index.blade.php new file mode 100644 index 00000000..00670e3a --- /dev/null +++ b/resources/views/hr/attendance-integrated/index.blade.php @@ -0,0 +1,356 @@ +@extends('layouts.app') + +@section('title', '근태관리') + +@section('content') +
+ {{-- 페이지 헤더 --}} +
+
+

근태관리

+

근태 조회, 휴가/근태 신청, 사유서 제출을 한 화면에서 관리합니다.

+
+
+ {{-- 신규 신청 드롭다운 --}} +
+ + +
+
+
+ + {{-- 탭 네비게이션 --}} +
+ + + +
+ + {{-- 탭 콘텐츠 영역 --}} +
+ {{-- 탭 1: 근태현황 --}} +
+ @include('hr.attendance-integrated.partials.tab-attendance') +
+ + {{-- 탭 2: 신청/결재 (lazy load) --}} + + + {{-- 탭 3: 연차잔여 (lazy load) --}} + +
+
+ +{{-- 신청 모달 --}} +@include('hr.attendance-integrated.partials.modal-leave') + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/hr/attendance-integrated/partials/modal-leave.blade.php b/resources/views/hr/attendance-integrated/partials/modal-leave.blade.php new file mode 100644 index 00000000..4e8f4549 --- /dev/null +++ b/resources/views/hr/attendance-integrated/partials/modal-leave.blade.php @@ -0,0 +1,94 @@ +{{-- 휴가/근태신청/사유서 통합 모달 --}} + diff --git a/resources/views/hr/attendance-integrated/partials/stats.blade.php b/resources/views/hr/attendance-integrated/partials/stats.blade.php new file mode 100644 index 00000000..3883da35 --- /dev/null +++ b/resources/views/hr/attendance-integrated/partials/stats.blade.php @@ -0,0 +1,27 @@ +@php + $statItems = [ + ['label' => '정상출근', 'value' => $stats['onTime'] ?? 0, 'color' => 'emerald', 'icon' => 'M5 13l4 4L19 7'], + ['label' => '지각', 'value' => $stats['late'] ?? 0, 'color' => 'amber', 'icon' => 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'], + ['label' => '결근', 'value' => $stats['absent'] ?? 0, 'color' => 'red', 'icon' => 'M6 18L18 6M6 6l12 12'], + ['label' => '휴가', 'value' => $stats['vacation'] ?? 0, 'color' => 'blue', 'icon' => 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'], + ['label' => '출장/외근/재택', 'value' => $stats['etc'] ?? 0, 'color' => 'purple', 'icon' => 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z'], + ]; +@endphp + +
+ @foreach($statItems as $item) +
+
+
+ + + +
+
+

{{ $item['label'] }}

+

{{ number_format($item['value']) }}

+
+
+
+ @endforeach +
diff --git a/resources/views/hr/attendance-integrated/partials/tab-attendance.blade.php b/resources/views/hr/attendance-integrated/partials/tab-attendance.blade.php new file mode 100644 index 00000000..68ae9c64 --- /dev/null +++ b/resources/views/hr/attendance-integrated/partials/tab-attendance.blade.php @@ -0,0 +1,55 @@ +{{-- 통계 카드 --}} +
+ @include('hr.attendance-integrated.partials.stats') +
+ +{{-- 필터 --}} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +{{-- 근태 목록 --}} +
+
+ + 불러오는 중... +
+
diff --git a/resources/views/hr/attendances/partials/requests.blade.php b/resources/views/hr/attendances/partials/requests.blade.php deleted file mode 100644 index e1baec58..00000000 --- a/resources/views/hr/attendances/partials/requests.blade.php +++ /dev/null @@ -1,110 +0,0 @@ -{{-- 근태 신청/승인 목록 (HTMX로 로드) --}} -@php - use App\Models\HR\AttendanceRequest; -@endphp - -
-

근태 신청/승인

- -
- -@if($requests->isEmpty()) -
- - - -

근태 신청 내역이 없습니다.

-
-@else - -
- - - - - - - - - - - - - @foreach($requests as $req) - @php - $profile = $req->user?->tenantProfiles?->first(); - $displayName = $profile?->display_name ?? $req->user?->name ?? '-'; - $statusColor = AttendanceRequest::STATUS_COLORS[$req->status] ?? 'gray'; - $typeLabel = AttendanceRequest::TYPE_MAP[$req->request_type] ?? $req->request_type; - $statusLabel = AttendanceRequest::STATUS_MAP[$req->status] ?? $req->status; - $dateRange = $req->start_date->format('m/d') . ($req->start_date->ne($req->end_date) ? ' ~ ' . $req->end_date->format('m/d') : ''); - @endphp - - - - - - - - - - @endforeach - -
신청자유형기간사유상태처리자작업
-
-
- {{ mb_substr($displayName, 0, 1) }} -
- {{ $displayName }} -
-
- - {{ $typeLabel }} - - {{ $dateRange }} - {{ $req->reason ?? '-' }} - - - {{ $statusLabel }} - - - @if($req->approved_by) - {{ $req->approver?->name ?? '-' }} -
{{ $req->approved_at?->format('m/d H:i') }}
- @else - - - @endif -
- @if($req->status === 'pending') -
- - -
- @elseif($req->status === 'rejected' && $req->reject_reason) - - 사유: {{ mb_substr($req->reject_reason, 0, 20) }}{{ mb_strlen($req->reject_reason) > 20 ? '...' : '' }} - - @else - - - @endif -
- - -@if($requests->hasPages()) -
- {{ $requests->links() }} -
-@endif -@endif diff --git a/routes/api.php b/routes/api.php index cb3bc768..87af0760 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1186,15 +1186,9 @@ Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'destroy'])->name('destroy'); }); -// 근태 신청/승인 API -Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/attendance-requests')->name('api.admin.hr.attendances.requests.')->group(function () { - Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'index'])->name('index'); - Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'store'])->name('store'); - 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 — Leave 시스템으로 통합됨 (attendance-requests 라우트 폐기) -// 휴가관리 API +// 휴가/근태신청/사유서 통합 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'); @@ -1205,6 +1199,7 @@ 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'); + Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'destroy'])->name('destroy'); }); // 급여관리 API diff --git a/routes/web.php b/routes/web.php index b40847d9..6e916559 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1004,18 +1004,21 @@ // 입퇴사자 현황 Route::get('/employee-tenure', [\App\Http\Controllers\HR\EmployeeTenureController::class, 'index'])->name('employee-tenure'); - // 근태현황 + // 근태현황 (기존) Route::prefix('attendances')->name('attendances.')->group(function () { Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index'); Route::get('/manage', [\App\Http\Controllers\HR\AttendanceController::class, 'manage'])->name('manage'); }); - // 휴가관리 + // 휴가관리 (기존) Route::prefix('leaves')->name('leaves.')->group(function () { Route::get('/', [\App\Http\Controllers\HR\LeaveController::class, 'index'])->name('index'); Route::get('/help', [\App\Http\Controllers\HR\LeaveController::class, 'helpGuide'])->name('help'); }); + // 근태관리 통합 (신규) + Route::get('/attendance', [\App\Http\Controllers\HR\AttendanceIntegratedController::class, 'index'])->name('attendance.index'); + // 급여관리 Route::prefix('payrolls')->name('payrolls.')->group(function () { Route::get('/', [\App\Http\Controllers\HR\PayrollController::class, 'index'])->name('index');