feat: [hr] 휴가관리 참조자 선택 기능 추가

- 휴가 신청 모달에 참조자 검색/선택 UI 추가 (Alpine.js)
- 부서별 사용자 목록에서 참조자 검색 및 녹색 칩 표시
- LeaveController 참조 배열 유효성 검증 추가
- LeaveService에서 결재선 steps에 참조자 자동 병합
This commit is contained in:
김보곤
2026-03-10 00:09:09 +09:00
parent fff45cf707
commit 512f01bea6
3 changed files with 141 additions and 2 deletions

View File

@@ -53,6 +53,8 @@ public function store(Request $request): JsonResponse
'end_date' => 'required|date|after_or_equal:start_date',
'reason' => 'nullable|string|max:1000',
'approval_line_id' => 'nullable|integer|exists:approval_lines,id',
'references' => 'nullable|array',
'references.*.user_id' => 'required|integer|exists:users,id',
]);
try {

View File

@@ -118,7 +118,7 @@ public function storeLeave(array $data): Leave
]);
// 결재 자동 생성 + 상신 (유형에 맞는 결재양식 자동 선택)
$approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null);
$approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null, $data['references'] ?? []);
$leave->update(['approval_id' => $approval->id]);
return $leave;
@@ -992,7 +992,7 @@ public function sendPromotionNotices(array $employeeIds, string $noticeType, int
/**
* 휴가/근태신청/사유서 결재 자동 생성 + 상신
*/
private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null): Approval
private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null, array $references = []): Approval
{
$approvalService = app(ApprovalService::class);
@@ -1033,6 +1033,21 @@ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approval
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
])->toArray();
// 4-1. 개별 참조자 추가 (결재선에 없는 사용자만)
if (! empty($references)) {
$existingUserIds = collect($steps)->pluck('user_id')->toArray();
foreach ($references as $ref) {
$refUserId = (int) ($ref['user_id'] ?? 0);
if ($refUserId && ! in_array($refUserId, $existingUserIds)) {
$steps[] = [
'user_id' => $refUserId,
'step_type' => 'reference',
];
$existingUserIds[] = $refUserId;
}
}
}
// 5. 결재 제목 생성 (유형별 차별화)
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
$userName = $leave->user->name ?? '';

View File

@@ -272,6 +272,56 @@ class="text-xs text-blue-600 hover:underline mt-1">
@endif
</div>
{{-- 참조 선택 --}}
<div x-data="leaveReferenceSelector()" x-init="init()">
<label class="block text-sm font-medium text-gray-700 mb-1">참조</label>
<div class="flex flex-wrap gap-1.5 mb-2" x-show="references.length > 0" x-cloak>
<template x-for="(ref, idx) in references" :key="ref.user_id">
<span class="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-100 text-emerald-700 text-xs font-medium rounded-full">
<span x-text="ref.user_name"></span>
<button type="button" @click="removeReference(idx)" class="hover:text-red-500 font-bold leading-none">&times;</button>
</span>
</template>
</div>
<div class="relative">
<input type="text" x-model="searchQuery" @focus="showDropdown = true" @input="showDropdown = true"
placeholder="참조자 검색 (이름/부서)..."
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:bg-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<div x-show="showDropdown" @click.outside="showDropdown = false"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto"
x-cloak>
<template x-if="loading">
<div class="p-3 text-center text-xs text-gray-400">로딩 ...</div>
</template>
<template x-if="!loading">
<div>
<template x-for="dept in filteredDepartments" :key="dept.department_id ?? 'none'">
<div>
<div class="px-3 py-1 bg-gray-50 text-[10px] font-semibold text-gray-500 sticky top-0" x-text="dept.department_name"></div>
<template x-for="user in dept.users" :key="user.id">
<button type="button"
@click="addReference(user); searchQuery = ''"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-emerald-50 flex items-center justify-between"
:class="isAdded(user.id) ? 'opacity-40 cursor-not-allowed' : ''"
:disabled="isAdded(user.id)">
<span>
<span class="font-medium" x-text="user.name"></span>
<span class="text-gray-400 ml-1" x-text="user.position || user.job_title || ''"></span>
</span>
<span x-show="isAdded(user.id)" class="text-[10px] text-gray-400">추가됨</span>
</button>
</template>
</div>
</template>
<template x-if="filteredDepartments.length === 0">
<div class="p-3 text-center text-xs text-gray-400">검색 결과가 없습니다.</div>
</template>
</div>
</template>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
<textarea name="reason" rows="3" maxlength="1000" placeholder="휴가 사유를 입력하세요..."
@@ -554,6 +604,72 @@ function exportLeaves() {
window.location.href = '{{ route("api.admin.hr.leaves.export") }}?' + params.toString();
}
// ===== 참조 선택기 (Alpine 컴포넌트) =====
window._leaveReferences = [];
function leaveReferenceSelector() {
return {
departments: [],
references: [],
searchQuery: '',
showDropdown: false,
loading: true,
async init() {
document.addEventListener('reset-leave-references', () => this.reset());
try {
const res = await fetch('/api/admin/tenant-users/list', {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const data = await res.json();
if (data.success) this.departments = data.data;
} catch (e) {
console.error('참조자 목록 로딩 실패:', e);
}
this.loading = false;
},
get filteredDepartments() {
if (!this.searchQuery.trim()) return this.departments;
const q = this.searchQuery.trim().toLowerCase();
return this.departments.map(dept => {
const deptMatch = dept.department_name.toLowerCase().includes(q);
const matchedUsers = dept.users.filter(u =>
u.name.toLowerCase().includes(q) ||
(u.position || '').toLowerCase().includes(q) ||
(u.job_title || '').toLowerCase().includes(q)
);
if (deptMatch) return dept;
if (matchedUsers.length > 0) return { ...dept, users: matchedUsers };
return null;
}).filter(Boolean);
},
isAdded(userId) {
return this.references.some(r => r.user_id === userId);
},
addReference(user) {
if (this.isAdded(user.id)) return;
this.references.push({ user_id: user.id, user_name: user.name });
window._leaveReferences = this.references;
this.showDropdown = false;
},
removeReference(idx) {
this.references.splice(idx, 1);
window._leaveReferences = this.references;
},
reset() {
this.references = [];
this.searchQuery = '';
this.showDropdown = false;
window._leaveReferences = [];
},
};
}
// ===== 결재선 미리보기 =====
function previewApprovalSteps() {
const select = document.getElementById('leaveApprovalLine');
@@ -593,6 +709,7 @@ function openLeaveModal() {
document.getElementById('leaveForm').reset();
document.getElementById('balanceInfo').classList.add('hidden');
document.getElementById('leaveModal').classList.remove('hidden');
document.dispatchEvent(new CustomEvent('reset-leave-references'));
previewApprovalSteps();
}
@@ -651,6 +768,11 @@ function submitLeave(e) {
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// 참조자 데이터 추가
if (window._leaveReferences && window._leaveReferences.length) {
data.references = window._leaveReferences.map(r => ({ user_id: r.user_id }));
}
const btn = document.getElementById('leaveSubmitBtn');
btn.disabled = true;
btn.textContent = '처리 중...';