From 2f739d0d55275b959022829d377b156f78a5b91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 08:24:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[hr]=20=EC=9E=85=ED=87=B4=EC=82=AC?= =?UTF-8?q?=EC=9E=90=20=ED=98=84=ED=99=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmployeeService에 근속기간 조회/통계/CSV 내보내기 메서드 추가 - API 컨트롤러에 tenure/tenureExport 엔드포인트 추가 - EmployeeTenureController 뷰 컨트롤러 생성 - 통계 카드 6개 (전체/재직/퇴직/평균근속/올해입사/올해퇴사) - HTMX 테이블 (사원/부서/직책/상태/입사일/퇴사일/근속기간/근속일수) - 필터: 이름검색, 부서, 상태, 입사기간 범위, 정렬 - CSV 엑셀 다운로드 기능 --- .../Api/Admin/HR/EmployeeController.php | 115 +++++++++++ .../HR/EmployeeTenureController.php | 34 ++++ app/Services/HR/EmployeeService.php | 187 ++++++++++++++++++ .../views/hr/employee-tenure/index.blade.php | 141 +++++++++++++ .../employee-tenure/partials/table.blade.php | 116 +++++++++++ routes/api.php | 2 + routes/web.php | 3 + 7 files changed, 598 insertions(+) create mode 100644 app/Http/Controllers/HR/EmployeeTenureController.php create mode 100644 resources/views/hr/employee-tenure/index.blade.php create mode 100644 resources/views/hr/employee-tenure/partials/table.blade.php diff --git a/app/Http/Controllers/Api/Admin/HR/EmployeeController.php b/app/Http/Controllers/Api/Admin/HR/EmployeeController.php index b625f122..bcd8d9b6 100644 --- a/app/Http/Controllers/Api/Admin/HR/EmployeeController.php +++ b/app/Http/Controllers/Api/Admin/HR/EmployeeController.php @@ -6,11 +6,13 @@ use App\Models\Boards\File; use App\Services\GoogleCloudStorageService; use App\Services\HR\EmployeeService; +use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\StreamedResponse; class EmployeeController extends Controller { @@ -383,6 +385,119 @@ public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gc abort(404, '파일이 서버에 존재하지 않습니다.'); } + /** + * 입퇴사자 현황 목록 (HTMX → HTML / 일반 → JSON) + */ + public function tenure(Request $request): JsonResponse|Response + { + $employees = $this->employeeService->getEmployeeTenure( + $request->all(), + $request->integer('per_page', 50) + ); + + // 근속기간 계산 추가 + $employees->getCollection()->each(function ($employee) { + $hireDate = $employee->hire_date; + if ($hireDate) { + $hire = Carbon::parse($hireDate); + $end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today(); + $tenureDays = $hire->diffInDays($end); + $diff = $hire->diff($end); + + $employee->tenure_days = $tenureDays; + $employee->tenure_label = $this->formatTenureLabel($diff); + } else { + $employee->tenure_days = 0; + $employee->tenure_label = '-'; + } + }); + + if ($request->header('HX-Request')) { + $stats = $this->employeeService->getTenureStats(); + + return response(view('hr.employee-tenure.partials.table', compact('employees', 'stats'))); + } + + return response()->json([ + 'success' => true, + 'data' => $employees->items(), + 'meta' => [ + 'current_page' => $employees->currentPage(), + 'last_page' => $employees->lastPage(), + 'per_page' => $employees->perPage(), + 'total' => $employees->total(), + ], + ]); + } + + /** + * 입퇴사자 현황 CSV 내보내기 + */ + public function tenureExport(Request $request): StreamedResponse + { + $employees = $this->employeeService->getTenureExportData($request->all()); + + $filename = '입퇴사자현황_'.now()->format('Ymd').'.csv'; + + return response()->streamDownload(function () use ($employees) { + $handle = fopen('php://output', 'w'); + + // BOM for Excel UTF-8 + fwrite($handle, "\xEF\xBB\xBF"); + + // 헤더 + fputcsv($handle, ['No.', '사원명', '부서', '직책', '상태', '입사일', '퇴사일', '근속기간', '근속일수']); + + $index = 1; + foreach ($employees as $employee) { + $hireDate = $employee->hire_date; + $tenureDays = 0; + $tenureLabel = '-'; + + if ($hireDate) { + $hire = Carbon::parse($hireDate); + $end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today(); + $tenureDays = $hire->diffInDays($end); + $tenureLabel = $this->formatTenureLabel($hire->diff($end)); + } + + $statusMap = ['active' => '재직', 'leave' => '휴직', 'resigned' => '퇴직']; + + fputcsv($handle, [ + $index++, + $employee->display_name ?? $employee->user?->name ?? '-', + $employee->department?->name ?? '-', + $employee->position_label ?? '-', + $statusMap[$employee->employee_status] ?? $employee->employee_status, + $employee->hire_date ?? '-', + $employee->resign_date ?? '-', + $tenureLabel, + $tenureDays, + ]); + } + + fclose($handle); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + private function formatTenureLabel(\DateInterval $diff): string + { + $parts = []; + if ($diff->y > 0) { + $parts[] = "{$diff->y}년"; + } + if ($diff->m > 0) { + $parts[] = "{$diff->m}개월"; + } + if ($diff->d > 0 || empty($parts)) { + $parts[] = "{$diff->d}일"; + } + + return implode(' ', $parts); + } + /** * 직급/직책 추가 */ diff --git a/app/Http/Controllers/HR/EmployeeTenureController.php b/app/Http/Controllers/HR/EmployeeTenureController.php new file mode 100644 index 00000000..fc04641f --- /dev/null +++ b/app/Http/Controllers/HR/EmployeeTenureController.php @@ -0,0 +1,34 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('hr.employee-tenure')); + } + + $stats = $this->employeeService->getTenureStats(); + $departments = $this->employeeService->getDepartments(); + + return view('hr.employee-tenure.index', [ + 'stats' => $stats, + 'departments' => $departments, + ]); + } +} diff --git a/app/Services/HR/EmployeeService.php b/app/Services/HR/EmployeeService.php index ac404342..e33bee4b 100644 --- a/app/Services/HR/EmployeeService.php +++ b/app/Services/HR/EmployeeService.php @@ -6,7 +6,9 @@ use App\Models\HR\Position; use App\Models\Tenants\Department; use App\Models\User; +use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -380,6 +382,191 @@ public function getPositions(string $type = 'rank'): \Illuminate\Database\Eloque ->get(['id', 'key', 'name']); } + /** + * 입퇴사자 현황 조회 (페이지네이션) + */ + public function getEmployeeTenure(array $filters = [], int $perPage = 50): LengthAwarePaginator + { + $tenantId = session('selected_tenant_id'); + + $query = Employee::query() + ->with(['user', 'department']) + ->forTenant($tenantId) + ->whereNotNull('json_extra->hire_date'); + + // 이름 검색 + if (! empty($filters['q'])) { + $search = $filters['q']; + $query->where(function ($q) use ($search) { + $q->where('display_name', 'like', "%{$search}%") + ->orWhereHas('user', function ($uq) use ($search) { + $uq->where('name', 'like', "%{$search}%"); + }); + }); + } + + // 부서 필터 + if (! empty($filters['department_id'])) { + $query->where('department_id', $filters['department_id']); + } + + // 상태 필터 + if (! empty($filters['status'])) { + if ($filters['status'] === 'active') { + $query->where('employee_status', 'active'); + } elseif ($filters['status'] === 'resigned') { + $query->where('employee_status', 'resigned'); + } + } + + // 입사기간 범위 + if (! empty($filters['hire_from'])) { + $query->where('json_extra->hire_date', '>=', $filters['hire_from']); + } + if (! empty($filters['hire_to'])) { + $query->where('json_extra->hire_date', '<=', $filters['hire_to']); + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'hire_date_desc'; + switch ($sortBy) { + case 'hire_date_asc': + $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC"); + break; + case 'tenure_desc': + $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC"); + break; + case 'tenure_asc': + $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC"); + break; + default: // hire_date_desc + $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC"); + break; + } + + return $query->paginate($perPage); + } + + /** + * 입퇴사자 통계 + */ + public function getTenureStats(): array + { + $tenantId = session('selected_tenant_id'); + + $baseQuery = Employee::query() + ->forTenant($tenantId) + ->whereNotNull('json_extra->hire_date'); + + $total = (clone $baseQuery)->count(); + $active = (clone $baseQuery)->where('employee_status', 'active')->count(); + $resigned = (clone $baseQuery)->where('employee_status', 'resigned')->count(); + + // 올해 입사자 + $thisYear = now()->year; + $hiredThisYear = (clone $baseQuery) + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) LIKE ?", ["{$thisYear}%"]) + ->count(); + + // 올해 퇴사자 + $resignedThisYear = Employee::query() + ->forTenant($tenantId) + ->whereNotNull('json_extra->resign_date') + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) LIKE ?", ["{$thisYear}%"]) + ->count(); + + // 평균 근속기간 (재직자 기준) + $activeEmployees = (clone $baseQuery) + ->where('employee_status', 'active') + ->get(['json_extra']); + + $avgTenureDays = 0; + if ($activeEmployees->isNotEmpty()) { + $totalDays = 0; + $count = 0; + foreach ($activeEmployees as $emp) { + $hireDate = $emp->json_extra['hire_date'] ?? null; + if ($hireDate) { + $totalDays += Carbon::parse($hireDate)->diffInDays(today()); + $count++; + } + } + $avgTenureDays = $count > 0 ? (int) round($totalDays / $count) : 0; + } + + return [ + 'total' => $total, + 'active' => $active, + 'resigned' => $resigned, + 'hired_this_year' => $hiredThisYear, + 'resigned_this_year' => $resignedThisYear, + 'avg_tenure_days' => $avgTenureDays, + 'avg_tenure_label' => $this->formatTenure($avgTenureDays), + ]; + } + + /** + * CSV 내보내기용 전체 데이터 + */ + public function getTenureExportData(array $filters = []): Collection + { + $tenantId = session('selected_tenant_id'); + + $query = Employee::query() + ->with(['user', 'department']) + ->forTenant($tenantId) + ->whereNotNull('json_extra->hire_date'); + + if (! empty($filters['department_id'])) { + $query->where('department_id', $filters['department_id']); + } + + if (! empty($filters['status'])) { + if ($filters['status'] === 'active') { + $query->where('employee_status', 'active'); + } elseif ($filters['status'] === 'resigned') { + $query->where('employee_status', 'resigned'); + } + } + + if (! empty($filters['hire_from'])) { + $query->where('json_extra->hire_date', '>=', $filters['hire_from']); + } + if (! empty($filters['hire_to'])) { + $query->where('json_extra->hire_date', '<=', $filters['hire_to']); + } + + return $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC")->get(); + } + + /** + * 근속일수 → "N년 M개월 D일" 변환 + */ + public function formatTenure(int $days): string + { + if ($days <= 0) { + return '0일'; + } + + $years = intdiv($days, 365); + $remaining = $days % 365; + $months = intdiv($remaining, 30); + $d = $remaining % 30; + + $parts = []; + if ($years > 0) { + $parts[] = "{$years}년"; + } + if ($months > 0) { + $parts[] = "{$months}개월"; + } + if ($d > 0 || empty($parts)) { + $parts[] = "{$d}일"; + } + + return implode(' ', $parts); + } + /** * 직급/직책 추가 */ diff --git a/resources/views/hr/employee-tenure/index.blade.php b/resources/views/hr/employee-tenure/index.blade.php new file mode 100644 index 00000000..17310d6f --- /dev/null +++ b/resources/views/hr/employee-tenure/index.blade.php @@ -0,0 +1,141 @@ +@extends('layouts.app') + +@section('title', '입퇴사자 현황') + +@section('content') +
+ {{-- 페이지 헤더 --}} +
+
+

입퇴사자 현황

+

{{ now()->format('Y년 n월 j일') }} 현재

+
+ +
+ + {{-- 통계 카드 --}} +
+
+
전체 사원
+
{{ $stats['total'] }}명
+
+
+
재직
+
{{ $stats['active'] }}명
+
+
+
퇴직
+
{{ $stats['resigned'] }}명
+
+
+
평균 근속기간
+
{{ $stats['avg_tenure_label'] }}
+
+
+
올해 입사
+
{{ $stats['hired_this_year'] }}명
+
+
+
올해 퇴사
+
{{ $stats['resigned_this_year'] }}명
+
+
+ + {{-- 테이블 컨테이너 --}} +
+ {{-- 필터 --}} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + {{-- HTMX 테이블 영역 --}} +
+
+
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/hr/employee-tenure/partials/table.blade.php b/resources/views/hr/employee-tenure/partials/table.blade.php new file mode 100644 index 00000000..a07a51d6 --- /dev/null +++ b/resources/views/hr/employee-tenure/partials/table.blade.php @@ -0,0 +1,116 @@ +{{-- 입퇴사자 현황 테이블 (HTMX로 로드) --}} + + + + + + + + + + + + + + + + + @forelse($employees as $employee) + + {{-- No. --}} + + + {{-- 사원 --}} + + + {{-- 부서 --}} + + + {{-- 직책 --}} + + + {{-- 상태 --}} + + + {{-- 입사일 --}} + + + {{-- 퇴사일 --}} + + + {{-- 근속기간 --}} + + + {{-- 근속일수 --}} + + + @empty + + + + @endforelse + +
No.사원부서직책상태입사일퇴사일근속기간근속일수
+ {{ ($employees->currentPage() - 1) * $employees->perPage() + $loop->iteration }} + + +
+ {{ mb_substr($employee->display_name ?? $employee->user?->name ?? '?', 0, 1) }} +
+ + {{ $employee->display_name ?? $employee->user?->name ?? '-' }} + +
+
+ {{ $employee->department?->name ?? '-' }} + + {{ $employee->position_label ?? '-' }} + + @if($employee->employee_status === 'active') + + 재직 + + @elseif($employee->employee_status === 'resigned') + + 퇴직 + + @else + + {{ $employee->employee_status === 'leave' ? '휴직' : ($employee->employee_status ?? '-') }} + + @endif + + {{ $employee->hire_date ?? '-' }} + + {{ $employee->resign_date ?? '-' }} + + {{ $employee->tenure_label ?? '-' }} + + {{ number_format($employee->tenure_days ?? 0) }}일 +
+
+ + + +

입사일이 등록된 사원이 없습니다.

+
+
+
+ +{{-- 하단 요약 + 페이지네이션 --}} +@if($employees->isNotEmpty()) +
+
+
+ 총 {{ number_format($employees->total()) }}명 + @if(isset($stats)) + · 평균 근속 {{ $stats['avg_tenure_label'] }} + @endif +
+ @if($employees->hasPages()) +
{{ $employees->links() }}
+ @endif +
+
+@endif diff --git a/routes/api.php b/routes/api.php index 2d9378f8..df141363 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1043,6 +1043,8 @@ Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/employees')->name('api.admin.hr.employees.')->group(function () { Route::get('/search-users', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'searchUsers'])->name('search-users'); Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'stats'])->name('stats'); + Route::get('/tenure', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'tenure'])->name('tenure'); + Route::get('/tenure-export', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'tenureExport'])->name('tenure-export'); Route::get('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'index'])->name('index'); Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'store'])->name('store'); Route::get('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'show'])->name('show'); diff --git a/routes/web.php b/routes/web.php index 4c473721..5eb42a1f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -897,6 +897,9 @@ Route::get('/{id}/edit', [\App\Http\Controllers\HR\EmployeeController::class, 'edit'])->name('edit'); }); + // 입퇴사자 현황 + 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');