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'] }}월 현재

+
+
+ +
+
+ + {{-- 통계 카드 --}} +
+
+
정시출근
+
{{ $stats['onTime'] }}건
+
+
+
지각
+
{{ $stats['late'] }}건
+
+
+
결근
+
{{ $stats['absent'] }}건
+
+
+
휴가
+
{{ $stats['vacation'] }}건
+
+
+
기타
+
{{ $stats['etc'] }}건
+
+
+ + {{-- 테이블 컨테이너 --}} +
+ {{-- 필터 --}} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + {{-- HTMX 테이블 영역 --}} +
+
+
+
+
+
+
+ +{{-- 근태 등록/수정 모달 --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/hr/attendances/partials/table.blade.php b/resources/views/hr/attendances/partials/table.blade.php new file mode 100644 index 00000000..1d327452 --- /dev/null +++ b/resources/views/hr/attendances/partials/table.blade.php @@ -0,0 +1,123 @@ +{{-- 근태현황 테이블 (HTMX로 로드) --}} +@php + use App\Models\HR\Attendance; +@endphp + + + + + + + + + + + + + + + + + @forelse($attendances as $attendance) + @php + $profile = $attendance->user?->tenantProfiles?->first(); + $department = $profile?->department; + $displayName = $profile?->display_name ?? $attendance->user?->name ?? '-'; + $color = Attendance::STATUS_COLORS[$attendance->status] ?? 'gray'; + $label = Attendance::STATUS_MAP[$attendance->status] ?? $attendance->status; + $checkIn = $attendance->check_in ? substr($attendance->check_in, 0, 5) : '-'; + $checkOut = $attendance->check_out ? substr($attendance->check_out, 0, 5) : '-'; + @endphp + + {{-- 날짜 --}} + + + {{-- 사원 --}} + + + {{-- 부서 --}} + + + {{-- 상태 --}} + + + {{-- 출근 --}} + + + {{-- 퇴근 --}} + + + {{-- 비고 --}} + + + {{-- 작업 --}} + + + @empty + + + + @endforelse + +
날짜사원부서상태출근퇴근비고작업
+ {{ $attendance->base_date->format('m-d') }} + {{ ['일','월','화','수','목','금','토'][$attendance->base_date->dayOfWeek] }} + +
+
+ {{ mb_substr($displayName, 0, 1) }} +
+ {{ $displayName }} +
+
+ {{ $department?->name ?? '-' }} + + + {{ $label }} + + + {{ $checkIn }} + + {{ $checkOut }} + + {{ $attendance->remarks ?? '' }} + +
+ {{-- 수정 --}} + + + {{-- 삭제 --}} + +
+
+
+ + + +

근태 기록이 없습니다.

+
+
+
+ +{{-- 페이지네이션 --}} +@if($attendances->hasPages()) +
+ {{ $attendances->links() }} +
+@endif diff --git a/routes/api.php b/routes/api.php index a4287267..d9b67568 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1060,3 +1060,12 @@ Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'storePosition'])->name('store'); }); +// 근태현황 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('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'update'])->name('update'); + Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'destroy'])->name('destroy'); +}); + diff --git a/routes/web.php b/routes/web.php index d1bed16e..4c473721 100644 --- a/routes/web.php +++ b/routes/web.php @@ -896,6 +896,11 @@ Route::get('/{id}', [\App\Http\Controllers\HR\EmployeeController::class, 'show'])->name('show'); Route::get('/{id}/edit', [\App\Http\Controllers\HR\EmployeeController::class, 'edit'])->name('edit'); }); + + // 근태현황 + Route::prefix('attendances')->name('attendances.')->group(function () { + Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index'); + }); }); /*