697 lines
32 KiB
HTML
697 lines
32 KiB
HTML
{% extends 'layouts/base.html' %}
|
|
{% load i18n %}
|
|
|
|
{% block title %}{% trans "Staff Hierarchy" %} - PX360{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
/* D3 Styles */
|
|
.node circle {
|
|
fill: white;
|
|
stroke-width: 3px;
|
|
transition: all 0.3s ease;
|
|
cursor: pointer;
|
|
}
|
|
.node circle:hover {
|
|
filter: drop-shadow(0 0 8px rgba(0, 123, 189, 0.5));
|
|
stroke-width: 4px;
|
|
}
|
|
.node text {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
fill: #1e293b;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
.link {
|
|
fill: none;
|
|
stroke: #cbd5e1;
|
|
stroke-width: 2px;
|
|
transition: all 0.5s ease;
|
|
}
|
|
.link:hover {
|
|
stroke: #007bbd;
|
|
}
|
|
|
|
/* Loading Animation */
|
|
@keyframes pulse-ring {
|
|
0% { transform: scale(0.8); opacity: 1; }
|
|
100% { transform: scale(1.3); opacity: 0; }
|
|
}
|
|
.loading-pulse {
|
|
animation: pulse-ring 1.5s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
|
|
}
|
|
|
|
/* Progress bar animation */
|
|
@keyframes shimmer {
|
|
0% { background-position: -200% 0; }
|
|
100% { background-position: 200% 0; }
|
|
}
|
|
.progress-shimmer {
|
|
background: linear-gradient(90deg, #005696 25%, #007bbd 50%, #005696 75%);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 2s infinite;
|
|
}
|
|
|
|
/* Custom scrollbar for chart */
|
|
#hierarchy-chart::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
#hierarchy-chart::-webkit-scrollbar-track {
|
|
background: #f1f5f9;
|
|
border-radius: 4px;
|
|
}
|
|
#hierarchy-chart::-webkit-scrollbar-thumb {
|
|
background: #cbd5e1;
|
|
border-radius: 4px;
|
|
}
|
|
#hierarchy-chart::-webkit-scrollbar-thumb:hover {
|
|
background: #94a3b8;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="p-6">
|
|
<!-- Page Header -->
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-navy to-blue flex items-center justify-center">
|
|
<i data-lucide="git-branch" class="w-5 h-5 text-white"></i>
|
|
</div>
|
|
{% trans "Staff Hierarchy" %}
|
|
</h1>
|
|
<p class="text-slate mt-2 ml-13">{% trans "Interactive organizational chart with reporting relationships" %}</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<a href="{% url 'organizations:staff_list' %}" class="px-4 py-2.5 text-slate hover:text-navy hover:bg-light rounded-xl font-medium transition flex items-center gap-2 border border-slate-200">
|
|
<i data-lucide="list" class="w-5 h-5"></i> {% trans "List View" %}
|
|
</a>
|
|
<a href="{% url 'organizations:staff_create' %}" class="px-4 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 shadow-lg shadow-navy/20">
|
|
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Add Staff" %}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<!-- Total Staff -->
|
|
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
|
|
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-navy to-blue flex items-center justify-center flex-shrink-0">
|
|
<i data-lucide="users" class="w-7 h-7 text-white"></i>
|
|
</div>
|
|
<div>
|
|
<p class="text-slate text-sm font-medium">{% trans "Total Staff" %}</p>
|
|
<p class="text-2xl font-bold text-navy" id="totalStaff">-</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top Managers -->
|
|
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
|
|
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-blue to-cyan-500 flex items-center justify-center flex-shrink-0">
|
|
<i data-lucide="crown" class="w-7 h-7 text-white"></i>
|
|
</div>
|
|
<div>
|
|
<p class="text-slate text-sm font-medium">{% trans "Top Managers" %}</p>
|
|
<p class="text-2xl font-bold text-navy" id="topManagers">-</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Avg Depth -->
|
|
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
|
|
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 flex items-center justify-center flex-shrink-0">
|
|
<i data-lucide="layers" class="w-7 h-7 text-white"></i>
|
|
</div>
|
|
<div>
|
|
<p class="text-slate text-sm font-medium">{% trans "Avg. Depth" %}</p>
|
|
<p class="text-2xl font-bold text-navy" id="avgDepth">-</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load Time -->
|
|
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
|
|
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-violet-500 to-purple-500 flex items-center justify-center flex-shrink-0">
|
|
<i data-lucide="zap" class="w-7 h-7 text-white"></i>
|
|
</div>
|
|
<div>
|
|
<p class="text-slate text-sm font-medium">{% trans "Load Time" %}</p>
|
|
<p class="text-2xl font-bold text-navy" id="loadTime">-</p>
|
|
<p class="text-xs text-slate">{% trans "milliseconds" %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls Toolbar -->
|
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6">
|
|
<div class="flex flex-wrap items-end gap-4">
|
|
<!-- Search -->
|
|
<div class="flex-1 min-w-[200px]">
|
|
<label class="block text-sm font-medium text-slate mb-2">{% trans "Search Staff" %}</label>
|
|
<div class="relative">
|
|
<i data-lucide="search" class="w-5 h-5 text-slate absolute left-3 top-1/2 -translate-y-1/2"></i>
|
|
<input type="text" id="searchInput" placeholder="{% trans 'Search by name or employee ID...' %}"
|
|
class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Layout -->
|
|
<div class="w-40">
|
|
<label class="block text-sm font-medium text-slate mb-2">{% trans "Layout" %}</label>
|
|
<div class="relative">
|
|
<select id="layoutSelect" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition appearance-none bg-white">
|
|
<option value="horizontal">{% trans "Horizontal" %}</option>
|
|
<option value="vertical">{% trans "Vertical" %}</option>
|
|
<option value="radial">{% trans "Radial" %}</option>
|
|
</select>
|
|
<i data-lucide="chevron-down" class="w-4 h-4 text-slate absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Node Size -->
|
|
<div class="w-40">
|
|
<label class="block text-sm font-medium text-slate mb-2">{% trans "Node Size" %}</label>
|
|
<div class="relative">
|
|
<select id="nodeSizeSelect" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition appearance-none bg-white">
|
|
<option value="fixed">{% trans "Fixed" %}</option>
|
|
<option value="team">{% trans "By Team" %}</option>
|
|
<option value="level">{% trans "By Level" %}</option>
|
|
</select>
|
|
<i data-lucide="chevron-down" class="w-4 h-4 text-slate absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Max Depth -->
|
|
<div class="w-32">
|
|
<label class="block text-sm font-medium text-slate mb-2">{% trans "Max Depth" %}</label>
|
|
<div class="relative">
|
|
<select id="depthSelect" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition appearance-none bg-white">
|
|
<option value="2">2 {% trans "levels" %}</option>
|
|
<option value="3" selected>3 {% trans "levels" %}</option>
|
|
<option value="4">4 {% trans "levels" %}</option>
|
|
<option value="0">{% trans "All" %}</option>
|
|
</select>
|
|
<i data-lucide="chevron-down" class="w-4 h-4 text-slate absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex gap-2">
|
|
<button id="reloadBtn" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2" title="{% trans 'Reload' %}">
|
|
<i data-lucide="refresh-cw" class="w-5 h-5"></i>
|
|
</button>
|
|
<button id="resetZoom" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2" title="{% trans 'Reset View' %}">
|
|
<i data-lucide="maximize-2" class="w-5 h-5"></i>
|
|
</button>
|
|
<button id="expandAllBtn" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2">
|
|
<i data-lucide="expand" class="w-5 h-5"></i> {% trans "Expand" %}
|
|
</button>
|
|
<button id="collapseAllBtn" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2">
|
|
<i data-lucide="collapse" class="w-5 h-5"></i> {% trans "Collapse" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart Container -->
|
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<!-- Progress Bar -->
|
|
<div id="loadingProgress" class="h-1 bg-slate-100 hidden">
|
|
<div id="progressBar" class="h-full progress-shimmer w-0 transition-all duration-300"></div>
|
|
</div>
|
|
|
|
<!-- Chart Area -->
|
|
<div class="relative" style="height: 600px;">
|
|
<!-- Loading State -->
|
|
<div id="chartLoader" class="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
|
|
<div class="relative mb-6">
|
|
<div class="w-20 h-20 rounded-full bg-blue/10 flex items-center justify-center">
|
|
<i data-lucide="git-branch" class="w-10 h-10 text-blue"></i>
|
|
</div>
|
|
<div class="absolute inset-0 rounded-full border-4 border-blue/20 loading-pulse"></div>
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-navy mb-2">{% trans "Loading Hierarchy" %}</h3>
|
|
<p class="text-slate text-sm" id="loadingStatus">{% trans "Fetching staff data..." %}</p>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div id="emptyState" class="hidden absolute inset-0 flex flex-col items-center justify-center bg-white">
|
|
<div class="w-24 h-24 rounded-full bg-light flex items-center justify-center mb-6">
|
|
<i data-lucide="users" class="w-12 h-12 text-slate"></i>
|
|
</div>
|
|
<h3 class="text-xl font-semibold text-navy mb-2">{% trans "No Staff Data" %}</h3>
|
|
<p class="text-slate text-center max-w-md mb-6">{% trans "Start building your organizational chart by adding staff members to the system." %}</p>
|
|
<a href="{% url 'organizations:staff_create' %}" class="px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
|
|
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Add First Staff Member" %}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div id="errorState" class="hidden absolute inset-0 flex flex-col items-center justify-center bg-white">
|
|
<div class="w-24 h-24 rounded-full bg-red-50 flex items-center justify-center mb-6">
|
|
<i data-lucide="alert-circle" class="w-12 h-12 text-red-500"></i>
|
|
</div>
|
|
<h3 class="text-xl font-semibold text-red-600 mb-2">{% trans "Failed to Load" %}</h3>
|
|
<p class="text-slate text-center max-w-md mb-2" id="errorMessage"></p>
|
|
<button id="retryBtn" class="px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 mt-4">
|
|
<i data-lucide="refresh-cw" class="w-5 h-5"></i> {% trans "Try Again" %}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- D3 Chart -->
|
|
<div id="hierarchy-chart" class="w-full h-full overflow-auto bg-gradient-to-br from-slate-50 to-white"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Instructions -->
|
|
<div class="mt-6 bg-gradient-to-r from-light to-blue-50 rounded-2xl p-6 border border-blue-100">
|
|
<h3 class="text-lg font-semibold text-navy mb-4 flex items-center gap-2">
|
|
<i data-lucide="info" class="w-5 h-5"></i>
|
|
{% trans "How to Use" %}
|
|
</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="space-y-3">
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
<i data-lucide="mouse-pointer-click" class="w-4 h-4 text-blue"></i>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-navy text-sm">{% trans "Click Nodes" %}</p>
|
|
<p class="text-slate text-sm">{% trans "Click to expand or collapse team members" %}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
<i data-lucide="mouse" class="w-4 h-4 text-blue"></i>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-navy text-sm">{% trans "Zoom & Pan" %}</p>
|
|
<p class="text-slate text-sm">{% trans "Scroll to zoom, drag to pan around" %}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
<i data-lucide="chevron-right" class="w-4 h-4 text-blue"></i>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-navy text-sm">{% trans "Lazy Loading" %}</p>
|
|
<p class="text-slate text-sm">{% trans "Click ▶ to load more team members on demand" %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
<i data-lucide="search" class="w-4 h-4 text-blue"></i>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-navy text-sm">{% trans "Quick Search" %}</p>
|
|
<p class="text-slate text-sm">{% trans "Press Enter in search to find and focus on staff" %}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
<i data-lucide="external-link" class="w-4 h-4 text-blue"></i>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-navy text-sm">{% trans "View Details" %}</p>
|
|
<p class="text-slate text-sm">{% trans "Double-click any node to view staff profile" %}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
<i data-lucide="layers" class="w-4 h-4 text-blue"></i>
|
|
</div>
|
|
<div>
|
|
<p class="font-medium text-navy text-sm">{% trans "Performance" %}</p>
|
|
<p class="text-slate text-sm">{% trans "Reduce Max Depth for faster loading with large teams" %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- D3.js -->
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (typeof d3 === 'undefined') {
|
|
showError('Failed to load D3 library. Please check your connection.');
|
|
return;
|
|
}
|
|
|
|
const margin = { top: 40, right: 100, bottom: 40, left: 100 };
|
|
let width, height;
|
|
let root, treeLayout;
|
|
let currentLayout = 'horizontal', nodeSizeBy = 'fixed';
|
|
let i = 0, loadedNodes = new Set();
|
|
|
|
const colors = { primary: '#005696', blue: '#007bbd', success: '#10b981', slate: '#64748b' };
|
|
|
|
function initChart() {
|
|
const container = document.getElementById('hierarchy-chart');
|
|
width = container.clientWidth - margin.left - margin.right;
|
|
height = container.clientHeight - margin.top - margin.bottom;
|
|
|
|
d3.select("#hierarchy-chart").selectAll("*").remove();
|
|
|
|
const svg = d3.select("#hierarchy-chart")
|
|
.append("svg")
|
|
.attr("width", width + margin.left + margin.right)
|
|
.attr("height", height + margin.top + margin.bottom)
|
|
.append("g")
|
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
|
|
|
const zoom = d3.zoom().scaleExtent([0.1, 3]).on("zoom", (e) => svg.attr("transform", e.transform));
|
|
d3.select("svg").call(zoom);
|
|
|
|
return svg;
|
|
}
|
|
|
|
let svg = initChart();
|
|
|
|
function showLoading(show) {
|
|
document.getElementById('chartLoader').classList.toggle('hidden', !show);
|
|
document.getElementById('loadingProgress').classList.toggle('hidden', !show);
|
|
}
|
|
|
|
function updateProgress(pct) {
|
|
document.getElementById('progressBar').style.width = pct + '%';
|
|
}
|
|
|
|
function showEmpty() {
|
|
showLoading(false);
|
|
document.getElementById('emptyState').classList.remove('hidden');
|
|
document.getElementById('hierarchy-chart').classList.add('hidden');
|
|
}
|
|
|
|
function showError(msg) {
|
|
showLoading(false);
|
|
document.getElementById('errorMessage').textContent = msg;
|
|
document.getElementById('errorState').classList.remove('hidden');
|
|
document.getElementById('hierarchy-chart').classList.add('hidden');
|
|
}
|
|
|
|
async function fetchHierarchy() {
|
|
showLoading(true);
|
|
updateProgress(10);
|
|
document.getElementById('emptyState').classList.add('hidden');
|
|
document.getElementById('errorState').classList.add('hidden');
|
|
document.getElementById('hierarchy-chart').classList.remove('hidden');
|
|
|
|
const maxDepth = document.getElementById('depthSelect').value;
|
|
|
|
try {
|
|
const res = await fetch(`/organizations/api/staff/hierarchy/?max_depth=${maxDepth}`);
|
|
const data = await res.json();
|
|
|
|
updateProgress(50);
|
|
|
|
if (!data.hierarchy?.length) {
|
|
showEmpty();
|
|
return;
|
|
}
|
|
|
|
// Update stats
|
|
document.getElementById('totalStaff').textContent = data.statistics.total_staff.toLocaleString();
|
|
document.getElementById('topManagers').textContent = data.statistics.top_managers.toLocaleString();
|
|
document.getElementById('loadTime').textContent = data.statistics.load_time_ms;
|
|
|
|
// Build tree
|
|
const hierarchyData = { name: "Organization", is_virtual_root: true, children: data.hierarchy };
|
|
root = d3.hierarchy(hierarchyData);
|
|
root.x0 = height / 2;
|
|
root.y0 = 0;
|
|
|
|
// Mark loaded
|
|
if (root.children) root.children.forEach(c => loadedNodes.add(c.data.id));
|
|
|
|
// Collapse deep levels
|
|
root.descendants().forEach((d, idx) => {
|
|
d.id = idx;
|
|
if (d.depth > 2 && d.children) {
|
|
d._children = d.children;
|
|
d.children = null;
|
|
}
|
|
});
|
|
|
|
updateProgress(80);
|
|
|
|
treeLayout = d3.tree().nodeSize([50, 180]);
|
|
update(root);
|
|
centerView();
|
|
|
|
document.getElementById('avgDepth').textContent = calculateAvgDepth(data.hierarchy).toFixed(1);
|
|
|
|
updateProgress(100);
|
|
setTimeout(() => showLoading(false), 200);
|
|
|
|
} catch (err) {
|
|
showError(err.message);
|
|
}
|
|
}
|
|
|
|
async function loadChildren(nodeData) {
|
|
if (loadedNodes.has(nodeData.id) || !nodeData.has_children) return [];
|
|
loadedNodes.add(nodeData.id);
|
|
|
|
document.getElementById('loadingStatus').textContent = `Loading ${nodeData.name}'s team...`;
|
|
|
|
try {
|
|
const res = await fetch(`/organizations/api/staff/hierarchy/${nodeData.id}/children/`);
|
|
const data = await res.json();
|
|
document.getElementById('loadingStatus').textContent = '';
|
|
return data.children || [];
|
|
} catch (err) {
|
|
loadedNodes.delete(nodeData.id);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function update(source) {
|
|
const duration = 400;
|
|
const treeData = treeLayout(root);
|
|
const nodes = treeData.descendants();
|
|
const links = treeData.links();
|
|
|
|
nodes.forEach(d => d.y = d.depth * 180);
|
|
|
|
// Nodes
|
|
const node = svg.selectAll('g.node').data(nodes, d => d.id || (d.id = ++i));
|
|
|
|
const nodeEnter = node.enter().append('g')
|
|
.attr('class', 'node')
|
|
.attr('transform', d => `translate(${source.y0},${source.x0})`)
|
|
.on('click', async (e, d) => {
|
|
if (d.data.has_children && !d.children && !d._children) {
|
|
const children = await loadChildren(d.data);
|
|
if (children.length) {
|
|
if (!d.children) d.children = [];
|
|
children.forEach(c => {
|
|
const cn = d3.hierarchy(c);
|
|
cn.depth = d.depth + 1;
|
|
cn.parent = d;
|
|
d.children.push(cn);
|
|
});
|
|
}
|
|
}
|
|
click(e, d);
|
|
})
|
|
.on('dblclick', (e, d) => {
|
|
if (d.data?.id && !d.data.is_virtual_root) {
|
|
window.location.href = `/organizations/staff/${d.data.id}/`;
|
|
}
|
|
});
|
|
|
|
// Circle
|
|
nodeEnter.append('circle')
|
|
.attr('r', d => d.data.is_virtual_root ? 16 : 8)
|
|
.style('fill', d => d.data.is_virtual_root ? colors.slate : (d._children ? colors.primary : colors.success))
|
|
.style('stroke', d => d.data.is_virtual_root ? '#94a3b8' : colors.primary);
|
|
|
|
// Expand indicator
|
|
nodeEnter.append('text')
|
|
.attr('class', 'expand-indicator')
|
|
.attr('x', d => d.children || d._children ? -20 : 20)
|
|
.attr('y', 4)
|
|
.style('font-size', '12px')
|
|
.style('fill', colors.primary)
|
|
.text(d => d.data.has_children && !d.children && !d._children ? '▶' : '');
|
|
|
|
// Name label
|
|
nodeEnter.append('text')
|
|
.attr('dy', '.35em')
|
|
.attr('x', d => d.children || d._children ? -12 : 12)
|
|
.attr('text-anchor', d => d.children || d._children ? 'end' : 'start')
|
|
.text(d => d.data.name === 'Organization' ? d.data.name : (d.data.name.length > 16 ? d.data.name.substring(0, 16) + '...' : d.data.name))
|
|
.style('fill-opacity', 0);
|
|
|
|
// Tooltip
|
|
nodeEnter.append('title')
|
|
.text(d => `${d.data.name}${d.data.job_title ? '\n' + d.data.job_title : ''}${d.data.team_size ? '\nTeam: ' + d.data.team_size : ''}`);
|
|
|
|
// Update
|
|
const nodeUpdate = nodeEnter.merge(node);
|
|
|
|
nodeUpdate.transition().duration(duration)
|
|
.attr('transform', d => {
|
|
if (currentLayout === 'horizontal') return `translate(${d.y},${d.x})`;
|
|
if (currentLayout === 'vertical') return `translate(${d.x},${d.y})`;
|
|
const a = (d.x - 90) * Math.PI / 180;
|
|
return `translate(${d.y * Math.cos(a)},${d.y * Math.sin(a)})`;
|
|
});
|
|
|
|
nodeUpdate.select('circle')
|
|
.attr('r', d => getRadius(d))
|
|
.style('fill', d => d.data.is_virtual_root ? colors.slate : (d._children ? colors.primary : colors.success));
|
|
|
|
nodeUpdate.select('.expand-indicator')
|
|
.text(d => d.data.has_children && !d.children && !d._children ? '▶' : '');
|
|
|
|
nodeUpdate.select('text:not(.expand-indicator)')
|
|
.style('fill-opacity', 1)
|
|
.attr('x', d => {
|
|
const offset = getRadius(d) + 5;
|
|
if (currentLayout === 'horizontal') return d.children || d._children ? -offset - 8 : offset + 8;
|
|
if (currentLayout === 'vertical') return 0;
|
|
return (d.children || d._children ? -1 : 1) * (offset + 8);
|
|
})
|
|
.attr('text-anchor', d => {
|
|
if (currentLayout === 'horizontal') return d.children || d._children ? 'end' : 'start';
|
|
if (currentLayout === 'vertical') return 'middle';
|
|
return d.children || d._children ? 'end' : 'start';
|
|
});
|
|
|
|
// Remove
|
|
node.exit().transition().duration(duration)
|
|
.attr('transform', d => `translate(${source.y},${source.x})`)
|
|
.remove();
|
|
|
|
// Links
|
|
const link = svg.selectAll('path.link').data(links, d => d.target.id);
|
|
|
|
link.enter().insert('path', 'g')
|
|
.attr('class', 'link')
|
|
.attr('d', d => diagonal({x: source.x0, y: source.y0}, {x: source.x0, y: source.y0}))
|
|
.merge(link)
|
|
.transition().duration(duration)
|
|
.attr('d', d => diagonal(d.source, d.target));
|
|
|
|
link.exit().transition().duration(duration)
|
|
.attr('d', d => diagonal({x: source.x, y: source.y}, {x: source.x, y: source.y}))
|
|
.remove();
|
|
|
|
nodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; });
|
|
}
|
|
|
|
function diagonal(s, d) {
|
|
if (currentLayout === 'horizontal') {
|
|
return `M ${s.y} ${s.x} C ${(s.y + d.y) / 2} ${s.x}, ${(s.y + d.y) / 2} ${d.x}, ${d.y} ${d.x}`;
|
|
}
|
|
if (currentLayout === 'vertical') {
|
|
return `M ${s.x} ${s.y} C ${s.x} ${(s.y + d.y) / 2}, ${d.x} ${(s.y + d.y) / 2}, ${d.x} ${d.y}`;
|
|
}
|
|
const sA = (s.x - 90) * Math.PI / 180, dA = (d.x - 90) * Math.PI / 180;
|
|
return `M ${s.y * Math.cos(sA)} ${s.y * Math.sin(sA)} C ${s.y * Math.cos(sA)} ${(s.y + d.y) / 2 * Math.sin(sA)}, ${d.y * Math.cos(dA)} ${(s.y + d.y) / 2 * Math.sin(dA)}, ${d.y * Math.cos(dA)} ${d.y * Math.sin(dA)}`;
|
|
}
|
|
|
|
function click(e, d) {
|
|
if (d.children) { d._children = d.children; d.children = null; }
|
|
else { d.children = d._children; d._children = null; }
|
|
update(d);
|
|
}
|
|
|
|
function getRadius(d) {
|
|
if (d.data.is_virtual_root) return 16;
|
|
if (nodeSizeBy === 'fixed') return 8;
|
|
if (nodeSizeBy === 'team') return Math.min(7 + Math.sqrt(d.data.team_size || 0), 18);
|
|
return Math.max(7, 18 - d.depth * 2);
|
|
}
|
|
|
|
function calculateAvgDepth(h) {
|
|
let t = 0, c = 0;
|
|
function tr(n, d) {
|
|
if (n.name !== 'Organization') { t += d; c++; }
|
|
n.children?.forEach(x => tr(x, d + 1));
|
|
}
|
|
h.forEach(r => tr(r, 1));
|
|
return c ? t / c : 0;
|
|
}
|
|
|
|
function centerView() {
|
|
d3.select('#hierarchy-chart svg').transition().duration(750)
|
|
.call(d3.zoom().transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(0.85));
|
|
}
|
|
|
|
// Events
|
|
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
const term = this.value.toLowerCase();
|
|
if (!term || !root) return;
|
|
|
|
let found = null;
|
|
root.descendants().forEach(d => {
|
|
if (d.data.name?.toLowerCase().includes(term) || d.data.employee_id?.includes(term)) found = d;
|
|
});
|
|
|
|
if (found) {
|
|
let cur = found;
|
|
while (cur.parent) {
|
|
if (cur.parent._children) { cur.parent.children = cur.parent._children; cur.parent._children = null; }
|
|
cur = cur.parent;
|
|
}
|
|
update(root);
|
|
d3.select('#hierarchy-chart svg').transition().duration(750)
|
|
.call(d3.zoom().transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-found.y, -found.x));
|
|
|
|
// Highlight
|
|
svg.selectAll('circle').style('stroke', d => d === found ? '#f59e0b' : colors.primary).style('stroke-width', d => d === found ? '4px' : '3px');
|
|
} else {
|
|
alert('Staff not found');
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('layoutSelect').addEventListener('change', function() {
|
|
currentLayout = this.value;
|
|
treeLayout = d3.tree().nodeSize(currentLayout === 'vertical' ? [160, 45] : [45, 160]);
|
|
update(root);
|
|
centerView();
|
|
});
|
|
|
|
document.getElementById('nodeSizeSelect').addEventListener('change', function() {
|
|
nodeSizeBy = this.value;
|
|
update(root);
|
|
});
|
|
|
|
document.getElementById('depthSelect').addEventListener('change', fetchHierarchy);
|
|
document.getElementById('reloadBtn').addEventListener('click', fetchHierarchy);
|
|
document.getElementById('retryBtn').addEventListener('click', fetchHierarchy);
|
|
|
|
document.getElementById('expandAllBtn').addEventListener('click', function() {
|
|
if (!root) return;
|
|
root.descendants().forEach(d => { if (d._children) { d.children = d._children; d._children = null; } });
|
|
update(root);
|
|
});
|
|
|
|
document.getElementById('collapseAllBtn').addEventListener('click', function() {
|
|
if (!root) return;
|
|
root.descendants().forEach(d => { if (d.children && d.depth > 0) { d._children = d.children; d.children = null; } });
|
|
update(root);
|
|
});
|
|
|
|
document.getElementById('resetZoom').addEventListener('click', centerView);
|
|
|
|
window.addEventListener('resize', () => {
|
|
svg = initChart();
|
|
if (root) update(root);
|
|
});
|
|
|
|
// Init
|
|
treeLayout = d3.tree().nodeSize([45, 160]);
|
|
fetchHierarchy();
|
|
});
|
|
</script>
|
|
{% endblock %}
|