feat: [hr] 직급/직책 인라인 추가 기능 구현
- Position 생성 API 엔드포인트 추가 (POST /admin/hr/positions) - 직급/직책 select 옆 "+" 버튼으로 모달 열기 - 모달에서 이름 입력 → API 저장 → 드롭다운에 자동 추가 및 선택 - 중복 key 방지 (기존 값이면 그대로 반환) - create/edit 뷰 모두 적용
This commit is contained in:
@@ -170,4 +170,27 @@ public function destroy(Request $request, int $id): JsonResponse|Response
|
||||
'message' => '퇴직 처리되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 추가
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ public function getDepartments(): \Illuminate\Database\Eloquent\Collection
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급 목록 (드롭다운용)
|
||||
* 직급/직책 목록 (드롭다운용)
|
||||
*/
|
||||
public function getPositions(string $type = 'rank'): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
@@ -246,4 +246,41 @@ public function getPositions(string $type = 'rank'): \Illuminate\Database\Eloque
|
||||
->ordered()
|
||||
->get(['id', 'key', 'name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 추가
|
||||
*/
|
||||
public function createPosition(string $type, string $name): Position
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
// key 생성: 이름을 소문자+언더스코어로 변환, 한글은 그대로
|
||||
$key = str_replace(' ', '_', mb_strtolower(trim($name)));
|
||||
|
||||
// 중복 체크 후 존재하면 기존 반환
|
||||
$existing = Position::query()
|
||||
->forTenant()
|
||||
->where('type', $type)
|
||||
->where('key', $key)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// 다음 sort_order
|
||||
$maxSort = Position::query()
|
||||
->forTenant()
|
||||
->where('type', $type)
|
||||
->max('sort_order') ?? 0;
|
||||
|
||||
return Position::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => $type,
|
||||
'key' => $key,
|
||||
'name' => trim($name),
|
||||
'sort_order' => $maxSort + 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,24 +98,42 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
|
||||
{{-- 직급 / 직책 --}}
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="position_key" class="block text-sm font-medium text-gray-700 mb-1">직급</label>
|
||||
<select name="position_key" id="position_key"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($ranks as $rank)
|
||||
<option value="{{ $rank->key }}">{{ $rank->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직급</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="position_key" id="position_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($ranks as $rank)
|
||||
<option value="{{ $rank->key }}">{{ $rank->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('rank')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직급 추가">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="job_title_key" class="block text-sm font-medium text-gray-700 mb-1">직책</label>
|
||||
<select name="job_title_key" id="job_title_key"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($titles as $title)
|
||||
<option value="{{ $title->key }}">{{ $title->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직책</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="job_title_key" id="job_title_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($titles as $title)
|
||||
<option value="{{ $title->key }}">{{ $title->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('title')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직책 추가">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +185,8 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 직급/직책 추가 모달 --}}
|
||||
@include('hr.employees.partials.position-add-modal')
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
|
||||
@@ -96,28 +96,46 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
|
||||
{{-- 직급 / 직책 --}}
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="position_key" class="block text-sm font-medium text-gray-700 mb-1">직급</label>
|
||||
<select name="position_key" id="position_key"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($ranks as $rank)
|
||||
<option value="{{ $rank->key }}" {{ $employee->position_key === $rank->key ? 'selected' : '' }}>
|
||||
{{ $rank->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직급</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="position_key" id="position_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($ranks as $rank)
|
||||
<option value="{{ $rank->key }}" {{ $employee->position_key === $rank->key ? 'selected' : '' }}>
|
||||
{{ $rank->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('rank')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직급 추가">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="job_title_key" class="block text-sm font-medium text-gray-700 mb-1">직책</label>
|
||||
<select name="job_title_key" id="job_title_key"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($titles as $title)
|
||||
<option value="{{ $title->key }}" {{ $employee->job_title_key === $title->key ? 'selected' : '' }}>
|
||||
{{ $title->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직책</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="job_title_key" id="job_title_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($titles as $title)
|
||||
<option value="{{ $title->key }}" {{ $employee->job_title_key === $title->key ? 'selected' : '' }}>
|
||||
{{ $title->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('title')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직책 추가">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +188,9 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 직급/직책 추가 모달 --}}
|
||||
@include('hr.employees.partials.position-add-modal')
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
{{-- 직급/직책 추가 모달 --}}
|
||||
<div id="positionModal" class="fixed inset-0 z-50 hidden">
|
||||
{{-- 배경 오버레이 --}}
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closePositionModal()"></div>
|
||||
|
||||
{{-- 모달 본체 --}}
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm relative">
|
||||
{{-- 헤더 --}}
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<h3 id="positionModalTitle" class="text-lg font-semibold text-gray-800">직급 추가</h3>
|
||||
<button type="button" onclick="closePositionModal()"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 바디 --}}
|
||||
<div class="px-5 py-4">
|
||||
<input type="hidden" id="positionType" value="">
|
||||
<label for="positionName" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<span id="positionTypeLabel">직급</span>명
|
||||
</label>
|
||||
<input type="text" id="positionName"
|
||||
placeholder="예: 대리, 과장, 팀장..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();savePosition();}">
|
||||
<div id="positionError" class="text-sm text-red-500 mt-1 hidden"></div>
|
||||
</div>
|
||||
|
||||
{{-- 푸터 --}}
|
||||
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-100">
|
||||
<button type="button" onclick="closePositionModal()"
|
||||
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="savePosition()" id="positionSaveBtn"
|
||||
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openPositionModal(type) {
|
||||
const modal = document.getElementById('positionModal');
|
||||
const title = document.getElementById('positionModalTitle');
|
||||
const typeLabel = document.getElementById('positionTypeLabel');
|
||||
const input = document.getElementById('positionName');
|
||||
const error = document.getElementById('positionError');
|
||||
|
||||
document.getElementById('positionType').value = type;
|
||||
title.textContent = type === 'rank' ? '직급 추가' : '직책 추가';
|
||||
typeLabel.textContent = type === 'rank' ? '직급' : '직책';
|
||||
input.value = '';
|
||||
input.placeholder = type === 'rank' ? '예: 사원, 대리, 과장, 차장, 부장' : '예: 팀장, 실장, 본부장, 대표이사';
|
||||
error.classList.add('hidden');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
|
||||
function closePositionModal() {
|
||||
document.getElementById('positionModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function savePosition() {
|
||||
const type = document.getElementById('positionType').value;
|
||||
const name = document.getElementById('positionName').value.trim();
|
||||
const error = document.getElementById('positionError');
|
||||
const btn = document.getElementById('positionSaveBtn');
|
||||
|
||||
if (!name) {
|
||||
error.textContent = '이름을 입력해주세요.';
|
||||
error.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '저장 중...';
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route("api.admin.hr.positions.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ type, name }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
// 해당 select에 옵션 추가 및 선택
|
||||
const selectId = type === 'rank' ? 'position_key' : 'job_title_key';
|
||||
const select = document.getElementById(selectId);
|
||||
|
||||
// 이미 같은 key가 있는지 확인
|
||||
let exists = false;
|
||||
for (const opt of select.options) {
|
||||
if (opt.value === data.data.key) {
|
||||
opt.selected = true;
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
const option = new Option(data.data.name, data.data.key, true, true);
|
||||
select.add(option);
|
||||
}
|
||||
|
||||
closePositionModal();
|
||||
} else {
|
||||
error.textContent = data.message || '저장에 실패했습니다.';
|
||||
error.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
error.textContent = '서버 오류가 발생했습니다.';
|
||||
error.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '추가';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1049,3 +1049,8 @@
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 직급/직책 관리 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/positions')->name('api.admin.hr.positions.')->group(function () {
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'storePosition'])->name('store');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user