|
|
|
|
@@ -6,9 +6,15 @@
|
|
|
|
|
<!-- 페이지 헤더 -->
|
|
|
|
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
|
|
|
|
<h1 class="text-2xl font-bold text-gray-800">기안함</h1>
|
|
|
|
|
<a href="{{ route('approvals.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
|
|
|
|
|
+ 새 기안
|
|
|
|
|
</a>
|
|
|
|
|
<div class="flex gap-2 w-full sm:w-auto">
|
|
|
|
|
<button onclick="openLineManager()"
|
|
|
|
|
class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition text-center flex-1 sm:flex-none">
|
|
|
|
|
결재선 관리
|
|
|
|
|
</button>
|
|
|
|
|
<a href="{{ route('approvals.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center flex-1 sm:flex-none">
|
|
|
|
|
+ 새 기안
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 필터 영역 -->
|
|
|
|
|
@@ -43,6 +49,102 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
|
|
|
|
|
|
|
|
|
<!-- 페이지네이션 -->
|
|
|
|
|
<div id="pagination-area" class="mt-4"></div>
|
|
|
|
|
|
|
|
|
|
<!-- 결재선 관리 모달 -->
|
|
|
|
|
<div id="lineManagerModal" class="fixed inset-0 z-50 hidden">
|
|
|
|
|
<div class="fixed inset-0 bg-black/50" onclick="closeLineManager()"></div>
|
|
|
|
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
|
|
|
|
<div class="bg-white rounded-xl shadow-2xl w-full relative" style="max-width: 720px; max-height: 90vh; display: flex; flex-direction: column;">
|
|
|
|
|
<!-- 모달 헤더 -->
|
|
|
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<button id="lineBackBtn" onclick="backToLineList()" class="hidden p-1 hover:bg-gray-100 rounded-lg transition">
|
|
|
|
|
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<h2 id="lineManagerTitle" class="text-lg font-bold text-gray-800">결재선 관리</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick="closeLineManager()" class="p-1 hover:bg-gray-100 rounded-lg transition">
|
|
|
|
|
<svg class="w-5 h-5 text-gray-500" 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="flex-1 overflow-y-auto" style="min-height: 0;">
|
|
|
|
|
<!-- 목록 화면 -->
|
|
|
|
|
<div id="lineListView">
|
|
|
|
|
<div class="p-4">
|
|
|
|
|
<button onclick="openLineEdit()" class="w-full bg-blue-50 hover:bg-blue-100 text-blue-600 font-medium px-4 py-3 rounded-lg transition border border-blue-200 border-dashed text-sm">
|
|
|
|
|
+ 새 결재선 만들기
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="lineListBody" class="px-4 pb-4 space-y-2">
|
|
|
|
|
<div class="flex justify-center py-8">
|
|
|
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 편집 화면 -->
|
|
|
|
|
<div id="lineEditView" class="hidden">
|
|
|
|
|
<!-- 이름 입력 -->
|
|
|
|
|
<div class="p-4 border-b border-gray-100">
|
|
|
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">결재선 이름</label>
|
|
|
|
|
<input type="text" id="lineNameInput" placeholder="예: 일반 결재선, 팀장 결재선..."
|
|
|
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 2패널 구조 -->
|
|
|
|
|
<div class="flex" style="min-height: 360px;">
|
|
|
|
|
<!-- 좌측: 인원 목록 -->
|
|
|
|
|
<div class="border-r border-gray-200" style="flex: 0 0 250px; max-width: 250px;">
|
|
|
|
|
<div class="p-3 border-b border-gray-100">
|
|
|
|
|
<div class="relative">
|
|
|
|
|
<svg class="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<input type="text" id="lineUserSearch" placeholder="이름/부서 검색..."
|
|
|
|
|
class="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="lineDeptList" class="overflow-y-auto" style="max-height: 340px;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 우측: 결재선 -->
|
|
|
|
|
<div class="flex-1 flex flex-col min-w-0">
|
|
|
|
|
<div id="lineStepList" class="flex-1 overflow-y-auto p-3 space-y-2" style="max-height: 360px;"></div>
|
|
|
|
|
<div id="lineStepEmpty" class="flex-1 flex flex-col items-center justify-center py-12 text-gray-400">
|
|
|
|
|
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
|
|
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="text-xs">좌측에서 결재자를 추가하세요</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 하단 요약 -->
|
|
|
|
|
<div class="px-4 py-2 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
|
|
|
|
|
<span id="lineSummary">결재: 0명 | 합의: 0명 | 참조: 0명 — 합계 0명</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 모달 푸터 (편집 모드에서만) -->
|
|
|
|
|
<div id="lineEditFooter" class="hidden px-6 py-3 border-t border-gray-200 flex justify-end gap-2 shrink-0">
|
|
|
|
|
<button onclick="backToLineList()" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition text-sm">
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
<button onclick="saveLine()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition text-sm">
|
|
|
|
|
저장
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@endsection
|
|
|
|
|
|
|
|
|
|
@push('scripts')
|
|
|
|
|
@@ -148,6 +250,398 @@ function renderPagination(data) {
|
|
|
|
|
area.innerHTML = html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 결재선 관리 모달
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
let lineManagerState = 'list';
|
|
|
|
|
let editingLineId = null;
|
|
|
|
|
let lineSteps = [];
|
|
|
|
|
let lineDepartments = [];
|
|
|
|
|
let lineExpandedDepts = {};
|
|
|
|
|
const csrfToken = '{{ csrf_token() }}';
|
|
|
|
|
|
|
|
|
|
function openLineManager() {
|
|
|
|
|
document.getElementById('lineManagerModal').classList.remove('hidden');
|
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
|
switchToLineList();
|
|
|
|
|
loadLineList();
|
|
|
|
|
loadLineDepartments();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeLineManager() {
|
|
|
|
|
document.getElementById('lineManagerModal').classList.add('hidden');
|
|
|
|
|
document.body.style.overflow = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function switchToLineList() {
|
|
|
|
|
lineManagerState = 'list';
|
|
|
|
|
editingLineId = null;
|
|
|
|
|
lineSteps = [];
|
|
|
|
|
document.getElementById('lineListView').classList.remove('hidden');
|
|
|
|
|
document.getElementById('lineEditView').classList.add('hidden');
|
|
|
|
|
document.getElementById('lineEditFooter').classList.add('hidden');
|
|
|
|
|
document.getElementById('lineBackBtn').classList.add('hidden');
|
|
|
|
|
document.getElementById('lineManagerTitle').textContent = '결재선 관리';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function switchToLineEdit() {
|
|
|
|
|
lineManagerState = 'edit';
|
|
|
|
|
document.getElementById('lineListView').classList.add('hidden');
|
|
|
|
|
document.getElementById('lineEditView').classList.remove('hidden');
|
|
|
|
|
document.getElementById('lineEditFooter').classList.remove('hidden');
|
|
|
|
|
document.getElementById('lineBackBtn').classList.remove('hidden');
|
|
|
|
|
document.getElementById('lineManagerTitle').textContent = editingLineId ? '결재선 수정' : '새 결재선';
|
|
|
|
|
renderLineSteps();
|
|
|
|
|
renderLineDeptList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadLineList() {
|
|
|
|
|
const body = document.getElementById('lineListBody');
|
|
|
|
|
body.innerHTML = '<div class="flex justify-center py-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
|
|
|
|
|
|
|
|
|
|
fetch('/api/admin/approvals/lines', {
|
|
|
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
const lines = data.data || [];
|
|
|
|
|
if (!lines.length) {
|
|
|
|
|
body.innerHTML = '<div class="py-8 text-center text-gray-400 text-sm">등록된 결재선이 없습니다.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
body.innerHTML = lines.map(line => {
|
|
|
|
|
const stepsArr = line.steps || [];
|
|
|
|
|
const summary = stepsArr.map(s => s.user_name || '?').join(' → ');
|
|
|
|
|
const typeCounts = {
|
|
|
|
|
approval: stepsArr.filter(s => s.step_type === 'approval').length,
|
|
|
|
|
agreement: stepsArr.filter(s => s.step_type === 'agreement').length,
|
|
|
|
|
reference: stepsArr.filter(s => s.step_type === 'reference').length,
|
|
|
|
|
};
|
|
|
|
|
const typeLabel = [
|
|
|
|
|
typeCounts.approval ? `결재 ${typeCounts.approval}` : '',
|
|
|
|
|
typeCounts.agreement ? `합의 ${typeCounts.agreement}` : '',
|
|
|
|
|
typeCounts.reference ? `참조 ${typeCounts.reference}` : '',
|
|
|
|
|
].filter(Boolean).join(', ');
|
|
|
|
|
|
|
|
|
|
return `<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 group hover:border-blue-300 transition">
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="font-medium text-sm text-gray-800">${escapeHtml(line.name)}</span>
|
|
|
|
|
${line.is_default ? '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-600">기본</span>' : ''}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-xs text-gray-500 mt-0.5 truncate">${escapeHtml(summary)}</div>
|
|
|
|
|
<div class="text-xs text-gray-400 mt-0.5">${typeLabel} (${stepsArr.length}단계)</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition">
|
|
|
|
|
<button onclick="openLineEdit(${line.id})" class="p-1.5 hover:bg-blue-100 rounded-lg transition" title="수정">
|
|
|
|
|
<svg class="w-4 h-4 text-gray-500 hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button onclick="deleteLine(${line.id}, '${escapeHtml(line.name)}')" class="p-1.5 hover:bg-red-100 rounded-lg transition" title="삭제">
|
|
|
|
|
<svg class="w-4 h-4 text-gray-500 hover:text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
body.innerHTML = '<div class="py-8 text-center text-red-400 text-sm">목록을 불러올 수 없습니다.</div>';
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadLineDepartments() {
|
|
|
|
|
if (lineDepartments.length > 0) return;
|
|
|
|
|
fetch('/api/admin/tenant-users/list', {
|
|
|
|
|
headers: { 'Accept': 'application/json' }
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.success) {
|
|
|
|
|
lineDepartments = data.data;
|
|
|
|
|
lineDepartments.forEach(d => {
|
|
|
|
|
lineExpandedDepts[d.department_id ?? 'none'] = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openLineEdit(id = null) {
|
|
|
|
|
editingLineId = id;
|
|
|
|
|
lineSteps = [];
|
|
|
|
|
document.getElementById('lineNameInput').value = '';
|
|
|
|
|
|
|
|
|
|
if (id) {
|
|
|
|
|
// 기존 결재선 로드
|
|
|
|
|
fetch('/api/admin/approvals/lines', {
|
|
|
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
const line = (data.data || []).find(l => l.id === id);
|
|
|
|
|
if (line) {
|
|
|
|
|
document.getElementById('lineNameInput').value = line.name;
|
|
|
|
|
lineSteps = (line.steps || []).map((s, i) => ({
|
|
|
|
|
_key: i + 1,
|
|
|
|
|
user_id: s.user_id,
|
|
|
|
|
user_name: s.user_name || '',
|
|
|
|
|
department: s.department || '',
|
|
|
|
|
position: s.position || '',
|
|
|
|
|
step_type: s.step_type || 'approval',
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
switchToLineEdit();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
switchToLineEdit();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function backToLineList() {
|
|
|
|
|
switchToLineList();
|
|
|
|
|
loadLineList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveLine() {
|
|
|
|
|
const name = document.getElementById('lineNameInput').value.trim();
|
|
|
|
|
if (!name) {
|
|
|
|
|
if (typeof showToast === 'function') showToast('결재선 이름을 입력하세요.', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (lineSteps.length === 0) {
|
|
|
|
|
if (typeof showToast === 'function') showToast('결재자를 1명 이상 추가하세요.', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
name: name,
|
|
|
|
|
steps: lineSteps.map(s => ({
|
|
|
|
|
user_id: s.user_id,
|
|
|
|
|
step_type: s.step_type,
|
|
|
|
|
})),
|
|
|
|
|
is_default: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const url = editingLineId
|
|
|
|
|
? `/api/admin/approvals/lines/${editingLineId}`
|
|
|
|
|
: '/api/admin/approvals/lines';
|
|
|
|
|
const method = editingLineId ? 'PUT' : 'POST';
|
|
|
|
|
|
|
|
|
|
fetch(url, {
|
|
|
|
|
method: method,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.success) {
|
|
|
|
|
if (typeof showToast === 'function') showToast(data.message, 'success');
|
|
|
|
|
backToLineList();
|
|
|
|
|
} else {
|
|
|
|
|
const msg = data.message || '저장에 실패했습니다.';
|
|
|
|
|
if (typeof showToast === 'function') showToast(msg, 'error');
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (typeof showToast === 'function') showToast('저장 중 오류가 발생했습니다.', 'error');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deleteLine(id, name) {
|
|
|
|
|
if (!confirm(`"${name}" 결재선을 삭제하시겠습니까?`)) return;
|
|
|
|
|
|
|
|
|
|
fetch(`/api/admin/approvals/lines/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.success) {
|
|
|
|
|
if (typeof showToast === 'function') showToast(data.message, 'success');
|
|
|
|
|
loadLineList();
|
|
|
|
|
} else {
|
|
|
|
|
if (typeof showToast === 'function') showToast(data.message || '삭제 실패', 'error');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addLineStep(user, deptName) {
|
|
|
|
|
if (lineSteps.some(s => s.user_id === user.id)) {
|
|
|
|
|
if (typeof showToast === 'function') showToast('이미 추가된 결재자입니다.', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lineSteps.push({
|
|
|
|
|
_key: Date.now(),
|
|
|
|
|
user_id: user.id,
|
|
|
|
|
user_name: user.name,
|
|
|
|
|
department: deptName || '',
|
|
|
|
|
position: user.position || user.job_title || '',
|
|
|
|
|
step_type: 'approval',
|
|
|
|
|
});
|
|
|
|
|
renderLineSteps();
|
|
|
|
|
renderLineDeptList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeLineStep(index) {
|
|
|
|
|
lineSteps.splice(index, 1);
|
|
|
|
|
renderLineSteps();
|
|
|
|
|
renderLineDeptList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function changeLineStepType(index, value) {
|
|
|
|
|
lineSteps[index].step_type = value;
|
|
|
|
|
updateLineSummary();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moveLineStep(index, direction) {
|
|
|
|
|
const newIndex = index + direction;
|
|
|
|
|
if (newIndex < 0 || newIndex >= lineSteps.length) return;
|
|
|
|
|
const temp = lineSteps[index];
|
|
|
|
|
lineSteps[index] = lineSteps[newIndex];
|
|
|
|
|
lineSteps[newIndex] = temp;
|
|
|
|
|
renderLineSteps();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderLineSteps() {
|
|
|
|
|
const container = document.getElementById('lineStepList');
|
|
|
|
|
const emptyEl = document.getElementById('lineStepEmpty');
|
|
|
|
|
|
|
|
|
|
if (lineSteps.length === 0) {
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
container.classList.add('hidden');
|
|
|
|
|
emptyEl.classList.remove('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
emptyEl.classList.add('hidden');
|
|
|
|
|
container.classList.remove('hidden');
|
|
|
|
|
|
|
|
|
|
container.innerHTML = lineSteps.map((step, i) => {
|
|
|
|
|
const info = [step.department, step.position].filter(Boolean).join(' / ');
|
|
|
|
|
return `<div class="flex items-center gap-2 p-2 bg-gray-50 rounded-lg border border-gray-200 group">
|
|
|
|
|
<div class="shrink-0 flex flex-col gap-0.5">
|
|
|
|
|
<button onclick="moveLineStep(${i}, -1)" class="p-0.5 text-gray-300 hover:text-gray-600 transition ${i === 0 ? 'invisible' : ''}">
|
|
|
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button onclick="moveLineStep(${i}, 1)" class="p-0.5 text-gray-300 hover:text-gray-600 transition ${i === lineSteps.length - 1 ? 'invisible' : ''}">
|
|
|
|
|
<svg class="w-3 h-3" 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>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="shrink-0 flex items-center justify-center bg-blue-100 text-blue-700 rounded-full font-medium text-xs" style="width: 22px; height: 22px;">${i + 1}</span>
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<span class="font-medium text-xs text-gray-800">${escapeHtml(step.user_name)}</span>
|
|
|
|
|
<div class="text-xs text-gray-400 truncate">${escapeHtml(info)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<select onchange="changeLineStepType(${i}, this.value)" class="shrink-0 px-1.5 py-1 border border-gray-300 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500" style="width: 60px;">
|
|
|
|
|
<option value="approval" ${step.step_type === 'approval' ? 'selected' : ''}>결재</option>
|
|
|
|
|
<option value="agreement" ${step.step_type === 'agreement' ? 'selected' : ''}>합의</option>
|
|
|
|
|
<option value="reference" ${step.step_type === 'reference' ? 'selected' : ''}>참조</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button onclick="removeLineStep(${i})" class="shrink-0 p-1 text-gray-300 hover:text-red-500 transition opacity-0 group-hover:opacity-100">
|
|
|
|
|
<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>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateLineSummary();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderLineDeptList() {
|
|
|
|
|
const container = document.getElementById('lineDeptList');
|
|
|
|
|
const query = (document.getElementById('lineUserSearch')?.value || '').trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
let depts = lineDepartments;
|
|
|
|
|
if (query) {
|
|
|
|
|
depts = depts.map(dept => {
|
|
|
|
|
const deptMatch = dept.department_name.toLowerCase().includes(query);
|
|
|
|
|
const matched = dept.users.filter(u =>
|
|
|
|
|
u.name.toLowerCase().includes(query) ||
|
|
|
|
|
(u.position && u.position.toLowerCase().includes(query))
|
|
|
|
|
);
|
|
|
|
|
if (deptMatch) return dept;
|
|
|
|
|
if (matched.length > 0) return { ...dept, users: matched };
|
|
|
|
|
return null;
|
|
|
|
|
}).filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!depts.length) {
|
|
|
|
|
container.innerHTML = '<div class="py-8 text-center text-gray-400 text-xs">인원 정보가 없습니다.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.innerHTML = depts.map(dept => {
|
|
|
|
|
const deptKey = dept.department_id ?? 'none';
|
|
|
|
|
const expanded = lineExpandedDepts[deptKey] !== false;
|
|
|
|
|
return `<div class="border-b border-gray-50">
|
|
|
|
|
<button type="button" onclick="toggleLineDept('${deptKey}')"
|
|
|
|
|
class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-600 bg-gray-50 hover:bg-gray-100 transition">
|
|
|
|
|
<span class="flex items-center gap-1">
|
|
|
|
|
<svg class="w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
|
|
|
</svg>
|
|
|
|
|
${escapeHtml(dept.department_name)}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="text-gray-400">${dept.users.length}명</span>
|
|
|
|
|
</button>
|
|
|
|
|
<div id="lineDept-${deptKey}" class="${expanded ? '' : 'hidden'}">
|
|
|
|
|
${dept.users.map(user => {
|
|
|
|
|
const added = lineSteps.some(s => s.user_id === user.id);
|
|
|
|
|
return `<div class="flex items-center justify-between px-3 py-1.5 hover:bg-blue-50 transition ${added ? 'opacity-50' : ''}">
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<span class="text-xs font-medium text-gray-800">${escapeHtml(user.name)}</span>
|
|
|
|
|
<span class="text-xs text-gray-400 ml-1">${escapeHtml(user.position || user.job_title || '')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick='addLineStep(${JSON.stringify({id: user.id, name: user.name, position: user.position || user.job_title || ""})}, "${escapeHtml(dept.department_name)}")'
|
|
|
|
|
${added ? 'disabled' : ''}
|
|
|
|
|
class="shrink-0 ml-2 text-xs px-2 py-0.5 rounded transition ${added ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'}">
|
|
|
|
|
${added ? '추가됨' : '+ 추가'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>`;
|
|
|
|
|
}).join('')}
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleLineDept(key) {
|
|
|
|
|
lineExpandedDepts[key] = !lineExpandedDepts[key];
|
|
|
|
|
const el = document.getElementById('lineDept-' + key);
|
|
|
|
|
if (el) el.classList.toggle('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateLineSummary() {
|
|
|
|
|
const counts = { approval: 0, agreement: 0, reference: 0 };
|
|
|
|
|
lineSteps.forEach(s => { if (counts[s.step_type] !== undefined) counts[s.step_type]++; });
|
|
|
|
|
const total = lineSteps.length;
|
|
|
|
|
document.getElementById('lineSummary').textContent =
|
|
|
|
|
`결재: ${counts.approval}명 | 합의: ${counts.agreement}명 | 참조: ${counts.reference}명 — 합계 ${total}명`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(str) {
|
|
|
|
|
if (!str) return '';
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.textContent = str;
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 인원 검색 디바운스
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
|
|
|
const searchInput = document.getElementById('lineUserSearch');
|
|
|
|
|
if (searchInput) {
|
|
|
|
|
let timer;
|
|
|
|
|
searchInput.addEventListener('input', function() {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
timer = setTimeout(() => renderLineDeptList(), 200);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function confirmDelete(id, title) {
|
|
|
|
|
if (!confirm(`"${title}" 문서를 삭제하시겠습니까?`)) return;
|
|
|
|
|
|
|
|
|
|
|