feat: [hr] 사원 등록 - 기존 직원 불러오기 기능 추가
- 검색 API (GET /api/admin/hr/employees/search-users) - 테넌트 소속 + 사원 미등록 사용자 검색 - 기존 사용자 선택 시 Employee만 생성 (User 생성 건너뜀) - Alpine.js 검색 UI (포커스시 목록, debounce 검색, 선택/해제)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 구성
|
||||
|
||||
@@ -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"> · </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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user