diff --git a/resources/views/hr/leaves/index.blade.php b/resources/views/hr/leaves/index.blade.php index 9aa913d3..e6948fc8 100644 --- a/resources/views/hr/leaves/index.blade.php +++ b/resources/views/hr/leaves/index.blade.php @@ -232,19 +232,36 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f {{-- 결재선 선택 --}} -
+
- -
+ @if($approvalLines->isEmpty()) + {{-- 결재선 없음 경고 + 바로 생성 버튼 --}} +
+

등록된 결재선이 없습니다. 결재선을 먼저 생성해주세요.

+ +
+ +
+ @else + +
+ + @endif
@@ -304,6 +321,152 @@ class="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg trans
+{{-- 빠른 결재선 생성 모달 --}} + + @endsection @push('scripts') @@ -601,7 +764,7 @@ function showToast(message, type) { } const colors = { success: 'bg-emerald-500', error: 'bg-red-500', info: 'bg-blue-500' }; const toast = document.createElement('div'); - toast.className = `fixed top-4 right-4 z-[60] px-4 py-3 rounded-lg text-white text-sm shadow-lg ${colors[type] || colors.info}`; + toast.className = `fixed top-4 right-4 z-[70] px-4 py-3 rounded-lg text-white text-sm shadow-lg ${colors[type] || colors.info}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); @@ -614,5 +777,259 @@ function showToast(message, type) { document.getElementById('leaveEndDate').value = this.value; } }); + + // ===== 빠른 결재선 생성 모달 ===== + function openQuickLineModal() { + document.getElementById('quickLineModal').classList.remove('hidden'); + } + + function closeQuickLineModal() { + document.getElementById('quickLineModal').classList.add('hidden'); + } + + function quickLineEditor() { + let keyCounter = 0; + + return { + departments: [], + steps: [], + lineName: '', + searchQuery: '', + expandedDepts: {}, + loading: true, + saving: false, + sortableInstance: null, + + async init() { + 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; + this.departments.forEach(d => { + this.expandedDepts[d.department_id ?? 'none'] = true; + }); + } + } 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); + }, + + toggleDept(deptId) { + this.expandedDepts[deptId] = !this.expandedDepts[deptId]; + }, + + isDeptExpanded(deptId) { + return !!this.expandedDepts[deptId]; + }, + + isAdded(userId) { + return this.steps.some(s => s.user_id === userId); + }, + + addStep(user, deptName) { + if (this.isAdded(user.id)) return; + this.steps.push({ + _key: ++keyCounter, + user_id: user.id, + user_name: user.name, + department: deptName || '', + position: user.position || user.job_title || '', + step_type: 'approval', + }); + this.$nextTick(() => this.initSortable()); + }, + + removeStep(index) { + this.steps.splice(index, 1); + this.$nextTick(() => this.initSortable()); + }, + + initSortable() { + if (this.sortableInstance) { + this.sortableInstance.destroy(); + this.sortableInstance = null; + } + const el = this.$refs.sortableList; + if (!el || typeof Sortable === 'undefined') return; + + this.sortableInstance = Sortable.create(el, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'opacity-30', + onEnd: (evt) => { + const item = this.steps.splice(evt.oldIndex, 1)[0]; + this.steps.splice(evt.newIndex, 0, item); + }, + }); + }, + + countByType(type) { + return this.steps.filter(s => s.step_type === type).length; + }, + + async save() { + if (!this.lineName.trim()) { + showToast('결재선 이름을 입력해주세요.', 'error'); + return; + } + const nonRefSteps = this.steps.filter(s => s.step_type !== 'reference'); + if (nonRefSteps.length === 0) { + showToast('결재자를 1명 이상 추가해주세요.', 'error'); + return; + } + + this.saving = true; + try { + const payload = { + name: this.lineName.trim(), + steps: this.steps.map(s => ({ + user_id: s.user_id, + step_type: s.step_type, + })), + is_default: false, + }; + + const res = await fetch('/api/admin/approvals/lines', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + if (res.ok && data.success) { + showToast('결재선이 생성되었습니다.', 'success'); + closeQuickLineModal(); + refreshApprovalLines(data.data?.id || null); + // 상태 초기화 + this.lineName = ''; + this.steps = []; + } else { + const msg = data.message || (data.errors ? Object.values(data.errors).flat().join('\n') : '결재선 생성에 실패했습니다.'); + showToast(msg, 'error'); + } + } catch (e) { + console.error('결재선 생성 실패:', e); + showToast('네트워크 오류가 발생했습니다.', 'error'); + } + this.saving = false; + }, + }; + } + + // ===== 결재선 드롭다운 갱신 ===== + async function refreshApprovalLines(selectedLineId) { + try { + const res = await fetch('/api/admin/approvals/lines', { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' } + }); + const data = await res.json(); + if (!data.success || !data.data) return; + + const lines = data.data; + const section = document.getElementById('approvalLineSection'); + if (!section) return; + + // 경고 영역 제거 (있으면) + const warning = document.getElementById('noLineWarning'); + if (warning) warning.remove(); + + // 기존 hidden input 제거 (결재선 없음 상태에서 만들어진 것) + const hiddenInput = section.querySelector('input[type="hidden"][name="approval_line_id"]'); + if (hiddenInput) hiddenInput.remove(); + + // 기존 select가 있으면 재사용, 없으면 새로 생성 + let select = document.getElementById('leaveApprovalLine'); + let isNewSelect = false; + + if (!select || select.tagName !== 'SELECT') { + // hidden input이었던 경우 제거 + if (select) select.remove(); + + select = document.createElement('select'); + select.name = 'approval_line_id'; + select.id = 'leaveApprovalLine'; + select.required = true; + select.setAttribute('onchange', 'previewApprovalSteps()'); + select.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500'; + isNewSelect = true; + } + + // option 목록 재구성 + select.innerHTML = ''; + lines.forEach(line => { + const opt = document.createElement('option'); + opt.value = line.id; + opt.dataset.steps = JSON.stringify(line.steps || []); + opt.textContent = line.name + (line.is_default ? ' (기본)' : '') + ' — ' + (line.steps?.length || 0) + '단계'; + if (selectedLineId && line.id == selectedLineId) { + opt.selected = true; + } else if (!selectedLineId && line.is_default) { + opt.selected = true; + } + select.appendChild(opt); + }); + + if (isNewSelect) { + // label 다음에 삽입 + const label = section.querySelector('label'); + if (label) { + label.after(select); + } else { + section.prepend(select); + } + } + + // 미리보기 영역 확보 + let preview = document.getElementById('approvalStepsPreview'); + if (!preview) { + preview = document.createElement('div'); + preview.id = 'approvalStepsPreview'; + preview.className = 'mt-2 flex items-center gap-1 overflow-x-auto py-1'; + select.after(preview); + } + + // "새 결재선 추가" 링크 확보 + if (!section.querySelector('.quick-line-link')) { + const link = document.createElement('button'); + link.type = 'button'; + link.className = 'quick-line-link text-xs text-blue-600 hover:underline mt-1'; + link.textContent = '+ 새 결재선 추가'; + link.onclick = openQuickLineModal; + preview.after(link); + } + + // 미리보기 갱신 + previewApprovalSteps(); + + } catch (e) { + console.error('결재선 목록 갱신 실패:', e); + } + } @endpush