Partly working

This commit is contained in:
2026-07-28 14:21:21 -06:00
parent f55348f0b9
commit f497329169
4 changed files with 447 additions and 152 deletions
+40 -15
View File
@@ -1,25 +1,50 @@
class DashboardController < ApplicationController require 'yaml'
def index
joint_column = 'joint'
@available_joints = Joint.distinct.pluck(joint_column)
@available_data
@records = filter_records
class DashboardController < ApplicationController
before_action :load_manifest
def index
# Renders the main page
respond_to do |format| respond_to do |format|
format.html format.html
format.json { render json: @records } format.json { render json: @manifest }
end end
end end
# GET /dashboard/options?table=Joint&filter_col=joint_id&value=1
def options
table = params[:table].constantize
if params[:filter_col].present?
options = table.where(params[:filter_col] => params[:value]).pluck(params[:pluck_column])
else
puts table
options = table.distinct.pluck(params[:pluck_column])
end
render json: options
end
# GET /dashboard/data?joint=Acromioclavicular&sub_joint=X
def data
results = {}
# Use the data_sources from YAML to aggregate data
@manifest['data_sources'].each do |source|
table = source['table'].constantize
# Find the filter value from the params that matches the filter_column
# We assume the filter_column name in DB matches the filter ID in YAML
filter_val = params[source['filter_column']] || params['joint'] # Fallback to joint
record = table.find_by(source['filter_column'] => filter_val)
results[source['key']] = record ? record.attributes : {}
end
#debugger
render json: results
end
private private
def filter_records def load_manifest
# Using .where(joint: params[:joint]) handles both present and nil values gracefully @manifest = YAML.load_file(Rails.root.join('config', 'dashboard_manifest.yml'))
if params[:joint].present?
Joint.where(joint: params[:joint])
else
# Return empty array if no joint selected to avoid loading thousands of records
[]
end
end end
end end
+361 -133
View File
@@ -1,164 +1,392 @@
<h1 style="font-family: Arial, sans-serif;">Excel Replacement Dashboard</h1> <h1 style="font-family: Arial, sans-serif;">Dynamic Dashboard</h1>
<!-- Dropdown Filters --> <div id="filter-area" style="margin-bottom: 20px; display: flex; gap: 15px; font-family: Arial, sans-serif;">
<%= form_with url: dashboard_path, method: :get, id: "filter-form" do |f| %> <!-- Dropdowns injected here by JS -->
<div style="margin-bottom: 20px; font-family: Arial, sans-serif;"> </div>
<%= f.label :joint, "Select Joint:", style: "font-weight: bold;" %>
<%= f.select :joint, options_for_select(@available_joints, params[:joint]), include_blank: "Select a joint", id: "joint" %>
</div>
<% end %>
<hr> <div id="results-area" style="display: flex; gap: 20px;">
<!-- Grids injected here by JS -->
<h2 style="font-family: Arial, sans-serif;">Dashboard Results</h2>
<!-- Container where the formatted "Excel-style" grids will be injected -->
<div id="results-container" style="display: flex; flex-wrap: wrap; gap: 30px;">
<!-- JS will inject the tables here -->
</div> </div>
<style> <style>
/* Main Table Layout */ .dyn-grid { border-collapse: collapse; border: 2px solid #000; font-family: Arial; font-size: 12px; }
.joint-grid { .dyn-grid td { border: 1px solid #000; padding: 4px 8px; text-align: center; }
border-collapse: collapse;
border: 2px solid #000;
font-family: Arial, sans-serif;
font-size: 12px;
table-layout: fixed;
width: 600px;
}
.joint-grid td, .joint-grid th {
border: 1px solid #000;
padding: 4px 8px;
text-align: center;
vertical-align: middle;
}
/* Color Palette from Image */
.bg-dark-blue { background-color: #002060; color: white; font-weight: bold; } .bg-dark-blue { background-color: #002060; color: white; font-weight: bold; }
.bg-light-blue { background-color: #D9E1F2; font-weight: bold; } .bg-light-blue { background-color: #D9E1F2; font-weight: bold; }
.bg-pale-yellow { background-color: #FFFFCC; } .bg-pale-yellow { background-color: #FFFFCC; }
.bg-pale-green { background-color: #E2EFDA; } .bg-pale-green { background-color: #E2EFDA; }
.bg-pale-orange { background-color: #FCE4D6; } .bg-pale-orange { background-color: #FCE4D6; }
.text-red { color: #C00000; text-align: left !important; }
</style> </style>
<!-- <script> -->
<!-- document.addEventListener("DOMContentLoaded", () => { -->
<!-- let manifest = {}; -->
<!-- let currentSelections = {}; -->
<!-- -->
<!-- async function init() { -->
<!-- const resp = await fetch('/dashboard'); -->
<!-- manifest = await resp.json(); -->
<!-- renderRootFilters(); -->
<!-- } -->
<!-- -->
<!-- function renderRootFilters() { -->
<!-- const rootFilters = manifest.filters.filter(f => f.depends_on === null); -->
<!-- rootFilters.forEach(f => createDropdown(f)); -->
<!-- } -->
<!-- -->
<!-- function createDropdown(config) { -->
<!-- const container = document.createElement("div"); -->
<!-- container.id = `container_${config.id}`; -->
<!-- -->
<!-- const label = document.createElement("label"); -->
<!-- label.textContent = config.label + ": "; -->
<!-- label.style.fontWeight = "bold"; -->
<!-- -->
<!-- const select = document.createElement("select"); -->
<!-- select.id = config.id; -->
<!-- select.innerHTML = `<option value="">Select...</option>`; -->
<!-- -->
<!-- select.addEventListener("change", async (e) => { -->
<!-- const val = e.target.value; -->
<!-- currentSelections[config.id] = val; -->
<!-- -->
<!-- // 1. Clear dependent filters and data -->
<!-- clearDependents(config.id); -->
<!-- -->
<!-- // 2. Load options for children -->
<!-- const children = manifest.filters.filter(f => f.depends_on === config.id); -->
<!-- children.forEach(child => { -->
<!-- const options = await fetchOptions(child, val); -->
<!-- populateDropdown(child.id, options); -->
<!-- }); -->
<!-- -->
<!-- // 3. Update data grid -->
<!-- updateGrid(); -->
<!-- }); -->
<!-- -->
<!-- container.appendChild(label); -->
<!-- container.appendChild(select); -->
<!-- document.getElementById("filter-area").appendChild(container); -->
<!-- } -->
<!-- -->
<!-- async function fetchOptions(config, value) { -->
<!-- const params = new URLSearchParams({ -->
<!-- table: config.table, -->
<!-- pluck_column: config.pluck_column, -->
<!-- filter_col: config.filter_column, -->
<!-- value: value -->
<!-- }); -->
<!-- const resp = await fetch(`/dashboard/options?${params.toString()}`); -->
<!-- return await resp.json(); -->
<!-- } -->
<!-- -->
<!-- function populateDropdown(id, options) { -->
<!-- const select = document.getElementById(id) || createMissingDropdown(id); -->
<!-- select.innerHTML = `<option value="">Select...</option>`; -->
<!-- options.forEach(opt => { -->
<!-- select.innerHTML += `<option value="${opt}">${opt}</option>`; -->
<!-- }); -->
<!-- // If this is the last dropdown, it should be visible now -->
<!-- const container = document.getElementById(`container_${id}`); -->
<!-- if(container) container.style.display = "block"; -->
<!-- } -->
<!-- -->
<!-- function createMissingDropdown(id) { -->
<!-- const config = manifest.filters.find(f => f.id === id); -->
<!-- const container = document.createElement("div"); -->
<!-- container.id = `container_${id}`; -->
<!-- container.style.display = "none"; // Hidden until parent is selected -->
<!-- -->
<!-- const label = document.createElement("label"); -->
<!-- label.textContent = config.label + ": "; -->
<!-- -->
<!-- const select = document.createElement("select"); -->
<!-- select.id = id; -->
<!-- -->
<!-- select.addEventListener("change", async (e) => { -->
<!-- currentSelections[id] = e.target.value; -->
<!-- clearDependents(id); -->
<!-- const children = manifest.filters.filter(f => f.depends_on === id); -->
<!-- children.forEach(child => { -->
<!-- const opts = await fetchOptions(child, e.target.value); -->
<!-- populateDropdown(child.id, opts); -->
<!-- }); -->
<!-- updateGrid(); -->
<!-- }); -->
<!-- -->
<!-- container.appendChild(label); -->
<!-- container.appendChild(select); -->
<!-- document.getElementById("filter-area").appendChild(container); -->
<!-- return select; -->
<!-- } -->
<!-- -->
<!-- function clearDependents(id) { -->
<!-- // Recursively find all filters that depend on this ID and reset them -->
<!-- manifest.filters.forEach(f => { -->
<!-- if (f.depends_on === id) { -->
<!-- const select = document.getElementById(f.id); -->
<!-- if (select) select.innerHTML = `<option value="">Select...</option>`; -->
<!-- const container = document.getElementById(`container_${f.id}`); -->
<!-- if (container) container.style.display = "none"; -->
<!-- delete currentSelections[f.id]; -->
<!-- clearDependents(f.id); -->
<!-- } -->
<!-- }); -->
<!-- } -->
<!-- -->
<!-- async function updateGrid() { -->
<!-- const params = new URLSearchParams(currentSelections); -->
<!-- const resp = await fetch(`/dashboard/data?${params.toString()}`); -->
<!-- const data = await resp.json(); -->
<!-- renderLayout(data); -->
<!-- } -->
<!-- -->
<!-- function renderLayout(data) { -->
<!-- const area = document.getElementById("results-area"); -->
<!-- area.innerHTML = ""; -->
<!-- -->
<!-- // Group layout by grid name (defaults to 'main') -->
<!-- const grids = {}; -->
<!-- manifest.layout.forEach(item => { -->
<!-- const gridId = item.grid || 'main'; -->
<!-- if (!grids[gridId]) grids[gridId] = []; -->
<!-- grids[gridId].push(item); -->
<!-- }); -->
<!-- -->
<!-- Object.keys(grids).forEach(gridId => { -->
<!-- const table = document.createElement("table"); -->
<!-- table.className = "dyn-grid"; -->
<!-- -->
<!-- // Find max rows/cols to initialize table structure if needed, -->
<!-- // but we can just build rows dynamically. -->
<!-- const layoutItems = grids[gridId]; -->
<!-- const maxRow = Math.max(...layoutItems.map(i => i.row)); -->
<!-- -->
<!-- for (let r = 0; r <= maxRow; r++) { -->
<!-- const tr = document.createElement("tr"); -->
<!-- const rowItems = layoutItems.filter(i => i.row === r); -->
<!-- -->
<!-- rowItems.forEach(item => { -->
<!-- const td = document.createElement("td"); -->
<!-- td.className = item.class; -->
<!-- td.colSpan = item.colspan || 1; -->
<!-- td.rowSpan = item.rowspan || 1; -->
<!-- -->
<!-- if (item.text) { -->
<!-- td.textContent = item.text; -->
<!-- } else if (item.value) { -->
<!-- // Resolve nested JSON path (e.g., "main_info.joint") -->
<!-- const parts = item.value.split('.'); -->
<!-- const val = parts.reduce((obj, key) => (obj && obj[key] !== undefined) ? obj[key] : '', data); -->
<!-- td.textContent = val; -->
<!-- } -->
<!-- tr.appendChild(td); -->
<!-- }); -->
<!-- table.appendChild(tr); -->
<!-- } -->
<!-- area.appendChild(table); -->
<!-- }); -->
<!-- } -->
<!-- -->
<!-- init(); -->
<!-- }); -->
<!-- </script> -->
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const jointSelect = document.getElementById("joint"); let manifest = {};
const resultsContainer = document.getElementById("results-container"); let currentSelections = {};
function fetchFilteredData() { async function init() {
const joint = jointSelect.value; const resp = await fetch(
const params = new URLSearchParams(); '/dashboard',
if (joint) params.append("joint", joint); {
headers: {
fetch(`/dashboard?${params.toString()}`, { 'Accept': 'application/json'
headers: { "Accept": "application/json" } }
}) });
.then(response => response.json()) manifest = await resp.json();
.then(data => { await renderRootFilters();
renderJointGrid(data);
})
.catch(error => console.error("Error fetching dashboard data:", error));
} }
/** async function renderRootFilters() {
* This function transforms a flat JSON record into the const rootFilters = manifest.filters.filter(f => f.depends_on === null);
* specific grid layout seen in the image. for (const f of rootFilters) {
*/ await createDropdown(f);
function renderJointGrid(records) {
resultsContainer.innerHTML = "";
if (records.length === 0) {
resultsContainer.innerHTML = "<p>No records found. Please select a joint.</p>";
return;
} }
//rootFilters.forEach(f => await createDropdown(f));
}
records.forEach(record => { async function createDropdown(config) {
// Create a wrapper for the two tables (Info and Ligaments) const container = document.createElement("div");
const wrapper = document.createElement("div"); container.id = `container_${config.id}`;
wrapper.style.display = "flex";
wrapper.style.gap = "20px";
// --- TABLE 1: Joint Information --- const label = document.createElement("label");
const infoTable = document.createElement("table"); label.textContent = config.label + ": ";
infoTable.className = "joint-grid"; label.style.fontWeight = "bold";
// Use a template literal to define the exact structure of the image const select = document.createElement("select");
// Note: We map JSON keys (record["Joint"]) to specific cells select.id = config.id;
infoTable.innerHTML = ` select.innerHTML = `<option value="">Select...</option>`;
<thead>
<tr><th colspan="4" class="bg-dark-blue">Joint Information</th></tr>
</thead>
<tbody>
<tr>
<td class="bg-light-blue">Anatomical Name</td>
<td class="bg-pale-yellow">${record["Joint"] || ""}</td>
<td class="bg-light-blue">Common Name</td>
<td class="bg-pale-yellow">${record["Common Name"] || ""}</td>
</tr>
<tr>
<td class="bg-light-blue">Diarthrodial Joint Type</td>
<td class="bg-pale-yellow">${record["Diarthrodial joint"] || ""}</td>
<td class="bg-light-blue">Planes of Freedom</td>
<td class="bg-pale-yellow">${record["# of Plains"] || ""}</td>
</tr>
<tr>
<td class="bg-pale-green">${record["Alternate Name"] || ""}</td>
<td class="bg-pale-green">Ball & Socket</td>
<td class="bg-pale-green">${record["# of Plains"] || ""}</td>
<td class="bg-pale-green">${record["# of Plains"] || ""}</td>
</tr>
<tr>
<td class="bg-light-blue">Arthrokinematic Rule</td>
<td class="bg-pale-orange">Osteokinematic</td>
<td class="bg-light-blue">Open Chain Roll/Glide</td>
<td class="bg-pale-orange">Anterior/Posterior</td>
</tr>
<tr>
<td class="bg-light-blue">Open Pack</td>
<td colspan="2" class="bg-pale-green">${record["Open Pack"] || "N/A"}</td>
<td class="bg-light-blue">Close Pack</td>
</tr>
<tr>
<td colspan="4" class="bg-pale-green">${record["Close Pack"] || "N/A"}</td>
</tr>
</tbody>
`;
// --- TABLE 2: Ligaments --- // Marked callback as async
const ligTable = document.createElement("table"); select.addEventListener("change", async (e) => {
ligTable.className = "joint-grid"; const val = e.target.value;
ligTable.style.width = "250px"; currentSelections[config.id] = val;
// Handle ligaments (assuming they might be a comma-separated string or array) // 1. Clear dependent filters and data
let ligsHtml = ""; clearDependents(config.id);
if (record["Ligaments"]) {
const ligArray = Array.isArray(record["Ligaments"]) ? record["Ligaments"] : record["Ligaments"].split(","); // 2. Load options for children (Fixed with for...of)
ligsHtml = ligArray.map(l => `<div class="text-red">${l.trim()}</div>`).join(""); const children = manifest.filters.filter(f => f.depends_on === config.id);
} else { for (const child of children) {
ligsHtml = "No ligaments listed"; const options = await fetchOptions(child, val);
populateDropdown(child.id, options);
} }
ligTable.innerHTML = ` // 3. Update data grid
<tr> updateGrid();
<td class="bg-dark-blue" style="width: 100px;">Ligaments</td> });
<td class="bg-pale-orange" style="text-align: left;">
<strong>Vertebral Articulating Processes</strong><br>
${ligsHtml}
</td>
</tr>
`;
wrapper.appendChild(infoTable); container.appendChild(label);
wrapper.appendChild(ligTable); container.appendChild(select);
resultsContainer.appendChild(wrapper); document.getElementById("filter-area").appendChild(container);
// Special handling for parent items:
if (config.depends_on === null) {
var options = await fetchOptions(config);
populateDropdown(select.id, options)
}
}
async function fetchOptions(config, value) {
var params
if (config.filter_col == undefined) {
params = new URLSearchParams({
table: config.table,
pluck_column: config.pluck_column,
});
} else {
params = new URLSearchParams({
table: config.table,
pluck_column: config.pluck_column,
filter_col: config.filter_column,
value: value
});
}
const resp = await fetch(`/dashboard/options?${params.toString()}`);
return await resp.json();
}
function populateDropdown(id, options) {
const select = document.getElementById(id) || createMissingDropdown(id);
select.innerHTML = `<option value="">Select...</option>`;
options.forEach(opt => {
select.innerHTML += `<option value="${opt}">${opt}</option>`;
});
// If this is the last dropdown, it should be visible now
const container = document.getElementById(`container_${id}`);
if(container) container.style.display = "block";
}
function createMissingDropdown(id) {
const config = manifest.filters.find(f => f.id === id);
const container = document.createElement("div");
container.id = `container_${id}`;
container.style.display = "none"; // Hidden until parent is selected
const label = document.createElement("label");
label.textContent = config.label + ": ";
const select = document.createElement("select");
select.id = id;
// Marked callback as async
select.addEventListener("change", async (e) => {
currentSelections[id] = e.target.value;
clearDependents(id);
const children = manifest.filters.filter(f => f.depends_on === id);
// Fixed with for...of loop
for (const child of children) {
const opts = await fetchOptions(child, e.target.value);
populateDropdown(child.id, opts);
}
updateGrid();
});
container.appendChild(label);
container.appendChild(select);
document.getElementById("filter-area").appendChild(container);
return select;
}
function clearDependents(id) {
// Recursively find all filters that depend on this ID and reset them
manifest.filters.forEach(f => {
if (f.depends_on === id) {
const select = document.getElementById(f.id);
if (select) select.innerHTML = `<option value="">Select...</option>`;
const container = document.getElementById(`container_${f.id}`);
if (container) container.style.display = "none";
delete currentSelections[f.id];
clearDependents(f.id);
}
}); });
} }
jointSelect.addEventListener("change", fetchFilteredData); async function updateGrid() {
fetchFilteredData(); const params = new URLSearchParams(currentSelections);
const resp = await fetch(`/dashboard/data?${params.toString()}`);
const data = await resp.json();
renderLayout(data);
}
function renderLayout(data) {
const area = document.getElementById("results-area");
area.innerHTML = "";
// Group layout by grid name (defaults to 'main')
const grids = {};
manifest.layout.forEach(item => {
const gridId = item.grid || 'main';
if (!grids[gridId]) grids[gridId] = [];
grids[gridId].push(item);
});
Object.keys(grids).forEach(gridId => {
const table = document.createElement("table");
table.className = "dyn-grid";
const layoutItems = grids[gridId];
const maxRow = Math.max(...layoutItems.map(i => i.row));
for (let r = 0; r <= maxRow; r++) {
const tr = document.createElement("tr");
const rowItems = layoutItems.filter(i => i.row === r);
rowItems.forEach(item => {
const td = document.createElement("td");
td.className = item.class;
td.colSpan = item.colspan || 1;
td.rowSpan = item.rowspan || 1;
if (item.text) {
td.textContent = item.text;
} else if (item.value) {
// Resolve nested JSON path (e.g., "main_info.joint")
const parts = item.value.split('.');
const val = parts.reduce((obj, key) => (obj && obj[key] !== undefined) ? obj[key] : '', data);
td.textContent = val;
}
tr.appendChild(td);
});
table.appendChild(tr);
}
area.appendChild(table);
});
}
init();
}); });
</script> </script>
+40
View File
@@ -0,0 +1,40 @@
# Filter Hierarchy: Defines the dropdowns and their dependencies
filters:
- id: "joint"
label: "Select Joint"
table: "Joint"
pluck_column: "Joint"
depends_on: null # Root level
# - id: "joint_info"
# label: "joint_info"
# table: "joint_info" # A separate table in SQLite
# pluck_column: "Common Joint Name"
# depends_on: "joint" # Only appears after 'joint' is selected
# filter_column: "Common Joint Name" # Filter SubJoint where joint_id == selected joint value
# Data Mapping: Defines which tables to hit for the final grid
data_sources:
- table: "Joint"
key: "main_info" # Nested JSON key
filter_column: "joint"
# - table: "Ligament"
# key: "lig_info"
# filter_column: "joint_id"
# Layout: Defines the visual grid
# colors: dark_blue, light_blue, pale_yellow, pale_green, pale_orange
layout:
- { row: 0, col: 0, colspan: 4, rowspan: 1, text: "Joint Information", class: "bg-dark-blue" }
- { row: 1, col: 0, colspan: 1, rowspan: 1, text: "Anatomical Name", class: "bg-light-blue" }
- { row: 1, col: 1, colspan: 1, rowspan: 1, value: "main_info.joint", class: "bg-pale-yellow" }
- { row: 1, col: 2, colspan: 1, rowspan: 1, text: "Common Name", class: "bg-light-blue" }
- { row: 1, col: 3, colspan: 1, rowspan: 1, value: "main_info.common_name", class: "bg-pale-yellow" }
- { row: 2, col: 0, colspan: 1, rowspan: 1, text: "Joint Type", class: "bg-light-blue" }
- { row: 2, col: 1, colspan: 1, rowspan: 1, value: "main_info.joint_type", class: "bg-pale-yellow" }
- { row: 2, col: 2, colspan: 1, rowspan: 1, text: "Planes", class: "bg-light-blue" }
- { row: 2, col: 3, colspan: 1, rowspan: 1, value: "main_info.planes", class: "bg-pale-yellow" }
- { row: 3, col: 0, colspan: 4, rowspan: 1, value: "main_info.notes", class: "bg-pale-green" }
# Ligaments Table (separate grid)
#- { grid: "ligaments", row: 0, col: 0, colspan: 1, rowspan: 1, text: "Ligaments", class: "bg-dark-blue" }
#- { grid: "ligaments", row: 0, col: 1, colspan: 1, rowspan: 1, value: ".list", class: "bg-pale-orange" }
+2
View File
@@ -13,4 +13,6 @@ Rails.application.routes.draw do
# root "posts#index" # root "posts#index"
root "dashboard#index" root "dashboard#index"
get "dashboard", to: "dashboard#index" get "dashboard", to: "dashboard#index"
get "dashboard/options", to: "dashboard#options"
get "dashboard/data", to: "dashboard#data"
end end