diff --git a/app/Http/Controllers/Api/Admin/HR/EmployeeController.php b/app/Http/Controllers/Api/Admin/HR/EmployeeController.php new file mode 100644 index 00000000..85252b8f --- /dev/null +++ b/app/Http/Controllers/Api/Admin/HR/EmployeeController.php @@ -0,0 +1,173 @@ +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 stats(): JsonResponse + { + $stats = $this->employeeService->getStats(); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 사원 등록 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:50', + 'email' => 'nullable|email|max:100|unique:users,email', + 'phone' => 'nullable|string|max:20', + 'password' => 'nullable|string|min:6', + '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', + 'employee_code' => 'nullable|string|max:30', + 'hire_date' => 'nullable|date', + 'address' => 'nullable|string|max:200', + 'emergency_contact' => 'nullable|string|max:100', + ]); + + $employee = $this->employeeService->createEmployee($validated); + + return response()->json([ + 'success' => true, + 'message' => '사원이 등록되었습니다.', + 'data' => $employee, + ], 201); + } + + /** + * 사원 상세 조회 + */ + 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', + 'employee_code' => 'nullable|string|max:30', + 'hire_date' => 'nullable|date', + 'address' => 'nullable|string|max:200', + 'emergency_contact' => 'nullable|string|max:100', + ]); + + $employee = $this->employeeService->updateEmployee($id, $validated); + + if (! $employee) { + return response()->json([ + 'success' => false, + 'message' => '사원 정보를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '사원 정보가 수정되었습니다.', + 'data' => $employee, + ]); + } + + /** + * 사원 삭제 (퇴직 처리) + */ + public function destroy(Request $request, int $id): JsonResponse|Response + { + $result = $this->employeeService->deleteEmployee($id); + + if (! $result) { + 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' => '퇴직 처리되었습니다.', + ]); + } +} diff --git a/app/Http/Controllers/HR/EmployeeController.php b/app/Http/Controllers/HR/EmployeeController.php new file mode 100644 index 00000000..73bc42bc --- /dev/null +++ b/app/Http/Controllers/HR/EmployeeController.php @@ -0,0 +1,83 @@ +employeeService->getStats(); + $departments = $this->employeeService->getDepartments(); + + return view('hr.employees.index', [ + 'stats' => $stats, + 'departments' => $departments, + ]); + } + + /** + * 사원 등록 폼 + */ + public function create(): View + { + $departments = $this->employeeService->getDepartments(); + $ranks = $this->employeeService->getPositions('rank'); + $titles = $this->employeeService->getPositions('title'); + + return view('hr.employees.create', [ + 'departments' => $departments, + 'ranks' => $ranks, + 'titles' => $titles, + ]); + } + + /** + * 사원 상세 페이지 + */ + public function show(int $id): View + { + $employee = $this->employeeService->getEmployeeById($id); + + if (! $employee) { + abort(404, '사원 정보를 찾을 수 없습니다.'); + } + + return view('hr.employees.show', [ + 'employee' => $employee, + ]); + } + + /** + * 사원 수정 폼 + */ + public function edit(int $id): View + { + $employee = $this->employeeService->getEmployeeById($id); + + if (! $employee) { + abort(404, '사원 정보를 찾을 수 없습니다.'); + } + + $departments = $this->employeeService->getDepartments(); + $ranks = $this->employeeService->getPositions('rank'); + $titles = $this->employeeService->getPositions('title'); + + return view('hr.employees.edit', [ + 'employee' => $employee, + 'departments' => $departments, + 'ranks' => $ranks, + 'titles' => $titles, + ]); + } +} diff --git a/app/Models/HR/Employee.php b/app/Models/HR/Employee.php new file mode 100644 index 00000000..b576837b --- /dev/null +++ b/app/Models/HR/Employee.php @@ -0,0 +1,166 @@ + 'array', + 'tenant_id' => 'int', + 'user_id' => 'int', + 'department_id' => 'int', + 'manager_user_id' => 'int', + ]; + + protected $appends = [ + 'employee_code', + 'hire_date', + 'position_label', + 'job_title_label', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_user_id'); + } + + // ========================================================================= + // json_extra Accessor + // ========================================================================= + + public function getEmployeeCodeAttribute(): ?string + { + return $this->json_extra['employee_code'] ?? null; + } + + public function getHireDateAttribute(): ?string + { + return $this->json_extra['hire_date'] ?? null; + } + + public function getAddressAttribute(): ?string + { + return $this->json_extra['address'] ?? null; + } + + public function getEmergencyContactAttribute(): ?string + { + return $this->json_extra['emergency_contact'] ?? null; + } + + public function getPositionLabelAttribute(): ?string + { + if (! $this->position_key || ! $this->tenant_id) { + return $this->position_key; + } + + $position = Position::where('tenant_id', $this->tenant_id) + ->where('type', Position::TYPE_RANK) + ->where('key', $this->position_key) + ->first(); + + return $position?->name ?? $this->position_key; + } + + public function getJobTitleLabelAttribute(): ?string + { + if (! $this->job_title_key || ! $this->tenant_id) { + return $this->job_title_key; + } + + $position = Position::where('tenant_id', $this->tenant_id) + ->where('type', Position::TYPE_TITLE) + ->where('key', $this->job_title_key) + ->first(); + + return $position?->name ?? $this->job_title_key; + } + + // ========================================================================= + // json_extra 헬퍼 + // ========================================================================= + + public function getJsonExtraValue(string $key, mixed $default = null): mixed + { + return $this->json_extra[$key] ?? $default; + } + + public function setJsonExtraValue(string $key, mixed $value): void + { + $extra = $this->json_extra ?? []; + if ($value === null) { + unset($extra[$key]); + } else { + $extra[$key] = $value; + } + $this->json_extra = $extra; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where('tenant_id', $tenantId); + } + + return $query; + } + + public function scopeActiveEmployees($query) + { + return $query->where('employee_status', 'active'); + } + + public function scopeOnLeave($query) + { + return $query->where('employee_status', 'leave'); + } + + public function scopeResigned($query) + { + return $query->where('employee_status', 'resigned'); + } +} diff --git a/app/Models/HR/Position.php b/app/Models/HR/Position.php new file mode 100644 index 00000000..4152fd26 --- /dev/null +++ b/app/Models/HR/Position.php @@ -0,0 +1,61 @@ + 'int', + 'sort_order' => 'int', + 'is_active' => 'bool', + ]; + + public const TYPE_RANK = 'rank'; + + public const TYPE_TITLE = 'title'; + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where('tenant_id', $tenantId); + } + + return $query; + } + + public function scopeRanks($query) + { + return $query->where('type', self::TYPE_RANK); + } + + public function scopeTitles($query) + { + return $query->where('type', self::TYPE_TITLE); + } + + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } +} diff --git a/app/Services/HR/EmployeeService.php b/app/Services/HR/EmployeeService.php new file mode 100644 index 00000000..42441d5a --- /dev/null +++ b/app/Services/HR/EmployeeService.php @@ -0,0 +1,237 @@ +with(['user', 'department']) + ->forTenant($tenantId); + + // 검색 필터 (이름, 사번, 이메일) + 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}%") + ->orWhere('email', 'like', "%{$search}%") + ->orWhere('phone', 'like', "%{$search}%"); + }) + ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.employee_code')) LIKE ?", ["%{$search}%"]); + }); + } + + // 상태 필터 + if (! empty($filters['status'])) { + $query->where('employee_status', $filters['status']); + } + + // 부서 필터 + if (! empty($filters['department_id'])) { + $query->where('department_id', $filters['department_id']); + } + + // 정렬 + $query->orderByRaw("FIELD(employee_status, 'active', 'leave', 'resigned')") + ->orderBy('created_at', 'desc'); + + return $query->paginate($perPage); + } + + /** + * 사원 상세 조회 + */ + public function getEmployeeById(int $id): ?Employee + { + $tenantId = session('selected_tenant_id'); + + return Employee::query() + ->with(['user', 'department', 'manager']) + ->forTenant($tenantId) + ->find($id); + } + + /** + * 사원 통계 + */ + public function getStats(): array + { + $tenantId = session('selected_tenant_id'); + + $baseQuery = Employee::query()->forTenant($tenantId); + + return [ + 'total' => (clone $baseQuery)->count(), + 'active' => (clone $baseQuery)->where('employee_status', 'active')->count(), + 'leave' => (clone $baseQuery)->where('employee_status', 'leave')->count(), + 'resigned' => (clone $baseQuery)->where('employee_status', 'resigned')->count(), + ]; + } + + /** + * 사원 등록 (User + TenantUserProfile 동시 생성) + */ + public function createEmployee(array $data): Employee + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + // User 생성 + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'] ?? null, + 'phone' => $data['phone'] ?? null, + 'password' => bcrypt($data['password'] ?? 'sam1234!'), + 'is_active' => true, + ]); + + // json_extra 구성 + $jsonExtra = []; + if (! empty($data['employee_code'])) { + $jsonExtra['employee_code'] = $data['employee_code']; + } + if (! empty($data['hire_date'])) { + $jsonExtra['hire_date'] = $data['hire_date']; + } + if (! empty($data['address'])) { + $jsonExtra['address'] = $data['address']; + } + if (! empty($data['emergency_contact'])) { + $jsonExtra['emergency_contact'] = $data['emergency_contact']; + } + + // Employee(TenantUserProfile) 생성 + $employee = Employee::create([ + 'tenant_id' => $tenantId, + 'user_id' => $user->id, + 'department_id' => $data['department_id'] ?? null, + 'position_key' => $data['position_key'] ?? null, + 'job_title_key' => $data['job_title_key'] ?? null, + 'work_location_key' => $data['work_location_key'] ?? null, + 'employment_type_key' => $data['employment_type_key'] ?? null, + 'employee_status' => $data['employee_status'] ?? 'active', + 'manager_user_id' => $data['manager_user_id'] ?? null, + 'display_name' => $data['display_name'] ?? $data['name'], + 'json_extra' => ! empty($jsonExtra) ? $jsonExtra : null, + ]); + + return $employee->load(['user', 'department']); + }); + } + + /** + * 사원 정보 수정 + */ + public function updateEmployee(int $id, array $data): ?Employee + { + $employee = $this->getEmployeeById($id); + if (! $employee) { + return null; + } + + // 기본 필드 업데이트 + $updateData = array_filter([ + 'department_id' => $data['department_id'] ?? null, + 'position_key' => $data['position_key'] ?? null, + 'job_title_key' => $data['job_title_key'] ?? null, + 'work_location_key' => $data['work_location_key'] ?? null, + 'employment_type_key' => $data['employment_type_key'] ?? null, + 'employee_status' => $data['employee_status'] ?? null, + 'manager_user_id' => $data['manager_user_id'] ?? null, + 'display_name' => $data['display_name'] ?? null, + ], fn ($v) => $v !== null); + + // json_extra 업데이트 + $jsonExtraKeys = ['employee_code', 'hire_date', 'address', 'emergency_contact', 'salary', 'bank_account']; + $extra = $employee->json_extra ?? []; + foreach ($jsonExtraKeys as $key) { + if (array_key_exists($key, $data)) { + if ($data[$key] === null || $data[$key] === '') { + unset($extra[$key]); + } else { + $extra[$key] = $data[$key]; + } + } + } + $updateData['json_extra'] = ! empty($extra) ? $extra : null; + + $employee->update($updateData); + + // User 기본정보 동기화 + if ($employee->user) { + $userUpdate = []; + if (! empty($data['name'])) { + $userUpdate['name'] = $data['name']; + } + if (! empty($data['email'])) { + $userUpdate['email'] = $data['email']; + } + if (! empty($data['phone'])) { + $userUpdate['phone'] = $data['phone']; + } + if (! empty($userUpdate)) { + $employee->user->update($userUpdate); + } + } + + return $employee->fresh(['user', 'department']); + } + + /** + * 사원 삭제 (퇴직 처리) + */ + public function deleteEmployee(int $id): bool + { + $employee = $this->getEmployeeById($id); + if (! $employee) { + return false; + } + + $employee->update(['employee_status' => 'resigned']); + + 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 getPositions(string $type = 'rank'): \Illuminate\Database\Eloquent\Collection + { + return Position::query() + ->forTenant() + ->where('type', $type) + ->where('is_active', true) + ->ordered() + ->get(['id', 'key', 'name']); + } +} diff --git a/resources/views/hr/employees/create.blade.php b/resources/views/hr/employees/create.blade.php new file mode 100644 index 00000000..9def6fc0 --- /dev/null +++ b/resources/views/hr/employees/create.blade.php @@ -0,0 +1,185 @@ +@extends('layouts.app') + +@section('title', '사원 등록') + +@section('content') +
{{ now()->format('Y년 n월 j일') }} 현재
+| 사원 | +부서 | +직급/직책 | +상태 | +입사일 | +연락처 | +작업 | +
|---|---|---|---|---|---|---|
|
+
+
+ {{ mb_substr($employee->display_name ?? $employee->user?->name ?? '?', 0, 1) }}
+
+
+
+
+
+ {{ $employee->display_name ?? $employee->user?->name ?? '-' }}
+
+ @if($employee->employee_code)
+ {{ $employee->employee_code }}
+ @endif
+ |
+
+ {{-- 부서 --}}
+ + {{ $employee->department?->name ?? '-' }} + | + + {{-- 직급/직책 --}} +
+ {{ $employee->position_label ?? '-' }}
+ @if($employee->job_title_label)
+ {{ $employee->job_title_label }}
+ @endif
+ |
+
+ {{-- 상태 --}}
+ + @switch($employee->employee_status) + @case('active') + + 재직 + + @break + @case('leave') + + 휴직 + + @break + @case('resigned') + + 퇴직 + + @break + @default + + {{ $employee->employee_status ?? '-' }} + + @endswitch + | + + {{-- 입사일 --}} ++ {{ $employee->hire_date ?? '-' }} + | + + {{-- 연락처 --}} ++ {{ $employee->user?->phone ?? $employee->user?->email ?? '-' }} + | + + {{-- 작업 --}} ++ + | +
|
+
+
+
+ 등록된 사원이 없습니다. + + 첫 번째 사원 등록하기 → + + |
+ ||||||