diff --git a/resources/views/rd/org-chart.blade.php b/resources/views/rd/org-chart.blade.php index 04782c06..07aa11ca 100644 --- a/resources/views/rd/org-chart.blade.php +++ b/resources/views/rd/org-chart.blade.php @@ -3,8 +3,7 @@ @section('title', '조직도 관리') @section('content') -
- +

@@ -18,240 +17,48 @@

-
- 직원 카드를 드래그하여 부서에 배치하거나, 미배치 영역으로 이동할 수 있습니다. 부서 카드도 드래그하여 순서를 변경할 수 있습니다. 변경은 즉시 저장됩니다. + 직원 카드를 드래그하여 부서에 배치합니다. 부서 헤더를 드래그하여 순서를 변경하거나 다른 부서 아래로 이동할 수 있습니다.
- -
+ +
- + @click="unassignedOpen = !unassignedOpen"> + 미배치 직원
-
-
-
- -
- - 미배치 직원 없음 -
-
+
+
- +
-
- -
-
-
-
- - -
-

-
+
+
+
+ +
+

- - -
-
-
- - - - -
- -

등록된 부서가 없습니다. 먼저 부서를 등록해주세요.

-
+
+
-
저장 중... @@ -259,13 +66,26 @@ class="fixed bottom-6 right-6 bg-gray-800 text-white px-4 py-2 rounded-lg shadow
@endsection @@ -278,219 +98,280 @@ function orgChart() { companyName: @json($companyName), ceoName: @json($ceoName), searchUnassigned: '', + unassignedOpen: true, saving: false, - sortables: [], deptSortables: [], - expandedDepts: [], + empSortables: [], + unassignedSortable: null, get totalEmployees() { return this.employees.length; }, get assignedCount() { return this.employees.filter(e => e.department_id).length; }, get unassignedCount() { return this.employees.filter(e => !e.department_id).length; }, - get unassignedEmployees() { return this.employees.filter(e => !e.department_id); }, - get filteredUnassigned() { - if (!this.searchUnassigned) return this.unassignedEmployees; - const s = this.searchUnassigned.toLowerCase(); - return this.unassignedEmployees.filter(e => e.display_name?.toLowerCase().includes(s)); - }, - get rootDepartments() { + get rootDepts() { return this.getChildrenSorted(null); }, + + getChildrenSorted(parentId) { return this.departments - .filter(d => !d.parent_id) - .sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999) || a.name.localeCompare(b.name)); + .filter(d => (d.parent_id || null) === parentId) + .sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999) || (a.name || '').localeCompare(b.name || '')); }, - - getChildDepartments(parentId) { - return this.departments - .filter(d => d.parent_id === parentId) - .sort((a, b) => (a.sort_order ?? 999) - (b.sort_order ?? 999) || a.name.localeCompare(b.name)); - }, - - getDeptEmployees(deptId) { - return this.employees.filter(e => e.department_id === deptId); - }, - + getDeptEmployees(deptId) { return this.employees.filter(e => e.department_id === deptId); }, getDeptTotalCount(deptId) { - let count = this.employees.filter(e => e.department_id === deptId).length; - const children = this.departments.filter(d => d.parent_id === deptId); - for (const child of children) { - count += this.getDeptTotalCount(child.id); - } - return count; + let c = this.getDeptEmployees(deptId).length; + for (const ch of this.departments.filter(d => d.parent_id === deptId)) c += this.getDeptTotalCount(ch.id); + return c; }, - - toggleDept(deptId) { - const idx = this.expandedDepts.indexOf(deptId); - if (idx >= 0) { - this.expandedDepts.splice(idx, 1); - } else { - this.expandedDepts.push(deptId); - } - this.$nextTick(() => { - this.initDropZones(); - this.initDeptSortables(); - }); + isDescendant(ancestorId, targetId) { + if (!targetId) return false; + if (targetId === ancestorId) return true; + const p = this.departments.find(d => d.id === targetId); + return p ? this.isDescendant(ancestorId, p.parent_id) : false; }, - getAvatarColor(name) { if (!name) return '#9CA3AF'; - const colors = ['#6366F1','#8B5CF6','#EC4899','#F59E0B','#10B981','#3B82F6','#EF4444','#14B8A6','#F97316','#6D28D9']; - let hash = 0; - for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); - return colors[Math.abs(hash) % colors.length]; + const c = ['#6366F1','#8B5CF6','#EC4899','#F59E0B','#10B981','#3B82F6','#EF4444','#14B8A6','#F97316','#6D28D9']; + let h = 0; for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h); + return c[Math.abs(h) % c.length]; }, + esc(s) { return s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; }, init() { - // 하위 부서가 있는 1단계 부서는 기본 펼침 - this.expandedDepts = this.rootDepartments - .filter(d => this.getChildDepartments(d.id).length > 0) - .map(d => d.id); - - this.$nextTick(() => { - this.initSortable(); - this.initDropZones(); - this.initDeptSortables(); - }); + this.renderTree(); + this.renderUnassigned(); + this.initUnassignedSortable(); }, - initSortable() { - const unassignedEl = document.getElementById('unassigned-zone'); - if (unassignedEl && !unassignedEl._sortableInitialized) { - unassignedEl._sortableInitialized = true; - this.sortables.push(new Sortable(unassignedEl, { - group: 'org-chart', - animation: 150, - ghostClass: 'sortable-ghost', - dragClass: 'sortable-drag', - onAdd: (evt) => this.handleDrop(evt), - })); + // ===== 미배치 직원 ===== + renderUnassigned() { + const zone = this.$refs.unassignedZone; + if (!zone) return; + let emps = this.employees.filter(e => !e.department_id); + if (this.searchUnassigned) { + const q = this.searchUnassigned.toLowerCase(); + emps = emps.filter(e => (e.display_name || '').toLowerCase().includes(q)); } + if (!emps.length) { + zone.innerHTML = '
미배치 직원 없음
'; + return; + } + zone.innerHTML = emps.map(e => this.buildEmpHtml(e, true)).join(''); }, - initDropZones() { - document.querySelectorAll('.dept-drop-zone').forEach(el => { - if (el._sortableInitialized) return; - el._sortableInitialized = true; - this.sortables.push(new Sortable(el, { - group: 'org-chart', - animation: 150, - ghostClass: 'sortable-ghost', - dragClass: 'sortable-drag', - onAdd: (evt) => this.handleDrop(evt), - })); + initUnassignedSortable() { + const zone = document.getElementById('unassigned-zone'); + if (!zone) return; + this.unassignedSortable = new Sortable(zone, { + group: 'employees', animation: 150, + ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', + onAdd: (evt) => { + const empId = parseInt(evt.item.dataset.employeeId); + evt.item.remove(); + const emp = this.employees.find(e => e.id === empId); + if (emp) emp.department_id = null; + this.renderTree(); + this.renderUnassigned(); + this.saveAssignment(empId, null); + }, }); }, + // ===== 부서 트리 ===== + renderTree() { + this.deptSortables.forEach(s => s.destroy()); this.deptSortables = []; + this.empSortables.forEach(s => s.destroy()); this.empSortables = []; + const el = this.$refs.deptTree; + if (!el) return; + el.innerHTML = this.buildChildrenHtml(null, 0); + this.$nextTick(() => { this.initDeptSortables(); this.initEmpSortables(); }); + }, + + buildChildrenHtml(parentId, level) { + const children = this.getChildrenSorted(parentId); + if (!children.length && parentId !== null) { + return `
하위 부서 드롭
`; + } + if (!children.length) return ''; + let h = `
`; + for (const d of children) h += this.buildNodeHtml(d, level); + h += '
'; + return h; + }, + + buildNodeHtml(dept, level) { + const emps = this.getDeptEmployees(dept.id); + const children = this.getChildrenSorted(dept.id); + const total = this.getDeptTotalCount(dept.id); + const styles = [ + { border:'2px solid #C4B5FD', hBg:'#F5F3FF', hBdr:'#E9D5FF', icon:'ri-building-2-line', iconClr:'#7C3AED', w:'200px' }, + { border:'1px solid #C7D2FE', hBg:'#EEF2FF', hBdr:'#C7D2FE', icon:'ri-git-branch-line', iconClr:'#6366F1', w:'180px' }, + { border:'1px solid #E5E7EB', hBg:'#F9FAFB', hBdr:'#E5E7EB', icon:'ri-subtract-line', iconClr:'#6B7280', w:'160px' }, + ]; + const s = styles[Math.min(level, styles.length - 1)]; + + let h = `
`; + h += `
`; + h += `
`; + h += `
`; + h += ``; + h += ``; + h += `${this.esc(dept.name)}`; + if (dept.code) h += `(${this.esc(dept.code)})`; + h += '
'; + h += `
`; + if (emps.length) { + for (const e of emps) h += this.buildEmpHtml(e, false); + } else { + h += '
드래그하여 배치
'; + } + h += '
'; + h += `
${total}명
`; + h += '
'; + if (children.length > 0) { + h += `
`; + } + h += this.buildChildrenHtml(dept.id, level + 1); + h += '
'; + return h; + }, + + buildEmpHtml(emp, isLarge) { + const color = this.getAvatarColor(emp.display_name); + const initial = (emp.display_name || '?').charAt(0); + const pos = emp.position_label || ''; + const name = this.esc(emp.display_name || ''); + const label = pos ? `${this.esc(pos)} ${name}` : name; + if (isLarge) { + return `
+
${initial}
+

${label}

`; + } + return `
+
${initial}
+ ${label} +
`; + }, + + // ===== SortableJS ===== initDeptSortables() { - // 1단계 부서 정렬 - const rootEl = document.getElementById('root-dept-sortable'); - if (rootEl && !rootEl._deptSortableInitialized) { - rootEl._deptSortableInitialized = true; - this.deptSortables.push(new Sortable(rootEl, { - handle: '.dept-drag-handle', - animation: 200, - ghostClass: 'sortable-ghost', - dragClass: 'sortable-drag', - draggable: '.dept-sortable-item', - onEnd: (evt) => this.handleDeptReorder(evt, null), - })); + document.querySelectorAll('.org-children').forEach(el => { + const inst = new Sortable(el, { + group: 'departments', handle: '.dept-drag-handle', animation: 200, + fallbackOnBody: true, swapThreshold: 0.65, draggable: '.org-node-wrap', emptyInsertThreshold: 20, + onStart: () => document.body.classList.add('dept-dragging'), + onEnd: () => document.body.classList.remove('dept-dragging'), + onMove: (evt) => { + const dragId = parseInt(evt.dragged.dataset.deptId); + const toPid = evt.to.dataset.parentId ? parseInt(evt.to.dataset.parentId) : null; + if (toPid === dragId || this.isDescendant(dragId, toPid)) return false; + }, + onAdd: (evt) => this.handleDeptMove(evt), + onUpdate: (evt) => this.handleDeptReorder(evt), + }); + this.deptSortables.push(inst); + }); + document.querySelectorAll('.org-drop-target').forEach(el => { + const inst = new Sortable(el, { + group: 'departments', animation: 200, draggable: '.org-node-wrap', emptyInsertThreshold: 30, + onStart: () => document.body.classList.add('dept-dragging'), + onEnd: () => document.body.classList.remove('dept-dragging'), + onMove: (evt) => { + const dragId = parseInt(evt.dragged.dataset.deptId); + const toPid = parseInt(el.dataset.parentId); + if (toPid === dragId || this.isDescendant(dragId, toPid)) return false; + }, + onAdd: (evt) => this.handleDeptMove(evt), + }); + this.deptSortables.push(inst); + }); + }, + + initEmpSortables() { + document.querySelectorAll('.emp-zone').forEach(el => { + const inst = new Sortable(el, { + group: 'employees', animation: 150, + ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', + onAdd: (evt) => { + const empId = parseInt(evt.item.dataset.employeeId); + const deptId = parseInt(evt.to.dataset.departmentId); + evt.item.remove(); + const emp = this.employees.find(e => e.id === empId); + if (emp) emp.department_id = deptId; + this.renderTree(); this.renderUnassigned(); + this.saveAssignment(empId, deptId); + }, + }); + this.empSortables.push(inst); + }); + }, + + handleDeptMove(evt) { + const deptId = parseInt(evt.item.dataset.deptId); + const newParentId = evt.to.dataset.parentId ? parseInt(evt.to.dataset.parentId) : null; + const dept = this.departments.find(d => d.id === deptId); + if (dept) dept.parent_id = newParentId; + const orders = this.collectSortOrders(evt.to, newParentId); + if (evt.from !== evt.to) { + const oldPid = evt.from.dataset.parentId ? parseInt(evt.from.dataset.parentId) : null; + orders.push(...this.collectSortOrders(evt.from, oldPid)); } - - // 2단계 부서 정렬 - document.querySelectorAll('.child-dept-sortable').forEach(el => { - if (el._deptSortableInitialized) return; - el._deptSortableInitialized = true; - const parentId = parseInt(el.dataset.parentId); - this.deptSortables.push(new Sortable(el, { - handle: '.dept-drag-handle', - animation: 200, - ghostClass: 'sortable-ghost', - dragClass: 'sortable-drag', - draggable: '.dept-sortable-item', - onEnd: (evt) => this.handleDeptReorder(evt, parentId), - })); + orders.forEach(o => { + const d = this.departments.find(dd => dd.id === o.id); + if (d) { d.parent_id = o.parent_id; d.sort_order = o.sort_order; } }); - }, - - handleDrop(evt) { - const employeeId = parseInt(evt.item.dataset.employeeId); - const targetDeptId = evt.to.dataset.departmentId; - const newDeptId = targetDeptId ? parseInt(targetDeptId) : null; - - const emp = this.employees.find(e => e.id === employeeId); - if (emp) emp.department_id = newDeptId; - - this.saveAssignment(employeeId, newDeptId); - }, - - handleDeptReorder(evt, parentId) { - // DOM에서 현재 순서 읽기 - const container = evt.from; - const items = container.querySelectorAll(':scope > .dept-sortable-item'); - const orders = []; - - items.forEach((item, index) => { - const deptId = parseInt(item.dataset.deptId); - orders.push({ id: deptId, parent_id: parentId, sort_order: index + 1 }); - - // Alpine 데이터도 갱신 - const dept = this.departments.find(d => d.id === deptId); - if (dept) dept.sort_order = index + 1; - }); - + this.renderTree(); this.renderUnassigned(); this.saveDeptOrder(orders); }, - async saveAssignment(employeeId, departmentId) { + handleDeptReorder(evt) { + const parentId = evt.from.dataset.parentId ? parseInt(evt.from.dataset.parentId) : null; + const orders = this.collectSortOrders(evt.from, parentId); + orders.forEach(o => { + const d = this.departments.find(dd => dd.id === o.id); + if (d) d.sort_order = o.sort_order; + }); + this.renderTree(); + this.saveDeptOrder(orders); + }, + + collectSortOrders(container, parentId) { + const items = container.querySelectorAll(':scope > .org-node-wrap'); + return [...items].map((el, idx) => ({ + id: parseInt(el.dataset.deptId), parent_id: parentId, sort_order: idx + 1, + })); + }, + + handleClick(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + if (btn.dataset.action === 'unassign') { + e.preventDefault(); + this.unassignEmployee(parseInt(btn.dataset.empId)); + } + }, + + async saveAssignment(empId, deptId) { this.saving = true; try { - const url = departmentId - ? '{{ route("rd.org-chart.assign") }}' - : '{{ route("rd.org-chart.unassign") }}'; - const body = departmentId - ? { employee_id: employeeId, department_id: departmentId } - : { employee_id: employeeId }; - - await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, - 'Accept': 'application/json', - }, - body: JSON.stringify(body), - }); - } catch (e) { - console.error('조직도 저장 실패:', e); - } finally { - this.saving = false; - } + const url = deptId ? '{{ route("rd.org-chart.assign") }}' : '{{ route("rd.org-chart.unassign") }}'; + const body = deptId ? { employee_id: empId, department_id: deptId } : { employee_id: empId }; + await fetch(url, { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':document.querySelector('meta[name="csrf-token"]').content,'Accept':'application/json'}, body:JSON.stringify(body) }); + } catch(e) { console.error('저장 실패:', e); } + finally { this.saving = false; } }, async saveDeptOrder(orders) { this.saving = true; try { - await fetch('{{ route("rd.org-chart.reorder-depts") }}', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, - 'Accept': 'application/json', - }, - body: JSON.stringify({ orders }), - }); + await fetch('{{ route("rd.org-chart.reorder-depts") }}', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':document.querySelector('meta[name="csrf-token"]').content,'Accept':'application/json'}, body:JSON.stringify({orders}) }); if (typeof showToast === 'function') showToast('부서 순서가 저장되었습니다.', 'success'); - } catch (e) { - console.error('부서 순서 저장 실패:', e); - } finally { - this.saving = false; - } + } catch(e) { console.error('부서 순서 저장 실패:', e); } + finally { this.saving = false; } }, async unassignEmployee(empId) { const emp = this.employees.find(e => e.id === empId); - if (emp) { - emp.department_id = null; - await this.saveAssignment(empId, null); - } + if (emp) emp.department_id = null; + this.renderTree(); this.renderUnassigned(); + await this.saveAssignment(empId, null); }, }; }