feat: Enhance linked cases overview with detailed case information and navigation, and add linked case card partial

This commit is contained in:
Ross
2026-04-27 22:41:16 +01:00
parent 02ad8c61e5
commit 29165811d1
4 changed files with 341 additions and 47 deletions
+102 -14
View File
@@ -493,6 +493,60 @@
Move selected series
</button>
</form>
<script>
(function () {
if (window.__moveSeriesCasePickerInit) return;
window.__moveSeriesCasePickerInit = true;
function syncMoveSubmit() {
const form = document.getElementById('move-selected-series-form');
const hiddenInput = form ? form.querySelector("[name='destination_case']") : null;
const submitBtn = document.getElementById('move-selected-series-submit');
if (!hiddenInput || !submitBtn) return;
submitBtn.disabled = !hiddenInput.value;
}
function selectMoveDestination(casePk, caseTitle) {
const form = document.getElementById('move-selected-series-form');
const hiddenInput = form ? form.querySelector("[name='destination_case']") : null;
const label = document.getElementById('selected-move-case');
const results = document.getElementById('move-series-case-search-results');
if (!hiddenInput || !casePk) return;
hiddenInput.value = casePk;
if (label) {
label.innerHTML = 'Selected destination: <strong>' + caseTitle + ' (' + casePk + ')</strong>';
label.classList.remove('text-muted');
}
if (results) {
results.querySelectorAll('.list-group-item[data-case-pk]').forEach(function (item) {
item.classList.toggle('active', String(item.getAttribute('data-case-pk')) === String(casePk));
});
}
syncMoveSubmit();
}
document.body.addEventListener('click', function (e) {
const item = e.target.closest('#move-series-case-search-results .list-group-item[data-case-pk]');
if (!item) return;
if (e.target.closest('a,button,form,input')) return;
const casePk = item.getAttribute('data-case-pk');
const titleEl = item.querySelector('h6');
const caseTitle = titleEl ? titleEl.textContent.trim() : ('Case #' + casePk);
selectMoveDestination(casePk, caseTitle);
});
document.body.addEventListener('case:selected', function (e) {
const detail = e.detail || {};
if (!detail.casePk) return;
const caseTitle = detail.caseTitle || ('Case #' + detail.casePk);
selectMoveDestination(detail.casePk, caseTitle);
});
document.addEventListener('DOMContentLoaded', syncMoveSubmit);
syncMoveSubmit();
})();
</script>
{% else %}
<div class="text-muted small mt-2">Case move options are unavailable in this view.</div>
{% endif %}
@@ -881,12 +935,15 @@
});
// On submit, update the hidden inputs to match the new order
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
const lis = document.querynelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
const reorderSeriesForm = document.getElementById("reorder-series-form");
if (reorderSeriesForm) {
reorderSeriesForm.addEventListener("submit", function(e) {
const lis = document.querySelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
});
});
});
}
// Drag and drop reordering for series list
const reorderList = document.getElementById("series-reorder-list");
@@ -1035,6 +1092,8 @@
if (moveSeriesForm) {
const hiddenInput = moveSeriesForm.querySelector("[name='destination_case']");
const moveSubmitButton = document.getElementById("move-selected-series-submit");
const moveSearchResults = document.getElementById("move-series-case-search-results");
const selectionLabel = document.getElementById("selected-move-case");
function syncMoveSeriesSubmitState() {
if (!moveSubmitButton || !hiddenInput) {
@@ -1043,22 +1102,51 @@
moveSubmitButton.disabled = !hiddenInput.value;
}
function setSelectedDestination(casePk, caseTitle) {
if (!hiddenInput || !casePk) {
return;
}
hiddenInput.value = casePk;
if (selectionLabel) {
selectionLabel.innerHTML = "Selected destination: <strong>" + caseTitle + " (" + casePk + ")</strong>";
selectionLabel.classList.remove("text-muted");
}
if (moveSearchResults) {
moveSearchResults.querySelectorAll(".list-group-item").forEach((item) => {
item.classList.toggle("active", String(item.getAttribute("data-case-pk")) === String(casePk));
});
}
syncMoveSeriesSubmitState();
}
if (moveSearchResults) {
moveSearchResults.addEventListener("click", function(event) {
const item = event.target.closest(".list-group-item[data-case-pk]");
if (!item || !moveSearchResults.contains(item)) {
return;
}
if (event.target.closest("a,button,form,input")) {
return;
}
const casePk = item.getAttribute("data-case-pk");
const titleEl = item.querySelector("h6");
const caseTitle = titleEl ? titleEl.textContent.trim() : ("Case #" + casePk);
setSelectedDestination(casePk, caseTitle);
});
}
moveSeriesForm.addEventListener("case:selected", function(event) {
const selectionLabel = document.getElementById("selected-move-case");
if (!hiddenInput || !event.detail) {
return;
}
const casePk = event.detail.casePk;
const caseTitle = event.detail.caseTitle || ("Case #" + casePk);
hiddenInput.value = casePk;
if (selectionLabel) {
selectionLabel.innerHTML = "Selected destination: <strong>" + caseTitle + " (" + casePk + ")</strong>";
selectionLabel.classList.remove("text-muted");
}
syncMoveSeriesSubmitState();
setSelectedDestination(casePk, caseTitle);
});
syncMoveSeriesSubmitState();
+113 -32
View File
@@ -1,38 +1,119 @@
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
{% extends "base.html" %}
{% extends "atlas/base.html" %}
{% block content %}
<h1>Linked Cases Overview</h1>
<h2>Current Case: {{ current_case.title }}</h2>
<div class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="h2 mb-1">Linked Cases Overview</h1>
<p class="text-muted mb-0">Review the full case chain around this study, with quick access to related cases and their linked series.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary" href="{{ current_case.get_absolute_url }}">Open current case</a>
{% if direct_previous %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_previous.pk %}">Previous in chain</a>
{% endif %}
{% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% endif %}
</div>
</div>
<h3>Previous Cases</h3>
{% if previous_cases %}
<ul>
{% for case in previous_cases %}
<li>
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="badge bg-primary">Current case</span>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ current_index }} of {{ chain_total }}</span>
{% if previous_cases %}
<span class="badge text-bg-light border">{{ previous_cases|length }} previous</span>
{% endif %}
{% if next_cases %}
<span class="badge text-bg-light border">{{ next_cases|length }} follow-up</span>
{% endif %}
</div>
<h2 class="h4 mb-1">{{ current_case.title|default:"Untitled case" }}</h2>
<div class="small text-muted mb-3">Case {{ current_case.pk }}</div>
{% if current_case.description %}
<p class="mb-3">{{ current_case.description|striptags|truncatechars:280 }}</p>
{% elif current_case.history %}
<p class="mb-3">{{ current_case.history|striptags|truncatechars:280 }}</p>
{% endif %}
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No previous cases.</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 small">
{% if current_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if current_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if current_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
{% if current_case.diagnostic_certainty %}
<span class="badge text-bg-light border">{{ current_case.get_diagnostic_certainty_display }}</span>
{% endif %}
<span class="badge text-bg-light border">{{ current_case.get_ordered_series|length }} series</span>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<h2 class="h5 mb-3">Chain Summary</h2>
<div class="list-group list-group-flush small">
{% for linked_case in chain_cases %}
<a href="{% url 'atlas:linked_cases_overview' linked_case.pk %}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}active{% endif %}">
<span class="text-truncate pe-3">{{ forloop.counter }}. {{ linked_case.title|default:"Untitled case" }}</span>
<span class="badge {% if linked_case.pk == current_case.pk %}text-bg-light{% else %}text-bg-secondary{% endif %}">{{ linked_case.pk }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<h3>Next Cases</h3>
{% if next_cases %}
<ul>
{% for case in next_cases %}
<li>
<a href="{{ next_case.get_absolute_url }}">{{ case.title }}</a>
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No next cases.</p>
{% endif %}
<div class="row g-4">
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Previous Cases</h2>
<span class="badge text-bg-light border">{{ previous_cases|length }}</span>
</div>
{% if previous_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in previous_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No previous linked cases.</div>
</div>
{% endif %}
</div>
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Next Cases</h2>
<span class="badge text-bg-light border">{{ next_cases|length }}</span>
</div>
{% if next_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in next_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No follow-up linked cases.</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,116 @@
<article class="card h-100 border-secondary-subtle shadow-sm">
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge bg-secondary">Case {{ linked_case.pk }}</span>
{% if is_current %}
<span class="badge bg-primary">Current</span>
{% endif %}
{% if linked_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if linked_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if linked_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
</div>
<h3 class="h5 mb-1">
<a class="link-body-emphasis text-decoration-none" href="{{ linked_case.get_absolute_url }}">
{{ linked_case.title|default:"Untitled case" }}
</a>
</h3>
{% if relation_label %}
<div class="small text-muted">{{ relation_label }}</div>
{% endif %}
</div>
<div class="text-sm-end small text-muted">
{% if linked_case.published_date %}
<div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div>
{% else %}
<div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div>
{% endif %}
{% if linked_case.diagnostic_certainty %}
<div>Certainty: {{ linked_case.get_diagnostic_certainty_display }}</div>
{% endif %}
</div>
</div>
{% if linked_case.description %}
<p class="mb-0 text-body-secondary">{{ linked_case.description|striptags|truncatechars:220 }}</p>
{% elif linked_case.history %}
<p class="mb-0 text-body-secondary">{{ linked_case.history|striptags|truncatechars:220 }}</p>
{% endif %}
<div class="d-flex flex-column gap-2 small">
{% if linked_case.subspecialty.all %}
<div>
<span class="text-muted me-2">Subspecialty</span>
{% for item in linked_case.subspecialty.all %}
<span class="badge text-bg-light border">{{ item }}</span>
{% empty %}{% endfor %}
</div>
{% endif %}
{% if linked_case.condition.all %}
<div>
<span class="text-muted me-2">Condition</span>
{% for item in linked_case.condition.all|slice:":4" %}
<span class="badge text-bg-light border">{{ item }}</span>
{% endfor %}
{% if linked_case.condition.count > 4 %}
<span class="text-muted">+{{ linked_case.condition.count|add:-4 }} more</span>
{% endif %}
</div>
{% endif %}
{% if linked_case.presentation.all %}
<div>
<span class="text-muted me-2">Presentation</span>
{% for item in linked_case.presentation.all|slice:":4" %}
<span class="badge text-bg-light border">{{ item }}</span>
{% endfor %}
{% if linked_case.presentation.count > 4 %}
<span class="text-muted">+{{ linked_case.presentation.count|add:-4 }} more</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<strong class="small">Series</strong>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ linked_case.get_ordered_series|length }} linked</span>
</div>
{% with ordered_series=linked_case.get_ordered_series %}
{% if ordered_series %}
<div class="d-flex flex-column gap-2 small">
{% for series in ordered_series|slice:":4" %}
<div class="d-flex justify-content-between align-items-start gap-2 border rounded px-2 py-1 bg-body">
<div>
<a class="text-decoration-none" href="{{ series.get_absolute_url }}">
{{ series.description|default:series.examination }}
</a>
<div class="text-muted">{{ series.modality }}{% if series.examination %} · {{ series.examination }}{% endif %}{% if series.plane %} · {{ series.plane }}{% endif %}</div>
</div>
<span class="badge text-bg-light border">#{{ forloop.counter }}</span>
</div>
{% endfor %}
</div>
{% if ordered_series|length > 4 %}
<div class="small text-muted mt-2">Showing 4 of {{ ordered_series|length }} linked series.</div>
{% endif %}
{% else %}
<div class="small text-muted">No series linked to this case.</div>
{% endif %}
{% endwith %}
</div>
<div class="d-flex flex-wrap gap-2 mt-auto">
<a class="btn btn-sm btn-primary" href="{{ linked_case.get_absolute_url }}">Open case</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' linked_case.pk %}">Re-centre overview</a>
</div>
</div>
</article>
+10 -1
View File
@@ -7289,7 +7289,7 @@ def reorder_series(request, case_pk):
def linked_cases_overview(request, case_id):
"""
View to display an overview of linked cases via the previous_case field.
Display the linked-case chain around a case, including prior and follow-up cases.
"""
# Get the current case
case = get_object_or_404(Case, pk=case_id)
@@ -7314,6 +7314,10 @@ def linked_cases_overview(request, case_id):
except ObjectDoesNotExist:
pass
chain_cases = previous_cases + [case] + next_cases
direct_previous = previous_cases[-1] if previous_cases else None
direct_next = next_cases[0] if next_cases else None
# Render the template with the linked cases
return render(
request,
@@ -7322,6 +7326,11 @@ def linked_cases_overview(request, case_id):
"current_case": case,
"previous_cases": previous_cases,
"next_cases": next_cases,
"chain_cases": chain_cases,
"chain_total": len(chain_cases),
"current_index": len(previous_cases) + 1,
"direct_previous": direct_previous,
"direct_next": direct_next,
},
)