-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
등록된 부서가 없습니다. 먼저 부서를 등록해주세요.
-
+
+
-
저장 중...
@@ -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 `
`;
+ }
+ 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);
},
};
}