feat: [hr] 사원 등록 - 기존 직원 불러오기 기능 추가

- 검색 API (GET /api/admin/hr/employees/search-users)
- 테넌트 소속 + 사원 미등록 사용자 검색
- 기존 사용자 선택 시 Employee만 생성 (User 생성 건너뜀)
- Alpine.js 검색 UI (포커스시 목록, debounce 검색, 선택/해제)
This commit is contained in:
김보곤
2026-02-26 17:35:54 +09:00
parent 2b3cb3bb92
commit 20fd449c39
4 changed files with 264 additions and 36 deletions

View File

@@ -40,6 +40,19 @@ public function index(Request $request): JsonResponse|Response
]);
}
/**
* 기존 사용자 검색 (사원 미등록, 테넌트 소속)
*/
public function searchUsers(Request $request): JsonResponse
{
$users = $this->employeeService->searchTenantUsers($request->get('q', ''));
return response()->json([
'success' => true,
'data' => $users,
]);
}
/**
* 사원 통계
*/
@@ -58,9 +71,10 @@ public function stats(): JsonResponse
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
$rules = [
'existing_user_id' => 'nullable|integer|exists:users,id',
'name' => 'required|string|max:50',
'email' => 'nullable|email|max:100|unique:users,email',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'password' => 'nullable|string|min:6',
'department_id' => 'nullable|integer|exists:departments,id',
@@ -75,7 +89,14 @@ public function store(Request $request): JsonResponse
'hire_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
]);
];
// 신규 사용자일 때만 이메일 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);

View File

@@ -85,6 +85,39 @@ public function getStats(): array
];
}
/**
* 테넌트 소속이지만 사원 미등록인 사용자 검색
*/
public function searchTenantUsers(string $query): array
{
$tenantId = session('selected_tenant_id');
$builder = User::query()
->select('users.id', 'users.name', 'users.email', 'users.phone')
->join('user_tenants as ut', function ($join) use ($tenantId) {
$join->on('users.id', '=', 'ut.user_id')
->where('ut.tenant_id', $tenantId)
->whereNull('ut.deleted_at');
})
->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) {
$join->on('users.id', '=', 'tup.user_id')
->where('tup.tenant_id', $tenantId);
})
->whereNull('tup.id')
->whereNull('users.deleted_at');
if ($query !== '') {
$like = "%{$query}%";
$builder->where(function ($q) use ($like) {
$q->where('users.name', 'like', $like)
->orWhere('users.email', 'like', $like)
->orWhere('users.phone', 'like', $like);
});
}
return $builder->orderBy('users.name')->limit(20)->get()->toArray();
}
/**
* 사원 등록 (User + TenantUserProfile 동시 생성)
*/
@@ -93,46 +126,65 @@ public function createEmployee(array $data): Employee
$tenantId = session('selected_tenant_id');
return DB::transaction(function () use ($data, $tenantId) {
// user_id 생성: 이메일 있으면 @ 앞부분, 없으면 EMP_랜덤6자
$userId = ! empty($data['email'])
? Str::before($data['email'], '@')
: 'EMP_'.strtolower(Str::random(6));
// 기존 사용자 선택 분기
if (! empty($data['existing_user_id'])) {
$user = User::findOrFail($data['existing_user_id']);
// user_id 중복 방지
while (User::where('user_id', $userId)->exists()) {
$userId = $userId.'_'.Str::random(3);
}
// 테넌트 소속 검증
$isMember = $user->tenants()
->wherePivot('tenant_id', $tenantId)
->wherePivotNull('deleted_at')
->exists();
// 이메일: 미입력 시 임시 이메일 생성 (NOT NULL 제약)
$email = ! empty($data['email'])
? $data['email']
: $userId.'@placeholder.local';
if (! $isMember) {
throw new \RuntimeException('해당 사용자는 현재 테넌트에 소속되어 있지 않습니다.');
}
// email 중복 방지
while (User::where('email', $email)->exists()) {
$email = $userId.'_'.Str::random(3).'@placeholder.local';
}
// 이미 사원 등록 여부 확인
$alreadyEmployee = Employee::where('tenant_id', $tenantId)
->where('user_id', $user->id)
->exists();
// User 생성
$user = User::create([
'user_id' => $userId,
'name' => $data['name'],
'email' => $email,
'phone' => $data['phone'] ?? null,
'password' => Hash::make($data['password'] ?? 'sam1234!'),
'role' => 'ops',
'is_active' => true,
'must_change_password' => true,
'created_by' => auth()->id(),
]);
if ($alreadyEmployee) {
throw new \RuntimeException('이미 사원으로 등록된 사용자입니다.');
}
} else {
// 신규 사용자 생성
$loginId = ! empty($data['email'])
? Str::before($data['email'], '@')
: 'EMP_'.strtolower(Str::random(6));
// user_tenants pivot 연동 (멀티테넌트 필수)
if ($tenantId) {
$user->tenants()->attach($tenantId, [
while (User::where('user_id', $loginId)->exists()) {
$loginId = $loginId.'_'.Str::random(3);
}
$email = ! empty($data['email'])
? $data['email']
: $loginId.'@placeholder.local';
while (User::where('email', $email)->exists()) {
$email = $loginId.'_'.Str::random(3).'@placeholder.local';
}
$user = User::create([
'user_id' => $loginId,
'name' => $data['name'],
'email' => $email,
'phone' => $data['phone'] ?? null,
'password' => Hash::make($data['password'] ?? 'sam1234!'),
'role' => 'ops',
'is_active' => true,
'is_default' => true,
'joined_at' => now(),
'must_change_password' => true,
'created_by' => auth()->id(),
]);
if ($tenantId) {
$user->tenants()->attach($tenantId, [
'is_active' => true,
'is_default' => true,
'joined_at' => now(),
]);
}
}
// json_extra 구성

View File

@@ -26,6 +26,74 @@ class="space-y-6">
<div id="form-message"></div>
{{-- 기존 직원 불러오기 --}}
<div x-data="userSearch()" class="border border-blue-200 bg-blue-50 rounded-lg p-4">
<h3 class="text-sm font-semibold text-blue-800 mb-3">기존 직원 불러오기</h3>
<template x-if="!selectedUser">
<div class="relative">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text"
x-model="query"
@input.debounce.300ms="search()"
@focus="onFocus()"
@click.outside="showDropdown = false"
placeholder="이름, 이메일, 연락처로 검색..."
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-sm">
</div>
{{-- 검색 결과 드롭다운 --}}
<div x-show="showDropdown"
x-transition
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
<template x-if="loading">
<div class="px-4 py-3 text-sm text-gray-500 text-center">검색 ...</div>
</template>
<template x-if="!loading && users.length === 0">
<div class="px-4 py-3 text-sm text-gray-500 text-center">검색 결과가 없습니다</div>
</template>
<template x-for="user in users" :key="user.id">
<button type="button"
@click="selectUser(user)"
class="w-full text-left px-4 py-2.5 hover:bg-blue-50 border-b border-gray-100 last:border-0 transition-colors">
<div class="text-sm font-medium text-gray-800" x-text="user.name"></div>
<div class="text-xs text-gray-500">
<span x-show="user.email" x-text="user.email"></span>
<span x-show="user.email && user.phone"> &middot; </span>
<span x-show="user.phone" x-text="user.phone"></span>
</div>
</button>
</template>
</div>
</div>
</template>
{{-- 선택된 사용자 표시 --}}
<template x-if="selectedUser">
<div class="flex items-center justify-between bg-white border border-green-300 rounded-lg px-4 py-2.5">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<span class="text-sm font-medium text-gray-800" x-text="selectedUser.name"></span>
<span class="text-xs text-gray-500 ml-1" x-text="selectedUser.email"></span>
</div>
</div>
<button type="button" @click="clearSelection()"
class="text-xs text-red-500 hover:text-red-700 font-medium shrink-0">
선택 해제
</button>
</div>
</template>
<input type="hidden" name="existing_user_id" :value="selectedUser ? selectedUser.id : ''">
<p class="text-xs text-blue-600 mt-2">이미 시스템에 등록된 사용자를 사원으로 추가할 있습니다.</p>
</div>
{{-- 기본 정보 --}}
<div class="border-b border-gray-200 pb-4 mb-4">
<h2 class="text-lg font-semibold text-gray-700">기본 정보</h2>
@@ -191,6 +259,92 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-
@push('scripts')
<script>
function userSearch() {
return {
query: '',
users: [],
selectedUser: null,
showDropdown: false,
loading: false,
async search() {
this.loading = true;
this.showDropdown = true;
try {
const res = await fetch(`{{ route('api.admin.hr.employees.search-users') }}?q=${encodeURIComponent(this.query)}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
const json = await res.json();
this.users = json.data || [];
} catch (e) {
this.users = [];
} finally {
this.loading = false;
}
},
onFocus() {
if (this.users.length === 0) this.search();
else this.showDropdown = true;
},
selectUser(user) {
this.selectedUser = user;
this.showDropdown = false;
this.query = '';
// 폼 필드 채우기
const nameEl = document.getElementById('name');
const emailEl = document.getElementById('email');
const phoneEl = document.getElementById('phone');
const passwordEl = document.getElementById('password');
nameEl.value = user.name || '';
emailEl.value = user.email || '';
phoneEl.value = user.phone || '';
// readonly 설정
nameEl.readOnly = true;
emailEl.readOnly = true;
phoneEl.readOnly = true;
passwordEl.readOnly = true;
passwordEl.placeholder = '기존 사용자는 비밀번호를 변경하지 않습니다';
// 시각적 구분
[nameEl, emailEl, phoneEl, passwordEl].forEach(el => {
el.classList.add('bg-gray-100', 'text-gray-500');
});
},
clearSelection() {
this.selectedUser = null;
const nameEl = document.getElementById('name');
const emailEl = document.getElementById('email');
const phoneEl = document.getElementById('phone');
const passwordEl = document.getElementById('password');
// 값 초기화
nameEl.value = '';
emailEl.value = '';
phoneEl.value = '';
passwordEl.value = '';
// readonly 해제
nameEl.readOnly = false;
emailEl.readOnly = false;
phoneEl.readOnly = false;
passwordEl.readOnly = false;
passwordEl.placeholder = '미입력 시 기본 비밀번호(sam1234!) 설정';
// 시각적 구분 해제
[nameEl, emailEl, phoneEl, passwordEl].forEach(el => {
el.classList.remove('bg-gray-100', 'text-gray-500');
});
}
};
}
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.elt.id !== 'employeeForm') return;

View File

@@ -1041,6 +1041,7 @@
|--------------------------------------------------------------------------
*/
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('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'store'])->name('store');