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
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$rules = [
|
||||||
|
'existing_user_id' => 'nullable|integer|exists:users,id',
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'email' => 'nullable|email|max:100|unique:users,email',
|
'email' => 'nullable|email|max:100',
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
'password' => 'nullable|string|min:6',
|
'password' => 'nullable|string|min:6',
|
||||||
'department_id' => 'nullable|integer|exists:departments,id',
|
'department_id' => 'nullable|integer|exists:departments,id',
|
||||||
@@ -75,7 +89,14 @@ public function store(Request $request): JsonResponse
|
|||||||
'hire_date' => 'nullable|date',
|
'hire_date' => 'nullable|date',
|
||||||
'address' => 'nullable|string|max:200',
|
'address' => 'nullable|string|max:200',
|
||||||
'emergency_contact' => 'nullable|string|max:100',
|
'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 {
|
try {
|
||||||
$employee = $this->employeeService->createEmployee($validated);
|
$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 동시 생성)
|
* 사원 등록 (User + TenantUserProfile 동시 생성)
|
||||||
*/
|
*/
|
||||||
@@ -93,46 +126,65 @@ public function createEmployee(array $data): Employee
|
|||||||
$tenantId = session('selected_tenant_id');
|
$tenantId = session('selected_tenant_id');
|
||||||
|
|
||||||
return DB::transaction(function () use ($data, $tenantId) {
|
return DB::transaction(function () use ($data, $tenantId) {
|
||||||
// user_id 생성: 이메일 있으면 @ 앞부분, 없으면 EMP_랜덤6자
|
// 기존 사용자 선택 분기
|
||||||
$userId = ! empty($data['email'])
|
if (! empty($data['existing_user_id'])) {
|
||||||
? Str::before($data['email'], '@')
|
$user = User::findOrFail($data['existing_user_id']);
|
||||||
: 'EMP_'.strtolower(Str::random(6));
|
|
||||||
|
|
||||||
// user_id 중복 방지
|
// 테넌트 소속 검증
|
||||||
while (User::where('user_id', $userId)->exists()) {
|
$isMember = $user->tenants()
|
||||||
$userId = $userId.'_'.Str::random(3);
|
->wherePivot('tenant_id', $tenantId)
|
||||||
}
|
->wherePivotNull('deleted_at')
|
||||||
|
->exists();
|
||||||
|
|
||||||
// 이메일: 미입력 시 임시 이메일 생성 (NOT NULL 제약)
|
if (! $isMember) {
|
||||||
$email = ! empty($data['email'])
|
throw new \RuntimeException('해당 사용자는 현재 테넌트에 소속되어 있지 않습니다.');
|
||||||
? $data['email']
|
}
|
||||||
: $userId.'@placeholder.local';
|
|
||||||
|
|
||||||
// email 중복 방지
|
// 이미 사원 등록 여부 확인
|
||||||
while (User::where('email', $email)->exists()) {
|
$alreadyEmployee = Employee::where('tenant_id', $tenantId)
|
||||||
$email = $userId.'_'.Str::random(3).'@placeholder.local';
|
->where('user_id', $user->id)
|
||||||
}
|
->exists();
|
||||||
|
|
||||||
// User 생성
|
if ($alreadyEmployee) {
|
||||||
$user = User::create([
|
throw new \RuntimeException('이미 사원으로 등록된 사용자입니다.');
|
||||||
'user_id' => $userId,
|
}
|
||||||
'name' => $data['name'],
|
} else {
|
||||||
'email' => $email,
|
// 신규 사용자 생성
|
||||||
'phone' => $data['phone'] ?? null,
|
$loginId = ! empty($data['email'])
|
||||||
'password' => Hash::make($data['password'] ?? 'sam1234!'),
|
? Str::before($data['email'], '@')
|
||||||
'role' => 'ops',
|
: 'EMP_'.strtolower(Str::random(6));
|
||||||
'is_active' => true,
|
|
||||||
'must_change_password' => true,
|
|
||||||
'created_by' => auth()->id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// user_tenants pivot 연동 (멀티테넌트 필수)
|
while (User::where('user_id', $loginId)->exists()) {
|
||||||
if ($tenantId) {
|
$loginId = $loginId.'_'.Str::random(3);
|
||||||
$user->tenants()->attach($tenantId, [
|
}
|
||||||
|
|
||||||
|
$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_active' => true,
|
||||||
'is_default' => true,
|
'must_change_password' => true,
|
||||||
'joined_at' => now(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($tenantId) {
|
||||||
|
$user->tenants()->attach($tenantId, [
|
||||||
|
'is_active' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// json_extra 구성
|
// json_extra 구성
|
||||||
|
|||||||
@@ -26,6 +26,74 @@ class="space-y-6">
|
|||||||
|
|
||||||
<div id="form-message"></div>
|
<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">
|
<div class="border-b border-gray-200 pb-4 mb-4">
|
||||||
<h2 class="text-lg font-semibold text-gray-700">기본 정보</h2>
|
<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')
|
@push('scripts')
|
||||||
<script>
|
<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) {
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
if (event.detail.elt.id !== 'employeeForm') return;
|
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::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('/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::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');
|
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'store'])->name('store');
|
||||||
|
|||||||
Reference in New Issue
Block a user