feat: [hr] 휴가관리 참조자 선택 기능 추가
- 휴가 신청 모달에 참조자 검색/선택 UI 추가 (Alpine.js) - 부서별 사용자 목록에서 참조자 검색 및 녹색 칩 표시 - LeaveController 참조 배열 유효성 검증 추가 - LeaveService에서 결재선 steps에 참조자 자동 병합
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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">×</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 = '처리 중...';
|
||||
|
||||
Reference in New Issue
Block a user