feat: [leave] 결재선 없을 때 빠른 생성 기능 추가

- 결재선 0개 시 경고 메시지 + '결재선 바로 생성' 버튼 표시
- 결재선 있을 때 '새 결재선 추가' 링크 표시
- 빠른 결재선 생성 모달 (z-[60]): 인원 목록 / 결재선 편집 2단 레이아웃
- 부서별 펼침/접기, 이름 검색, SortableJS 드래그 순서 변경
- 저장 후 드롭다운 동적 갱신 + 새 결재선 자동 선택
This commit is contained in:
김보곤
2026-03-03 22:50:34 +09:00
parent 511bfa3ec5
commit e8ea3375ad

View File

@@ -232,19 +232,36 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
</div>
</div>
{{-- 결재선 선택 --}}
<div>
<div id="approvalLineSection">
<label class="block text-sm font-medium text-gray-700 mb-1">결재선 <span class="text-red-500">*</span></label>
<select name="approval_line_id" id="leaveApprovalLine" required
onchange="previewApprovalSteps()"
class="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">
@foreach($approvalLines as $line)
<option value="{{ $line->id }}" {{ $line->is_default ? 'selected' : '' }}
data-steps='@json($line->steps)'>
{{ $line->name }}{{ $line->is_default ? ' (기본)' : '' }} {{ count($line->steps ?? []) }}단계
</option>
@endforeach
</select>
<div id="approvalStepsPreview" class="mt-2 flex items-center gap-1 overflow-x-auto py-1"></div>
@if($approvalLines->isEmpty())
{{-- 결재선 없음 경고 + 바로 생성 버튼 --}}
<div id="noLineWarning" class="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<p class="text-sm text-amber-700 mb-2">등록된 결재선이 없습니다. 결재선을 먼저 생성해주세요.</p>
<button type="button" onclick="openQuickLineModal()"
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded-lg transition-colors">
+ 결재선 바로 생성
</button>
</div>
<input type="hidden" name="approval_line_id" id="leaveApprovalLine" value="">
<div id="approvalStepsPreview" class="mt-2 flex items-center gap-1 overflow-x-auto py-1"></div>
@else
<select name="approval_line_id" id="leaveApprovalLine" required
onchange="previewApprovalSteps()"
class="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">
@foreach($approvalLines as $line)
<option value="{{ $line->id }}" {{ $line->is_default ? 'selected' : '' }}
data-steps='@json($line->steps)'>
{{ $line->name }}{{ $line->is_default ? ' (기본)' : '' }} {{ count($line->steps ?? []) }}단계
</option>
@endforeach
</select>
<div id="approvalStepsPreview" class="mt-2 flex items-center gap-1 overflow-x-auto py-1"></div>
<button type="button" onclick="openQuickLineModal()"
class="text-xs text-blue-600 hover:underline mt-1">
+ 결재선 추가
</button>
@endif
</div>
<div>
@@ -304,6 +321,152 @@ class="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg trans
</div>
</div>
</div>
{{-- 빠른 결재선 생성 모달 --}}
<div id="quickLineModal" class="fixed inset-0 z-[60] hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeQuickLineModal()"></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-2xl relative max-h-[90vh] flex flex-col" x-data="quickLineEditor()" x-init="init()">
{{-- 헤더 --}}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<h3 class="text-lg font-semibold text-gray-800">빠른 결재선 생성</h3>
<button type="button" onclick="closeQuickLineModal()" class="text-gray-400 hover:text-gray-600">
<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-6 py-4 overflow-y-auto flex-1 min-h-0">
{{-- 결재선 이름 --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">결재선 이름 <span class="text-red-500">*</span></label>
<input type="text" x-model="lineName" placeholder="예: 휴가 결재선"
class="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">
</div>
{{-- 2 레이아웃: 인원 목록 | 결재선 편집 --}}
<div class="flex gap-4" style="min-height: 300px;">
{{-- 왼쪽: 인원 목록 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden flex flex-col" style="flex: 0 0 240px;">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200 shrink-0">
<input type="text" x-model="searchQuery" placeholder="이름/부서 검색..."
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="overflow-y-auto flex-1" style="max-height: 280px;">
<template x-if="loading">
<div class="flex justify-center items-center p-6">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
</template>
<template x-if="!loading && filteredDepartments.length === 0">
<p class="text-xs text-gray-400 p-3">인원이 없습니다.</p>
</template>
<template x-for="dept in filteredDepartments" :key="dept.department_id ?? 'none'">
<div>
<button type="button" @click="toggleDept(dept.department_id ?? 'none')"
class="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-gray-700 bg-gray-50 hover:bg-gray-100 border-b border-gray-100">
<span x-text="dept.department_name + ' (' + dept.users.length + '명)'"></span>
<svg class="w-3 h-3 transition-transform" :class="isDeptExpanded(dept.department_id ?? 'none') ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<template x-if="isDeptExpanded(dept.department_id ?? 'none')">
<div>
<template x-for="user in dept.users" :key="user.id">
<div class="flex items-center justify-between px-3 py-1.5 hover:bg-blue-50 border-b border-gray-50">
<div class="min-w-0">
<span class="text-xs text-gray-800 font-medium" x-text="user.name"></span>
<span class="text-[10px] text-gray-400 ml-1" x-text="user.position || user.job_title || ''"></span>
</div>
<button type="button" @click="addStep(user, dept.department_name)"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded"
:class="isAdded(user.id) ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-100 text-blue-600 hover:bg-blue-200'"
:disabled="isAdded(user.id)"
x-text="isAdded(user.id) ? '추가됨' : '+ 추가'">
</button>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
{{-- 오른쪽: 결재선 편집 --}}
<div class="flex-1 border border-gray-200 rounded-lg overflow-hidden flex flex-col">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200 shrink-0">
<span class="text-xs font-medium text-gray-600">결재선 구성</span>
<span class="text-[10px] text-gray-400 ml-1">(드래그로 순서 변경)</span>
</div>
<div class="overflow-y-auto flex-1 p-2" style="max-height: 280px;">
<template x-if="steps.length === 0">
<p class="text-xs text-gray-400 text-center py-8">왼쪽에서 결재자를 추가하세요.</p>
</template>
<div x-ref="sortableList" class="space-y-1.5">
<template x-for="(step, index) in steps" :key="step._key">
<div class="flex items-center gap-2 px-2 py-2 bg-white border border-gray-200 rounded-lg hover:border-blue-200 transition-colors" :data-index="index">
{{-- 드래그 핸들 --}}
<span class="drag-handle cursor-grab text-gray-300 hover:text-gray-500 shrink-0" title="드래그하여 순서 변경">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
</span>
{{-- 순번 --}}
<span class="text-[10px] text-gray-400 shrink-0 w-4 text-center" x-text="index + 1"></span>
{{-- 이름/부서 --}}
<div class="min-w-0 flex-1">
<span class="text-xs font-medium text-gray-800" x-text="step.user_name"></span>
<span class="text-[10px] text-gray-400 ml-1" x-text="step.department ? '(' + step.department + ')' : ''"></span>
</div>
{{-- 유형 선택 --}}
<select x-model="step.step_type"
class="shrink-0 text-[11px] px-1.5 py-1 border border-gray-200 rounded focus:ring-1 focus:ring-blue-500"
style="width: 62px;">
<option value="approval">결재</option>
<option value="agreement">합의</option>
<option value="reference">참조</option>
</select>
{{-- 삭제 --}}
<button type="button" @click="removeStep(index)"
class="shrink-0 text-gray-300 hover:text-red-500 transition-colors" title="삭제">
<svg class="w-4 h-4" 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>
</template>
</div>
</div>
</div>
</div>
</div>
{{-- 푸터 --}}
<div class="flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-gray-50 shrink-0">
<div class="flex items-center gap-3 text-[11px] text-gray-500">
<span>결재: <strong class="text-blue-600" x-text="countByType('approval')"></strong></span>
<span>합의: <strong class="text-amber-600" x-text="countByType('agreement')"></strong></span>
<span>참조: <strong class="text-gray-600" x-text="countByType('reference')"></strong></span>
</div>
<div class="flex items-center gap-2">
<button type="button" onclick="closeQuickLineModal()"
class="px-3 py-1.5 text-xs text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="button" @click="save()"
:disabled="saving"
class="px-4 py-1.5 text-xs text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50">
<span x-show="!saving">저장</span>
<span x-show="saving">저장 ...</span>
</button>
</div>
</div>
</div>
</div>
</div>
@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);
}
}
</script>
@endpush