employeeService->getEmployees( $request->all(), $request->integer('per_page', 20) ); if ($request->header('HX-Request')) { return response(view('hr.employees.partials.table', compact('employees'))); } return response()->json([ 'success' => true, 'data' => $employees->items(), 'meta' => [ 'current_page' => $employees->currentPage(), 'last_page' => $employees->lastPage(), 'per_page' => $employees->perPage(), 'total' => $employees->total(), ], ]); } /** * 기존 사용자 검색 (사원 미등록, 테넌트 소속) */ public function searchUsers(Request $request): JsonResponse { $users = $this->employeeService->searchTenantUsers($request->get('q') ?? ''); return response()->json([ 'success' => true, 'data' => $users, ]); } /** * 사원 통계 */ public function stats(): JsonResponse { $stats = $this->employeeService->getStats(); return response()->json([ 'success' => true, 'data' => $stats, ]); } /** * 사원 등록 */ public function store(Request $request): JsonResponse { $rules = [ 'existing_user_id' => 'nullable|integer|exists:users,id', 'name' => 'required|string|max:50', 'email' => 'nullable|email|max:100', 'phone' => 'nullable|string|max:20', 'department_id' => 'nullable|integer|exists:departments,id', 'position_key' => 'nullable|string|max:50', 'job_title_key' => 'nullable|string|max:50', 'work_location_key' => 'nullable|string|max:50', 'employment_type_key' => 'nullable|string|max:50', 'employee_status' => 'nullable|string|in:active,leave,resigned', 'manager_user_id' => 'nullable|integer|exists:users,id', 'display_name' => 'nullable|string|max:50', 'hire_date' => 'nullable|date', 'resign_date' => 'nullable|date', 'address' => 'nullable|string|max:200', 'emergency_contact' => 'nullable|string|max:100', 'resident_number' => 'nullable|string|max:14', 'bank_account.bank_code' => 'nullable|string|max:20', 'bank_account.bank_name' => 'nullable|string|max:50', 'bank_account.account_holder' => 'nullable|string|max:50', 'bank_account.account_number' => 'nullable|string|max:30', 'dependents' => 'nullable|array', 'dependents.*.name' => 'required_with:dependents|string|max:50', 'dependents.*.nationality' => 'nullable|string|in:korean,foreigner', 'dependents.*.resident_number' => 'nullable|string|max:14', 'dependents.*.relationship' => 'nullable|string|max:20', 'dependents.*.is_disabled' => 'nullable|boolean', 'dependents.*.is_dependent' => 'nullable|boolean', ]; // 신규 사용자일 때만 이메일 unique 검증 if (! $request->filled('existing_user_id')) { $rules['email'] = 'nullable|email|max:100|unique:users,email'; } $validated = $request->validate($rules); try { $employee = $this->employeeService->createEmployee($validated); return response()->json([ 'success' => true, 'message' => '사원이 등록되었습니다.', 'data' => $employee, ], 201); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '사원 등록 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 사원 상세 조회 */ public function show(int $id): JsonResponse { $employee = $this->employeeService->getEmployeeById($id); if (! $employee) { return response()->json([ 'success' => false, 'message' => '사원 정보를 찾을 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'data' => $employee, ]); } /** * 사원 수정 */ public function update(Request $request, int $id): JsonResponse { $validated = $request->validate([ 'name' => 'sometimes|required|string|max:50', 'email' => 'nullable|email|max:100', 'phone' => 'nullable|string|max:20', 'department_id' => 'nullable|integer|exists:departments,id', 'position_key' => 'nullable|string|max:50', 'job_title_key' => 'nullable|string|max:50', 'work_location_key' => 'nullable|string|max:50', 'employment_type_key' => 'nullable|string|max:50', 'employee_status' => 'nullable|string|in:active,leave,resigned', 'manager_user_id' => 'nullable|integer|exists:users,id', 'display_name' => 'nullable|string|max:50', 'hire_date' => 'nullable|date', 'resign_date' => 'nullable|date', 'address' => 'nullable|string|max:200', 'emergency_contact' => 'nullable|string|max:100', 'resident_number' => 'nullable|string|max:14', 'bank_account.bank_code' => 'nullable|string|max:20', 'bank_account.bank_name' => 'nullable|string|max:50', 'bank_account.account_holder' => 'nullable|string|max:50', 'bank_account.account_number' => 'nullable|string|max:30', 'dependents' => 'nullable|array', 'dependents.*.name' => 'required_with:dependents|string|max:50', 'dependents.*.nationality' => 'nullable|string|in:korean,foreigner', 'dependents.*.resident_number' => 'nullable|string|max:14', 'dependents.*.relationship' => 'nullable|string|max:20', 'dependents.*.is_disabled' => 'nullable|boolean', 'dependents.*.is_dependent' => 'nullable|boolean', ]); // 부양가족 섹션이 포함된 폼인데 dependents 데이터가 없으면 → 전체 삭제 if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) { $validated['dependents'] = []; } try { $employee = $this->employeeService->updateEmployee($id, $validated); if (! $employee) { return response()->json([ 'success' => false, 'message' => '사원 정보를 찾을 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '사원 정보가 수정되었습니다.', 'data' => $employee, ]); } 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->employeeService->deleteEmployee($id); if (! $result) { if ($request->header('HX-Request')) { return response()->json([ 'success' => false, 'message' => '사원 정보를 찾을 수 없습니다.', ], 404); } return response()->json([ 'success' => false, 'message' => '사원 정보를 찾을 수 없습니다.', ], 404); } if ($request->header('HX-Request')) { $employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20)); return response(view('hr.employees.partials.table', compact('employees'))); } 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); } } /** * 사원 영구삭제 (퇴직자만, 슈퍼관리자 전용) */ public function forceDestroy(Request $request, int $id): JsonResponse|Response { if (! auth()->user()?->is_super_admin) { return response()->json([ 'success' => false, 'message' => '슈퍼관리자만 영구삭제할 수 있습니다.', ], 403); } try { $result = $this->employeeService->forceDeleteEmployee($id); if (! $result['success']) { return response()->json($result, 422); } if ($request->header('HX-Request')) { $employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20)); return response(view('hr.employees.partials.table', compact('employees'))); } return response()->json($result); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '영구삭제 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 사원 제외/복원 토글 */ public function toggleExclude(Request $request, int $id): JsonResponse|Response { $employee = $this->employeeService->toggleExclude($id); if (! $employee) { return response()->json([ 'success' => false, 'message' => '사원 정보를 찾을 수 없습니다.', ], 404); } $isExcluded = $employee->getJsonExtraValue('is_excluded', false); if ($request->header('HX-Request')) { $employees = $this->employeeService->getEmployees( $request->all(), $request->integer('per_page', 20) ); return response(view('hr.employees.partials.table', compact('employees'))); } return response()->json([ 'success' => true, 'message' => $isExcluded ? '사원이 목록에서 제외되었습니다.' : '사원이 목록에 복원되었습니다.', 'is_excluded' => $isExcluded, ]); } /** * 사원 첨부파일 업로드 (로컬 + GCS 듀얼 저장) */ public function uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): JsonResponse { $employee = $this->employeeService->getEmployeeById($id); if (! $employee) { return response()->json(['success' => false, 'message' => '사원 정보를 찾을 수 없습니다.'], 404); } $request->validate([ 'files' => 'required|array|max:10', 'files.*' => 'file|max:20480', ]); $tenantId = session('selected_tenant_id'); $uploaded = []; foreach ($request->file('files') as $file) { $storedName = Str::random(40).'.'.$file->getClientOriginalExtension(); $storagePath = "employees/{$tenantId}/{$employee->id}/{$storedName}"; // 로컬 저장 Storage::disk('tenant')->put($storagePath, file_get_contents($file)); // GCS 업로드 $gcsUri = null; $gcsObjectName = null; if ($gcs->isAvailable()) { $gcsObjectName = $storagePath; $gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName); } $fileRecord = File::create([ 'tenant_id' => $tenantId, 'document_id' => $employee->id, 'document_type' => 'employee_profile', 'original_name' => $file->getClientOriginalName(), 'stored_name' => $storedName, 'file_path' => $storagePath, 'mime_type' => $file->getMimeType(), 'file_size' => $file->getSize(), 'file_type' => strtolower($file->getClientOriginalExtension()), 'gcs_object_name' => $gcsObjectName, 'gcs_uri' => $gcsUri, 'uploaded_by' => auth()->id(), 'created_by' => auth()->id(), ]); $uploaded[] = $fileRecord; } return response()->json([ 'success' => true, 'message' => count($uploaded).'개 파일이 업로드되었습니다.', 'data' => $uploaded, ], 201); } /** * 사원 첨부파일 삭제 (GCS + 로컬 모두 삭제) */ public function deleteFile(int $id, int $fileId, GoogleCloudStorageService $gcs): JsonResponse { $tenantId = session('selected_tenant_id'); $file = File::where('id', $fileId) ->where('document_id', $id) ->where('document_type', 'employee_profile') ->where('tenant_id', $tenantId) ->first(); if (! $file) { return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404); } // GCS 삭제 if ($gcs->isAvailable() && $file->gcs_object_name) { $gcs->delete($file->gcs_object_name); } // 로컬 삭제 if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) { Storage::disk('tenant')->delete($file->file_path); } $file->deleted_by = auth()->id(); $file->save(); $file->delete(); return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']); } /** * 사원 첨부파일 다운로드 (GCS Signed URL 우선, 로컬 폴백) */ public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gcs) { $tenantId = session('selected_tenant_id'); $file = File::where('id', $fileId) ->where('document_id', $id) ->where('document_type', 'employee_profile') ->where('tenant_id', $tenantId) ->first(); if (! $file) { abort(404, '파일을 찾을 수 없습니다.'); } // GCS Signed URL로 리다이렉트 if ($gcs->isAvailable() && $file->gcs_object_name) { $signedUrl = $gcs->getSignedUrl($file->gcs_object_name, 60); if ($signedUrl) { return redirect($signedUrl); } } // 로컬 폴백 $disk = Storage::disk('tenant'); if ($file->file_path && $disk->exists($file->file_path)) { return $disk->download($file->file_path, $file->original_name); } 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); } /** * 직급/직책 추가 */ public function storePosition(Request $request): JsonResponse { $validated = $request->validate([ 'type' => 'required|string|in:rank,title', 'name' => 'required|string|max:50', ]); $position = $this->employeeService->createPosition($validated['type'], $validated['name']); return response()->json([ 'success' => true, 'message' => ($validated['type'] === 'rank' ? '직급' : '직책').'이 추가되었습니다.', 'data' => [ 'id' => $position->id, 'key' => $position->key, 'name' => $position->name, ], ], 201); } }