diff --git a/app/Console/Commands/MarkAbsentEmployees.php b/app/Console/Commands/MarkAbsentEmployees.php new file mode 100644 index 00000000..236078de --- /dev/null +++ b/app/Console/Commands/MarkAbsentEmployees.php @@ -0,0 +1,30 @@ +option('date') ?: now()->toDateString(); + + $this->info("자동 결근 처리 시작: {$date}"); + + $count = $service->markAbsentees($date); + + if ($count > 0) { + $this->info("{$count}명 결근 처리 완료"); + } else { + $this->info('결근 처리 대상이 없습니다 (주말이거나 모든 사원에 기록이 있음)'); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php index f614979b..83f85d02 100644 --- a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php @@ -62,6 +62,85 @@ public function stats(Request $request): JsonResponse|Response ]); } + /** + * 월간 캘린더 (HTMX → HTML) + */ + public function calendar(Request $request): JsonResponse|Response + { + $year = $request->integer('year') ?: now()->year; + $month = $request->integer('month') ?: now()->month; + $userId = $request->integer('user_id') ?: null; + + $attendances = $this->attendanceService->getMonthlyCalendarData($year, $month, $userId); + $calendarData = $attendances->groupBy(fn ($att) => $att->base_date->format('Y-m-d')); + $employees = $this->attendanceService->getActiveEmployees(); + + if ($request->header('HX-Request')) { + return response(view('hr.attendances.partials.calendar', compact( + 'year', 'month', 'calendarData', 'employees' + ))->with('selectedUserId', $userId)); + } + + return response()->json([ + 'success' => true, + 'data' => $calendarData, + ]); + } + + /** + * 사원별 월간 요약 (HTMX → HTML) + */ + public function summary(Request $request): JsonResponse|Response + { + $year = $request->integer('year') ?: now()->year; + $month = $request->integer('month') ?: now()->month; + + $summary = $this->attendanceService->getEmployeeMonthlySummary($year, $month); + + if ($request->header('HX-Request')) { + return response(view('hr.attendances.partials.summary', compact('summary', 'year', 'month'))); + } + + return response()->json([ + 'success' => true, + 'data' => $summary, + ]); + } + + /** + * 초과근무 알림 (HTMX → HTML) + */ + public function overtimeAlerts(Request $request): JsonResponse|Response + { + $alerts = $this->attendanceService->getOvertimeAlerts(); + + if ($request->header('HX-Request')) { + return response(view('hr.attendances.partials.overtime-alerts', compact('alerts'))); + } + + return response()->json([ + 'success' => true, + 'data' => $alerts, + ]); + } + + /** + * 잔여 연차 조회 + */ + public function leaveBalance(Request $request, int $userId): JsonResponse + { + $balance = $this->attendanceService->getLeaveBalance($userId); + + return response()->json([ + 'success' => true, + 'data' => [ + 'total' => $balance?->total ?? 0, + 'used' => $balance?->used ?? 0, + 'remaining' => $balance?->remaining ?? 0, + ], + ]); + } + /** * 엑셀(CSV) 내보내기 */ @@ -173,6 +252,40 @@ public function store(Request $request): JsonResponse } } + /** + * 일괄 등록 + */ + public function bulkStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'user_ids' => 'required|array|min:1', + 'user_ids.*' => 'integer|exists:users,id', + 'base_date' => 'required|date', + 'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote', + 'check_in' => 'nullable|date_format:H:i', + 'check_out' => 'nullable|date_format:H:i', + 'remarks' => 'nullable|string|max:500', + ]); + + try { + $result = $this->attendanceService->bulkStore($validated); + + return response()->json([ + 'success' => true, + 'message' => "신규 {$result['created']}건, 수정 {$result['updated']}건 처리되었습니다.", + 'data' => $result, + ], 201); + } 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/AttendanceRequestController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceRequestController.php new file mode 100644 index 00000000..4a623798 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/HR/AttendanceRequestController.php @@ -0,0 +1,141 @@ +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/Models/HR/AttendanceRequest.php b/app/Models/HR/AttendanceRequest.php new file mode 100644 index 00000000..2e0fb75c --- /dev/null +++ b/app/Models/HR/AttendanceRequest.php @@ -0,0 +1,93 @@ + '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/LeaveBalance.php b/app/Models/HR/LeaveBalance.php new file mode 100644 index 00000000..d93c6825 --- /dev/null +++ b/app/Models/HR/LeaveBalance.php @@ -0,0 +1,38 @@ + 'int', + 'user_id' => 'int', + 'year' => 'int', + 'total_days' => 'float', + 'used_days' => 'float', + 'remaining_days' => 'float', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'user_id'); + } + + public function getRemainingAttribute(): float + { + return $this->remaining_days ?? ($this->total_days - $this->used_days); + } +} diff --git a/app/Services/HR/AttendanceRequestService.php b/app/Services/HR/AttendanceRequestService.php new file mode 100644 index 00000000..0ec06757 --- /dev/null +++ b/app/Services/HR/AttendanceRequestService.php @@ -0,0 +1,148 @@ +with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), '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 e77660e4..0e8a6e28 100644 --- a/app/Services/HR/AttendanceService.php +++ b/app/Services/HR/AttendanceService.php @@ -4,7 +4,9 @@ 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; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -116,6 +118,15 @@ public function storeAttendance(array $data): Attendance $jsonDetails['check_out'] = $data['check_out']; } + // 근무 시간 자동 계산 + if (! empty($data['check_in']) && ! empty($data['check_out'])) { + $in = Carbon::createFromFormat('H:i', $data['check_in']); + $out = Carbon::createFromFormat('H:i', $data['check_out']); + if ($out->gt($in)) { + $jsonDetails['work_minutes'] = $out->diffInMinutes($in); + } + } + $attendance = Attendance::updateOrCreate( [ 'tenant_id' => $tenantId, @@ -134,10 +145,72 @@ public function storeAttendance(array $data): Attendance $attendance->update(['created_by' => auth()->id()]); } + // 휴가 상태이면 연차 차감 + if (($data['status'] ?? '') === 'vacation') { + $this->deductLeaveBalance($tenantId, $data['user_id']); + } + return $attendance->load('user'); }); } + /** + * 일괄 등록 + */ + public function bulkStore(array $data): array + { + $tenantId = session('selected_tenant_id'); + $created = 0; + $updated = 0; + + DB::transaction(function () use ($data, $tenantId, &$created, &$updated) { + $jsonDetails = []; + if (! empty($data['check_in'])) { + $jsonDetails['check_in'] = $data['check_in']; + } + if (! empty($data['check_out'])) { + $jsonDetails['check_out'] = $data['check_out']; + } + + if (! empty($data['check_in']) && ! empty($data['check_out'])) { + $in = Carbon::createFromFormat('H:i', $data['check_in']); + $out = Carbon::createFromFormat('H:i', $data['check_out']); + if ($out->gt($in)) { + $jsonDetails['work_minutes'] = $out->diffInMinutes($in); + } + } + + foreach ($data['user_ids'] as $userId) { + $attendance = Attendance::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'base_date' => $data['base_date'], + ], + [ + 'status' => $data['status'] ?? 'onTime', + 'json_details' => ! empty($jsonDetails) ? $jsonDetails : null, + 'remarks' => $data['remarks'] ?? null, + 'updated_by' => auth()->id(), + ] + ); + + if ($attendance->wasRecentlyCreated) { + $attendance->update(['created_by' => auth()->id()]); + $created++; + } else { + $updated++; + } + + if (($data['status'] ?? '') === 'vacation') { + $this->deductLeaveBalance($tenantId, $userId); + } + } + }); + + return ['created' => $created, 'updated' => $updated]; + } + /** * 근태 수정 */ @@ -178,6 +251,20 @@ public function updateAttendance(int $id, array $data): ?Attendance unset($jsonDetails['check_out']); } } + + // 근무 시간 재계산 + $checkIn = $jsonDetails['check_in'] ?? null; + $checkOut = $jsonDetails['check_out'] ?? null; + if ($checkIn && $checkOut) { + $in = Carbon::createFromFormat('H:i', $checkIn); + $out = Carbon::createFromFormat('H:i', $checkOut); + if ($out->gt($in)) { + $jsonDetails['work_minutes'] = $out->diffInMinutes($in); + } + } else { + unset($jsonDetails['work_minutes']); + } + $updateData['json_details'] = ! empty($jsonDetails) ? $jsonDetails : null; $updateData['updated_by'] = auth()->id(); @@ -229,6 +316,218 @@ public function bulkDelete(array $ids): int return $count; } + /** + * 월간 캘린더 데이터 (base_date 기준 그룹화) + */ + public function getMonthlyCalendarData(int $year, int $month, ?int $userId = null): Collection + { + $tenantId = session('selected_tenant_id'); + + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year)); + + $query = Attendance::query() + ->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)]) + ->forTenant($tenantId) + ->betweenDates($startDate, $endDate) + ->orderBy('base_date') + ->orderBy('user_id'); + + if ($userId) { + $query->where('user_id', $userId); + } + + return $query->get(); + } + + /** + * 사원별 월간 요약 + */ + public function getEmployeeMonthlySummary(int $year, int $month): array + { + $tenantId = session('selected_tenant_id'); + + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year)); + + $raw = Attendance::query() + ->forTenant($tenantId) + ->betweenDates($startDate, $endDate) + ->select( + 'user_id', + 'status', + DB::raw('COUNT(*) as cnt'), + DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as total_minutes") + ) + ->groupBy('user_id', 'status') + ->get(); + + $summary = []; + foreach ($raw as $row) { + if (! isset($summary[$row->user_id])) { + $summary[$row->user_id] = [ + 'user_id' => $row->user_id, + 'total_days' => 0, + 'total_minutes' => 0, + 'statuses' => [], + ]; + } + $summary[$row->user_id]['total_days'] += $row->cnt; + $summary[$row->user_id]['total_minutes'] += (int) $row->total_minutes; + $summary[$row->user_id]['statuses'][$row->status] = $row->cnt; + } + + // 사원 정보 가져오기 + $employees = Employee::query() + ->with(['user:id,name', 'department:id,name']) + ->forTenant($tenantId) + ->activeEmployees() + ->get() + ->keyBy('user_id'); + + foreach ($summary as &$item) { + $emp = $employees[$item['user_id']] ?? null; + $item['name'] = $emp?->display_name ?? $emp?->user?->name ?? '-'; + $item['department'] = $emp?->department?->name ?? '-'; + } + + return array_values($summary); + } + + /** + * 초과근무 알림 (이번 주 기준) + */ + public function getOvertimeAlerts(): array + { + $tenantId = session('selected_tenant_id'); + + $weekStart = now()->startOfWeek(Carbon::MONDAY)->toDateString(); + $weekEnd = now()->endOfWeek(Carbon::SUNDAY)->toDateString(); + + $results = Attendance::query() + ->forTenant($tenantId) + ->betweenDates($weekStart, $weekEnd) + ->select( + 'user_id', + DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as week_minutes") + ) + ->groupBy('user_id') + ->having('week_minutes', '>=', 2880) // 48시간 = 2880분 + ->get(); + + $alerts = []; + if ($results->isNotEmpty()) { + $employees = Employee::query() + ->with(['user:id,name']) + ->forTenant($tenantId) + ->activeEmployees() + ->get() + ->keyBy('user_id'); + + foreach ($results as $row) { + $emp = $employees[$row->user_id] ?? null; + $hours = round($row->week_minutes / 60, 1); + $alerts[] = [ + 'user_id' => $row->user_id, + 'name' => $emp?->display_name ?? $emp?->user?->name ?? '-', + 'hours' => $hours, + 'level' => $row->week_minutes >= 3120 ? 'danger' : 'warning', // 52h = 3120분 + ]; + } + } + + return $alerts; + } + + /** + * 자동 결근 처리 (영업일에 출근 기록 없는 사원) + */ + public function markAbsentees(?string $date = null): int + { + $date = $date ?? now()->toDateString(); + $carbonDate = Carbon::parse($date); + + // 주말이면 스킵 + if ($carbonDate->isWeekend()) { + return 0; + } + + $count = 0; + + // 모든 테넌트의 활성 사원 조회 + $tenantIds = DB::table('tenants')->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $activeUserIds = Employee::query() + ->where('tenant_id', $tenantId) + ->activeEmployees() + ->pluck('user_id') + ->toArray(); + + if (empty($activeUserIds)) { + continue; + } + + // 이미 기록이 있는 사원 제외 + $existingUserIds = Attendance::query() + ->where('tenant_id', $tenantId) + ->whereDate('base_date', $date) + ->pluck('user_id') + ->toArray(); + + $absentUserIds = array_diff($activeUserIds, $existingUserIds); + + foreach ($absentUserIds as $userId) { + Attendance::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'base_date' => $date, + 'status' => 'absent', + 'remarks' => '자동 결근 처리', + 'created_by' => null, + ]); + $count++; + } + } + + return $count; + } + + /** + * 연차 잔여 조회 + */ + public function getLeaveBalance(int $userId): ?LeaveBalance + { + $tenantId = session('selected_tenant_id'); + $year = now()->year; + + return LeaveBalance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('year', $year) + ->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/resources/views/hr/attendances/index.blade.php b/resources/views/hr/attendances/index.blade.php index 18f0cc97..7cb0b6d5 100644 --- a/resources/views/hr/attendances/index.blade.php +++ b/resources/views/hr/attendances/index.blade.php @@ -22,6 +22,13 @@
+
+ {{-- 탭 네비게이션 --}} +
+ + + + +
+ {{-- 통계 카드 (HTMX 갱신 대상) --}} -
+
@include('hr.attendances.partials.stats', ['stats' => $stats])
- {{-- 테이블 컨테이너 --}} -
- {{-- 필터 + 일괄 삭제 --}} -
- -
-
- - + {{-- 초과근무 알림 영역 --}} +
+
+ + {{-- 탭 콘텐츠 영역 --}} +
+ {{-- 목록 탭 --}} +
+
+ {{-- 필터 + 일괄 삭제 --}} +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {{-- HTMX 테이블 영역 --}} +
+
+
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - +
+
- {{-- HTMX 테이블 영역 --}} -
-
-
+ {{-- 캘린더 탭 --}} + + + {{-- 요약 탭 --}} + + + {{-- 승인 탭 --}} +
@@ -184,6 +256,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
+ + {{-- 잔여 연차 표시 --}} +
{{-- 푸터 --}} @@ -201,10 +278,237 @@ class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg tra
+ +{{-- 일괄 등록 모달 --}} + + +{{-- 승인 신청 모달 --}} + + +{{-- GPS 설정 모달 --}} + @endsection @push('scripts') @endpush diff --git a/resources/views/hr/attendances/partials/calendar.blade.php b/resources/views/hr/attendances/partials/calendar.blade.php new file mode 100644 index 00000000..9b96d748 --- /dev/null +++ b/resources/views/hr/attendances/partials/calendar.blade.php @@ -0,0 +1,184 @@ +{{-- 근태 월간 캘린더 (HTMX로 로드) --}} +@php + use Carbon\Carbon; + use App\Models\HR\Attendance; + + $year = $year ?? now()->year; + $month = $month ?? now()->month; + + $firstDay = Carbon::create($year, $month, 1); + $lastDay = $firstDay->copy()->endOfMonth(); + $startOfWeek = $firstDay->copy()->startOfWeek(Carbon::SUNDAY); + $endOfWeek = $lastDay->copy()->endOfWeek(Carbon::SATURDAY); + + $today = Carbon::today(); + $prevMonth = $firstDay->copy()->subMonth(); + $nextMonth = $firstDay->copy()->addMonth(); +@endphp + +{{-- 캘린더 헤더 --}} +
+
+ + +

{{ $year }}년 {{ $month }}월

+ + + + @if(!$today->isSameMonth($firstDay)) + + @endif +
+ + {{-- 사원 필터 --}} +
+ +
+
+ + + +{{-- 캘린더 그리드 --}} +
+ + + + + + + + + + + + + + @php + $currentDate = $startOfWeek->copy(); + @endphp + + @while($currentDate <= $endOfWeek) + + @for($i = 0; $i < 7; $i++) + @php + $dateKey = $currentDate->format('Y-m-d'); + $isCurrentMonth = $currentDate->month === (int)$month; + $isToday = $currentDate->isSameDay($today); + $isSunday = $currentDate->dayOfWeek === Carbon::SUNDAY; + $isSaturday = $currentDate->dayOfWeek === Carbon::SATURDAY; + $dayAttendances = $calendarData[$dateKey] ?? collect(); + @endphp + + + + @php + $currentDate->addDay(); + @endphp + @endfor + + @endwhile + +
+
+ {{-- 날짜 헤더 --}} +
+ + {{ $currentDate->day }} + + + @if($isCurrentMonth) + + @endif +
+ + {{-- 근태 목록 --}} + @if($dayAttendances->isNotEmpty()) +
+ @foreach($dayAttendances as $att) + @php + $color = Attendance::STATUS_COLORS[$att->status] ?? 'gray'; + $label = Attendance::STATUS_MAP[$att->status] ?? $att->status; + $profile = $att->user?->tenantProfiles?->first(); + $name = $profile?->display_name ?? $att->user?->name ?? '?'; + $checkIn = $att->check_in ? substr($att->check_in, 0, 5) : ''; + $checkOut = $att->check_out ? substr($att->check_out, 0, 5) : ''; + @endphp + + @endforeach +
+ @endif +
+
+
+ +{{-- 범례 --}} +
+ @foreach(Attendance::STATUS_MAP as $key => $label) + @php $color = Attendance::STATUS_COLORS[$key] ?? 'gray'; @endphp +
+ + {{ $label }} +
+ @endforeach +
diff --git a/resources/views/hr/attendances/partials/overtime-alerts.blade.php b/resources/views/hr/attendances/partials/overtime-alerts.blade.php new file mode 100644 index 00000000..b4c79c81 --- /dev/null +++ b/resources/views/hr/attendances/partials/overtime-alerts.blade.php @@ -0,0 +1,33 @@ +{{-- 초과근무 알림 배너 (HTMX로 로드) --}} +@if(!empty($alerts)) +
+ @foreach($alerts as $alert) +
+
+ @if($alert['level'] === 'danger') + + + + @else + + + + @endif +
+
+ + {{ $alert['name'] }} + + + — 이번 주 {{ $alert['hours'] }}시간 근무 + @if($alert['level'] === 'danger') + (주 52시간 초과!) + @else + (주 48시간 초과 경고) + @endif + +
+
+ @endforeach +
+@endif diff --git a/resources/views/hr/attendances/partials/requests.blade.php b/resources/views/hr/attendances/partials/requests.blade.php new file mode 100644 index 00000000..e1baec58 --- /dev/null +++ b/resources/views/hr/attendances/partials/requests.blade.php @@ -0,0 +1,110 @@ +{{-- 근태 신청/승인 목록 (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/resources/views/hr/attendances/partials/summary.blade.php b/resources/views/hr/attendances/partials/summary.blade.php new file mode 100644 index 00000000..d24f9144 --- /dev/null +++ b/resources/views/hr/attendances/partials/summary.blade.php @@ -0,0 +1,58 @@ +{{-- 사원별 월간 요약 (HTMX로 로드) --}} +@php + use App\Models\HR\Attendance; +@endphp + +
+

{{ $year }}년 {{ $month }}월 사원별 요약

+
+ +@if(empty($summary)) +
+ + + +

해당 월의 근태 데이터가 없습니다.

+
+@else + + + + + + + + + @foreach(Attendance::STATUS_MAP as $key => $label) + + @endforeach + + + + @foreach($summary as $item) + + + + + + @foreach(Attendance::STATUS_MAP as $key => $label) + @php $cnt = $item['statuses'][$key] ?? 0; @endphp + + @endforeach + + @endforeach + +
사원부서근무일총근무(h){{ $label }}
+
+
+ {{ mb_substr($item['name'], 0, 1) }} +
+ {{ $item['name'] }} +
+
{{ $item['department'] }}{{ $item['total_days'] }}일 + {{ $item['total_minutes'] > 0 ? round($item['total_minutes'] / 60, 1) : '-' }} + + {{ $cnt ?: '-' }} +
+
+@endif diff --git a/resources/views/hr/attendances/partials/table.blade.php b/resources/views/hr/attendances/partials/table.blade.php index d297ba5c..01f887c2 100644 --- a/resources/views/hr/attendances/partials/table.blade.php +++ b/resources/views/hr/attendances/partials/table.blade.php @@ -17,6 +17,7 @@ 출근 퇴근 비고 + GPS 작업 @@ -81,6 +82,27 @@ {{ $attendance->remarks ?? '' }} + {{-- GPS --}} + + @php $gpsData = $attendance->json_details['gps_data'] ?? null; @endphp + @if($gpsData) + + @else + + + + + + + @endif + + {{-- 작업 --}}
@@ -110,7 +132,7 @@ class="text-red-600 hover:text-red-800" title="삭제"> @empty - +
diff --git a/routes/api.php b/routes/api.php index 6c56e82c..2d9378f8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1063,7 +1063,12 @@ // 근태현황 API Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/attendances')->name('api.admin.hr.attendances.')->group(function () { Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'stats'])->name('stats'); + Route::get('/calendar', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'calendar'])->name('calendar'); + Route::get('/summary', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'summary'])->name('summary'); + Route::get('/overtime-alerts', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'overtimeAlerts'])->name('overtime-alerts'); Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'export'])->name('export'); + Route::get('/leave-balance/{userId}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'leaveBalance'])->name('leave-balance'); + Route::post('/bulk-store', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkStore'])->name('bulk-store'); Route::post('/bulk-delete', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkDestroy'])->name('bulk-delete'); Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'index'])->name('index'); Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'store'])->name('store'); @@ -1071,3 +1076,10 @@ 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'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..9054c3d9 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// 매일 23:50 자동 결근 처리 +Schedule::command('attendance:mark-absent')->dailyAt('23:50');