HH/templates/organizations/staff_hierarchy_d3.html

618 lines
24 KiB
HTML

{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Staff Hierarchy - D3 Visualization" %}{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Title -->
<div class="page-title-box">
<h4>{% trans "Staff Hierarchy" %}</h4>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'organizations:hospital_list' %}">{% trans "Organizations" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'organizations:staff_list' %}">{% trans "Staff" %}</a></li>
<li class="breadcrumb-item active">{% trans "Hierarchy" %}</li>
</ol>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<div class="card-body">
<div class="stat-icon bg-teal">
<i class="bi bi-people"></i>
</div>
<div class="stat-value" id="totalStaff">-</div>
<div class="stat-label">{% trans "Total Staff" %}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="card-body">
<div class="stat-icon bg-blue">
<i class="bi bi-diagram-3"></i>
</div>
<div class="stat-value" id="topManagers">-</div>
<div class="stat-label">{% trans "Top Managers" %}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="card-body">
<div class="stat-icon bg-green">
<i class="bi bi-layers"></i>
</div>
<div class="stat-value" id="avgDepth">-</div>
<div class="stat-label">{% trans "Avg. Hierarchy Depth" %}</div>
</div>
</div>
</div>
</div>
<!-- Controls -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">{% trans "Search Staff" %}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="searchInput" placeholder="{% trans 'Search by name or ID' %}">
</div>
</div>
<div class="col-md-3">
<label class="form-label">{% trans "Layout" %}</label>
<select class="form-select" id="layoutSelect">
<option value="horizontal">{% trans "Horizontal" %}</option>
<option value="vertical">{% trans "Vertical" %}</option>
<option value="radial">{% trans "Radial" %}</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">{% trans "Node Size By" %}</label>
<select class="form-select" id="nodeSizeSelect">
<option value="fixed">{% trans "Fixed Size" %}</option>
<option value="team">{% trans "Team Size" %}</option>
<option value="level">{% trans "Hierarchy Level" %}</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" id="resetZoom">
<i class="bi bi-zoom-in"></i> {% trans "Reset View" %}
</button>
</div>
</div>
</div>
</div>
<!-- D3 Visualization Container -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-diagram-3"></i> {% trans "Organizational Chart" %}
</h5>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" id="expandAllBtn">
<i class="bi bi-plus-square"></i> {% trans "Expand All" %}
</button>
<button class="btn btn-sm btn-outline-primary" id="collapseAllBtn">
<i class="bi bi-dash-square"></i> {% trans "Collapse All" %}
</button>
</div>
</div>
<div class="card-body">
<div id="hierarchy-chart" style="width: 100%; height: 800px; overflow: hidden;"></div>
</div>
</div>
<!-- Instructions -->
<div class="card mt-4">
<div class="card-header">
<i class="bi bi-info-circle"></i> {% trans "Instructions" %}
</div>
<div class="card-body">
<ul class="mb-0">
<li><strong>{% trans "Zoom & Pan" %}:</strong> {% trans "Use mouse wheel to zoom, click and drag to pan the chart" %}</li>
<li><strong>{% trans "Expand/Collapse" %}:</strong> {% trans "Click on a node to expand or collapse its children" %}</li>
<li><strong>{% trans "View Details" %}:</strong> {% trans "Double-click on a node to view full staff details" %}</li>
<li><strong>{% trans "Search" %}:</strong> {% trans "Type in the search box to find a staff member. The chart will automatically navigate to them" %}</li>
<li><strong>{% trans "Layout Options" %}:</strong> {% trans "Switch between horizontal, vertical, and radial layouts" %}</li>
</ul>
</div>
</div>
</div>
<!-- D3 Hierarchy Visualization Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Chart dimensions
const margin = { top: 40, right: 120, bottom: 40, left: 120 };
let width = document.getElementById('hierarchy-chart').offsetWidth - margin.left - margin.right;
let height = 800 - margin.top - margin.bottom;
// Create SVG
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})`);
// Zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.1, 3])
.on("zoom", (event) => {
svg.attr("transform", event.transform);
});
d3.select("svg").call(zoom);
// Global variables
let root = null;
let treeLayout = null;
let currentLayout = 'horizontal';
let nodeSizeBy = 'fixed';
let i = 0;
// Fetch hierarchy data
fetchHierarchyData();
function fetchHierarchyData() {
fetch('/organizations/api/staff/hierarchy/')
.then(response => response.json())
.then(data => {
console.log('Hierarchy data received:', data);
// Check if hierarchy is empty
if (!data.hierarchy || data.hierarchy.length === 0) {
document.getElementById('totalStaff').textContent = '0';
document.getElementById('topManagers').textContent = '0';
document.getElementById('avgDepth').textContent = '0';
// Show empty state message
const chartContainer = document.getElementById('hierarchy-chart');
chartContainer.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 40px;">
<i class="bi bi-people" style="font-size: 64px; color: #ccc; margin-bottom: 20px;"></i>
<h4 style="color: #666; margin-bottom: 10px;">No Staff Data Available</h4>
<p style="color: #888; max-width: 400px; margin-bottom: 20px;">
The staff hierarchy is empty. Please import staff data using the CSV import command.
</p>
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: left; max-width: 500px;">
<strong>Import Staff Data:</strong>
<code style="display: block; margin-top: 8px; padding: 8px; background: #fff; border-radius: 4px; overflow-x: auto;">
python manage.py import_staff_csv your_staff_data.csv
</code>
</div>
</div>
`;
return;
}
// Update statistics
document.getElementById('totalStaff').textContent = data.statistics.total_staff.toLocaleString();
document.getElementById('topManagers').textContent = data.statistics.top_managers.toLocaleString();
document.getElementById('avgDepth').textContent = calculateAverageDepth(data.hierarchy).toFixed(1);
// Create hierarchy
const hierarchyData = {
name: "Organization",
children: data.hierarchy
};
root = d3.hierarchy(hierarchyData);
root.x0 = height / 2;
root.y0 = 0;
// Only collapse nodes that actually have children and are at depth > 1
root.descendants().forEach((d, i) => {
d.id = i;
// Only collapse if node has children and is deeper than first level
if (d.depth > 1 && d.children && d.children.length > 0) {
d._children = d.children;
d.children = null;
}
});
console.log('Root hierarchy:', root);
console.log('Total nodes:', root.descendants().length);
// Initial render
update(root);
// Center the view
centerView();
})
.catch(error => {
console.error('Error fetching hierarchy data:', error);
// Show error message
const chartContainer = document.getElementById('hierarchy-chart');
chartContainer.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 40px;">
<i class="bi bi-exclamation-triangle" style="font-size: 64px; color: #dc3545; margin-bottom: 20px;"></i>
<h4 style="color: #dc3545; margin-bottom: 10px;">Error Loading Hierarchy</h4>
<p style="color: #666; max-width: 400px;">
Failed to load hierarchy data. Please check your connection and try again.
</p>
<p style="color: #888; font-size: 14px;">
Error: ${error.message}
</p>
</div>
`;
});
}
function update(source) {
// Get tree layout based on current orientation
const duration = 500;
// Assigns the x, y position for the nodes
const treeData = treeLayout(root);
// Compute the new tree layout
const nodes = treeData.descendants();
const links = treeData.links();
// Normalize for fixed-depth
nodes.forEach(d => {
d.y = d.depth * 200;
});
// ****************** Nodes section ******************
// Update the nodes
const node = svg.selectAll('g.node')
.data(nodes, d => d.id || (d.id = ++i));
// Enter any new modes at the parent's previous position
const nodeEnter = node.enter().append('g')
.attr('class', 'node')
.attr("transform", d => `translate(${source.y0},${source.x0})`)
.on('click', click)
.on('dblclick', d => {
// Only navigate if node has a real ID (not virtual root)
if (d.data && d.data.id && !d.data.is_virtual_root) {
window.location.href = `/organizations/staff/${d.data.id}/`;
}
});
// Add circle for the nodes
nodeEnter.append('circle')
.attr('class', 'node')
.attr('r', d => d.data.is_virtual_root ? 20 : 10) // Larger for virtual root
.style("fill", d => d.data.is_virtual_root ? "#666" : (d._children ? "var(--hh-primary)" : "var(--hh-success)"))
.style("stroke", d => d.data.is_virtual_root ? "#999" : "var(--hh-primary)")
.style("stroke-width", "2px")
.style("cursor", d => d.data.is_virtual_root ? "default" : "pointer");
// Add labels for the nodes
nodeEnter.append('text')
.attr("dy", ".35em")
.attr("x", d => d.children || d._children ? -15 : 15)
.attr("text-anchor", d => d.children || d._children ? "end" : "start")
.text(d => {
if (d.data.name === "Organization") return d.data.name;
return d.data.name.length > 20 ? d.data.name.substring(0, 20) + '...' : d.data.name;
})
.style("fill-opacity", 0);
// Add tooltip
nodeEnter.append('title')
.text(d => {
if (!d.data || !d.data.name) return "Staff";
if (d.data.name === "Organization") return d.data.name;
return `${d.data.name}\n${d.data.job_title || ''}\nTeam: ${d.data.team_size || 0}`;
});
// UPDATE
const nodeUpdate = nodeEnter.merge(node);
// Transition to the proper position for the node
nodeUpdate.transition()
.duration(duration)
.attr("transform", d => {
if (currentLayout === 'horizontal') {
return `translate(${d.y},${d.x})`;
} else if (currentLayout === 'vertical') {
return `translate(${d.x},${d.y})`;
} else {
// Radial
const angle = (d.x - 90) * Math.PI / 180;
const radius = d.y;
return `translate(${radius * Math.cos(angle)},${radius * Math.sin(angle)})`;
}
});
// Update the node attributes and style
nodeUpdate.select('circle.node')
.attr('r', d => getNodeRadius(d))
.style("fill", d => {
if (d.data.is_virtual_root) return "#666";
return d._children ? "var(--hh-primary)" : "var(--hh-success)";
})
.style("stroke", d => d.data.is_virtual_root ? "#999" : "var(--hh-primary)")
.attr('cursor', d => d.data.is_virtual_root ? "default" : "pointer");
// Update the text
nodeUpdate.select('text')
.style("fill-opacity", 1)
.attr("x", d => {
if (currentLayout === 'horizontal') {
return d.children || d._children ? -15 : 15;
} else if (currentLayout === 'vertical') {
return 0;
} else {
// Radial
const angle = (d.x - 90) * Math.PI / 180;
return (d.children || d._children ? -1 : 1) * 15;
}
})
.attr("dy", ".35em")
.attr("text-anchor", d => {
if (currentLayout === 'horizontal') {
return d.children || d._children ? "end" : "start";
} else if (currentLayout === 'vertical') {
return "middle";
} else {
// Radial
return d.children || d._children ? "end" : "start";
}
});
// Remove any exiting nodes
const nodeExit = node.exit().transition()
.duration(duration)
.attr("transform", d => `translate(${source.y},${source.x})`)
.remove();
nodeExit.select('circle')
.attr('r', 1e-6);
nodeExit.select('text')
.style("fill-opacity", 1e-6);
// ****************** Links section ******************
// Update the links
const link = svg.selectAll('path.link')
.data(links, d => d.target.id);
// Enter any new links at the parent's previous position
const linkEnter = link.enter().insert('path', "g")
.attr("class", "link")
.attr('d', d => {
const o = {x: source.x0, y: source.y0};
return diagonal(o, o);
})
.style("fill", "none")
.style("stroke", "#ccc")
.style("stroke-width", "2px");
// UPDATE
const linkUpdate = linkEnter.merge(link);
// Transition back to the parent element position
linkUpdate.transition()
.duration(duration)
.attr('d', d => diagonal(d.source, d.target));
// Remove any exiting links
link.exit().transition()
.duration(duration)
.attr('d', d => {
const o = {x: source.x, y: source.y};
return diagonal(o, o);
})
.remove();
// Store the old positions for transition
nodes.forEach(d => {
d.x0 = d.x;
d.y0 = d.y;
});
}
// Creates a curved (diagonal) path from parent to the child nodes
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}`;
} else 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}`;
} else {
// Radial
const sAngle = (s.x - 90) * Math.PI / 180;
const dAngle = (d.x - 90) * Math.PI / 180;
const sRadius = s.y;
const dRadius = d.y;
return `M ${sRadius * Math.cos(sAngle)} ${sRadius * Math.sin(sAngle)}
C ${sRadius * Math.cos(sAngle)} ${(sRadius + dRadius) / 2 * Math.sin(sAngle)},
${dRadius * Math.cos(dAngle)} ${(sRadius + dRadius) / 2 * Math.sin(dAngle)},
${dRadius * Math.cos(dAngle)} ${dRadius * Math.sin(dAngle)}`;
}
}
// Toggle children on click
function click(event, d) {
if (d.children) {
d._children = d.children;
d.children = null;
} else {
d.children = d._children;
d._children = null;
}
update(d);
}
// Get node radius based on nodeSizeBy setting
function getNodeRadius(d) {
// Virtual root always gets larger size
if (d.data.is_virtual_root) {
return 20;
}
if (nodeSizeBy === 'fixed') {
return 10;
} else if (nodeSizeBy === 'team') {
const teamSize = d.data.team_size || 0;
return Math.min(8 + Math.sqrt(teamSize) * 2, 25);
} else if (nodeSizeBy === 'level') {
return Math.max(10, 30 - d.depth * 3);
}
return 10;
}
// Calculate average depth
function calculateAverageDepth(hierarchy) {
let totalDepth = 0;
let count = 0;
function traverse(node, depth) {
if (node.name !== "Organization") {
totalDepth += depth;
count++;
}
if (node.children) {
node.children.forEach(child => traverse(child, depth + 1));
}
}
hierarchy.forEach(root => traverse(root, 1));
return count > 0 ? totalDepth / count : 0;
}
// Center the view
function centerView() {
const chart = d3.select("#hierarchy-chart svg");
const zoomIdentity = d3.zoomIdentity.translate(width/2, height/2).scale(1);
chart.transition().duration(750).call(zoom.transform, zoomIdentity);
}
// Search functionality
document.getElementById('searchInput').addEventListener('keyup', function(e) {
if (e.key === 'Enter') {
const searchTerm = e.target.value.toLowerCase();
if (!searchTerm || !root) return;
// Find matching node
let foundNode = null;
root.descendants().forEach(d => {
if (d.data.name && d.data.name.toLowerCase().includes(searchTerm) ||
d.data.employee_id && d.data.employee_id.toString().includes(searchTerm)) {
foundNode = d;
}
});
if (foundNode) {
// Expand all nodes leading to the found node
let current = foundNode;
while (current.parent) {
if (current.parent._children) {
current.parent.children = current.parent._children;
current.parent._children = null;
}
current = current.parent;
}
update(root);
// Zoom to the found node
const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(1.5)
.translate(-foundNode.y, -foundNode.x);
d3.select("svg").transition().duration(750).call(zoom.transform, transform);
// Highlight the found node
setTimeout(() => {
svg.selectAll('circle.node')
.style("stroke", d => d === foundNode ? "var(--hh-accent)" : "var(--hh-primary)")
.style("stroke-width", d => d === foundNode ? "4px" : "2px");
}, 750);
alert(`Found: ${foundNode.data.name}`);
} else {
alert('Staff member not found');
}
}
});
// Layout change
document.getElementById('layoutSelect').addEventListener('change', function(e) {
currentLayout = e.target.value;
// Update tree layout based on orientation
if (currentLayout === 'horizontal') {
treeLayout = d3.tree().nodeSize([50, 200]);
} else if (currentLayout === 'vertical') {
treeLayout = d3.tree().nodeSize([200, 50]);
} else {
treeLayout = d3.tree().nodeSize([50, 200]);
}
update(root);
centerView();
});
// Node size change
document.getElementById('nodeSizeSelect').addEventListener('change', function(e) {
nodeSizeBy = e.target.value;
update(root);
});
// Expand all
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);
});
// Collapse all
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);
});
// Reset zoom
document.getElementById('resetZoom').addEventListener('click', function() {
centerView();
});
// Initialize tree layout
treeLayout = d3.tree().nodeSize([50, 200]);
// Resize handler
window.addEventListener('resize', function() {
width = document.getElementById('hierarchy-chart').offsetWidth - margin.left - margin.right;
d3.select("svg")
.attr("width", width + margin.left + margin.right);
if (root) update(root);
});
});
</script>
{% endblock %}