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일') }} 현재
+| 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) }}일 + | +
|
+
+
+
+ 입사일이 등록된 사원이 없습니다. + |
+ ||||||||