diff --git a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php new file mode 100644 index 00000000..3ba9314d --- /dev/null +++ b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php @@ -0,0 +1,168 @@ +attendanceService->getAttendances( + $request->all(), + $request->integer('per_page', 20) + ); + + if ($request->header('HX-Request')) { + return response(view('hr.attendances.partials.table', compact('attendances'))); + } + + return response()->json([ + 'success' => true, + 'data' => $attendances->items(), + 'meta' => [ + 'current_page' => $attendances->currentPage(), + 'last_page' => $attendances->lastPage(), + 'per_page' => $attendances->perPage(), + 'total' => $attendances->total(), + ], + ]); + } + + /** + * 월간 통계 + */ + public function stats(Request $request): JsonResponse + { + $stats = $this->attendanceService->getMonthlyStats( + $request->integer('year') ?: null, + $request->integer('month') ?: null + ); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 근태 등록 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'user_id' => 'required|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 { + $attendance = $this->attendanceService->storeAttendance($validated); + + return response()->json([ + 'success' => true, + 'message' => '근태가 등록되었습니다.', + 'data' => $attendance, + ], 201); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '근태 등록 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 근태 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'sometimes|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 { + $attendance = $this->attendanceService->updateAttendance($id, $validated); + + if (! $attendance) { + return response()->json([ + 'success' => false, + 'message' => '근태 정보를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '근태가 수정되었습니다.', + 'data' => $attendance, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '근태 수정 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 근태 삭제 + */ + public function destroy(Request $request, int $id): JsonResponse|Response + { + try { + $result = $this->attendanceService->deleteAttendance($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '근태 정보를 찾을 수 없습니다.', + ], 404); + } + + if ($request->header('HX-Request')) { + $attendances = $this->attendanceService->getAttendances( + $request->all(), + $request->integer('per_page', 20) + ); + + return response(view('hr.attendances.partials.table', compact('attendances'))); + } + + 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); + } + } +} diff --git a/app/Http/Controllers/HR/AttendanceController.php b/app/Http/Controllers/HR/AttendanceController.php new file mode 100644 index 00000000..dfb7fd37 --- /dev/null +++ b/app/Http/Controllers/HR/AttendanceController.php @@ -0,0 +1,33 @@ +attendanceService->getMonthlyStats(); + $departments = $this->attendanceService->getDepartments(); + $employees = $this->attendanceService->getActiveEmployees(); + $statusMap = Attendance::STATUS_MAP; + + return view('hr.attendances.index', [ + 'stats' => $stats, + 'departments' => $departments, + 'employees' => $employees, + 'statusMap' => $statusMap, + ]); + } +} diff --git a/app/Models/HR/Attendance.php b/app/Models/HR/Attendance.php new file mode 100644 index 00000000..4ee20db5 --- /dev/null +++ b/app/Models/HR/Attendance.php @@ -0,0 +1,147 @@ + 'array', + 'base_date' => 'date', + 'tenant_id' => 'int', + 'user_id' => 'int', + ]; + + protected $attributes = [ + 'status' => 'onTime', + ]; + + public const STATUS_MAP = [ + 'onTime' => '정시출근', + 'late' => '지각', + 'absent' => '결근', + 'vacation' => '휴가', + 'businessTrip' => '출장', + 'fieldWork' => '외근', + 'overtime' => '야근', + 'remote' => '재택', + ]; + + public const STATUS_COLORS = [ + 'onTime' => 'emerald', + 'late' => 'amber', + 'absent' => 'red', + 'vacation' => 'blue', + 'businessTrip' => 'purple', + 'fieldWork' => 'indigo', + 'overtime' => 'orange', + 'remote' => 'teal', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + // ========================================================================= + // Accessor + // ========================================================================= + + public function getCheckInAttribute(): ?string + { + $checkIns = $this->json_details['check_ins'] ?? []; + + if (! empty($checkIns)) { + $times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkIns)); + if (! empty($times)) { + sort($times); + + return $times[0]; + } + } + + return $this->json_details['check_in'] ?? null; + } + + public function getCheckOutAttribute(): ?string + { + $checkOuts = $this->json_details['check_outs'] ?? []; + + if (! empty($checkOuts)) { + $times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkOuts)); + if (! empty($times)) { + rsort($times); + + return $times[0]; + } + } + + return $this->json_details['check_out'] ?? null; + } + + public function getWorkMinutesAttribute(): ?int + { + return isset($this->json_details['work_minutes']) + ? (int) $this->json_details['work_minutes'] + : null; + } + + 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; + } + + public function scopeOnDate($query, string $date) + { + return $query->whereDate('base_date', $date); + } + + public function scopeBetweenDates($query, string $startDate, string $endDate) + { + return $query->whereBetween('base_date', [$startDate, $endDate]); + } +} diff --git a/app/Services/HR/AttendanceService.php b/app/Services/HR/AttendanceService.php new file mode 100644 index 00000000..dd3d0d10 --- /dev/null +++ b/app/Services/HR/AttendanceService.php @@ -0,0 +1,226 @@ +with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)]) + ->forTenant($tenantId); + + // 이름 검색 + if (! empty($filters['q'])) { + $search = $filters['q']; + $query->whereHas('user', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + // 부서 필터 + 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['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->whereDate('base_date', '>=', $filters['date_from']); + } elseif (! empty($filters['date_to'])) { + $query->whereDate('base_date', '<=', $filters['date_to']); + } + + $query->orderBy('base_date', 'desc') + ->orderBy('created_at', 'desc'); + + return $query->paginate($perPage); + } + + /** + * 월간 통계 (상태별 카운트) + */ + public function getMonthlyStats(?int $year = null, ?int $month = null): array + { + $tenantId = session('selected_tenant_id'); + $year = $year ?? now()->year; + $month = $month ?? now()->month; + + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = now()->year == $year && now()->month == $month + ? now()->toDateString() + : sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year)); + + $counts = Attendance::query() + ->forTenant($tenantId) + ->betweenDates($startDate, $endDate) + ->select('status', DB::raw('COUNT(*) as cnt')) + ->groupBy('status') + ->pluck('cnt', 'status') + ->toArray(); + + return [ + 'onTime' => $counts['onTime'] ?? 0, + 'late' => $counts['late'] ?? 0, + 'absent' => $counts['absent'] ?? 0, + 'vacation' => $counts['vacation'] ?? 0, + 'etc' => ($counts['businessTrip'] ?? 0) + ($counts['fieldWork'] ?? 0) + ($counts['overtime'] ?? 0) + ($counts['remote'] ?? 0), + 'year' => $year, + 'month' => $month, + ]; + } + + /** + * 근태 등록 (Upsert: tenant_id + user_id + base_date) + */ + public function storeAttendance(array $data): Attendance + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + $jsonDetails = []; + if (! empty($data['check_in'])) { + $jsonDetails['check_in'] = $data['check_in']; + } + if (! empty($data['check_out'])) { + $jsonDetails['check_out'] = $data['check_out']; + } + + $attendance = Attendance::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'base_date' => $data['base_date'], + ], + [ + 'status' => $data['status'] ?? 'onTime', + 'json_details' => ! empty($jsonDetails) ? $jsonDetails : null, + 'remarks' => $data['remarks'] ?? null, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ] + ); + + return $attendance->load('user'); + }); + } + + /** + * 근태 수정 + */ + public function updateAttendance(int $id, array $data): ?Attendance + { + $tenantId = session('selected_tenant_id'); + + $attendance = Attendance::query() + ->forTenant($tenantId) + ->find($id); + + if (! $attendance) { + return null; + } + + $updateData = []; + + if (array_key_exists('status', $data)) { + $updateData['status'] = $data['status']; + } + if (array_key_exists('remarks', $data)) { + $updateData['remarks'] = $data['remarks']; + } + + // json_details 업데이트 + $jsonDetails = $attendance->json_details ?? []; + if (array_key_exists('check_in', $data)) { + if ($data['check_in']) { + $jsonDetails['check_in'] = $data['check_in']; + } else { + unset($jsonDetails['check_in']); + } + } + if (array_key_exists('check_out', $data)) { + if ($data['check_out']) { + $jsonDetails['check_out'] = $data['check_out']; + } else { + unset($jsonDetails['check_out']); + } + } + $updateData['json_details'] = ! empty($jsonDetails) ? $jsonDetails : null; + $updateData['updated_by'] = auth()->id(); + + $attendance->update($updateData); + + return $attendance->fresh('user'); + } + + /** + * 근태 삭제 + */ + public function deleteAttendance(int $id): bool + { + $tenantId = session('selected_tenant_id'); + + $attendance = Attendance::query() + ->forTenant($tenantId) + ->find($id); + + if (! $attendance) { + return false; + } + + $attendance->update(['deleted_by' => auth()->id()]); + $attendance->delete(); + + return true; + } + + /** + * 부서 목록 (드롭다운용) + */ + 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 Employee::query() + ->with('user:id,name') + ->forTenant($tenantId) + ->activeEmployees() + ->orderBy('display_name') + ->get(['id', 'user_id', 'display_name', 'department_id']); + } +} diff --git a/resources/views/hr/attendances/index.blade.php b/resources/views/hr/attendances/index.blade.php new file mode 100644 index 00000000..70f85390 --- /dev/null +++ b/resources/views/hr/attendances/index.blade.php @@ -0,0 +1,332 @@ +@extends('layouts.app') + +@section('title', '근태현황') + +@section('content') +
{{ $stats['year'] }}년 {{ $stats['month'] }}월 현재
+| 날짜 | +사원 | +부서 | +상태 | +출근 | +퇴근 | +비고 | +작업 | +
|---|---|---|---|---|---|---|---|
| + {{ $attendance->base_date->format('m-d') }} + {{ ['일','월','화','수','목','금','토'][$attendance->base_date->dayOfWeek] }} + | + + {{-- 사원 --}} +
+
+
+
+ {{ mb_substr($displayName, 0, 1) }}
+
+ {{ $displayName }}
+ |
+
+ {{-- 부서 --}}
+ + {{ $department?->name ?? '-' }} + | + + {{-- 상태 --}} ++ + {{ $label }} + + | + + {{-- 출근 --}} ++ {{ $checkIn }} + | + + {{-- 퇴근 --}} ++ {{ $checkOut }} + | + + {{-- 비고 --}} ++ {{ $attendance->remarks ?? '' }} + | + + {{-- 작업 --}} +
+
+ {{-- 수정 --}}
+
+
+ {{-- 삭제 --}}
+
+
+ |
+
|
+
+
+
+ 근태 기록이 없습니다. + |
+ |||||||