Files
WYGIWYH/app/templates/insights/fragments/overview/index.html
T
Herculino Trotta 82211567d0 feat(insights): replace fixed overviews with a single arrangeable overview
Categories, Tags and Entities Overview are merged into one Overview page.
Levels are chips that can be dragged (or moved with the arrow keys) to set
the hierarchy and clicked to switch off, so any ordering of the three is
reachable from the same page.

Only the results reload on change; the controls stay in place. The chip
order is kept in the session and defaults to categories by tags, matching
the old Categories Overview.
2026-09-12 18:02:24 -03:00

179 lines
8.4 KiB
HTML

{% load i18n %}
{% comment %}
Overview insight: the level chain is arranged here instead of being baked
into the view. Only #overview-results reloads when something changes, so the
controls never get pulled out from under the pointer.
{% endcomment %}
<div>
<div class="h-full text-center mb-4">
<div class="tabs tabs-box mx-auto w-fit" role="group" id="view-type" _="on change trigger updated">
<label class="tab">
<input type="radio"
name="view_type"
id="table-view"
autocomplete="off"
value="table"
aria-label="{% trans 'Table' %}"
{% if view_type == "table" %}checked{% endif %}>
<i class="fa-solid fa-table fa-fw me-2"></i>
{% trans 'Table' %}
</label>
<label class="tab">
<input type="radio"
name="view_type"
id="bars-view"
autocomplete="off"
value="bars"
aria-label="{% trans 'Bars' %}"
{% if view_type == "bars" %}checked{% endif %}>
<i class="fa-solid fa-chart-bar fa-fw me-2"></i>
{% trans 'Bars' %}
</label>
</div>
</div>
<div class="my-3 flex flex-col gap-3 md:flex-row md:items-center justify-between">
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2" id="level-chain"
x-data
x-sort.ghost="levelChainSorted()"
x-sort:config="{ ghostClass: 'opacity-40' }"
data-last-level-title="{% trans 'At least one level has to stay on' %}">
{% for chip in chips %}
{# The hidden inputs sit outside .join: it rounds its end from :last-child, which they would otherwise be #}
<div class="level-chip inline-flex{% if not chip.enabled %} level-off{% endif %}" data-level="{{ chip.key }}">
{# --radius-field is what .join reads for its outer corners, so overriding it here turns the chip into a pill #}
<div class="join bg-base-200 rounded-full [--radius-field:9999px]">
<button class="join-item btn btn-sm btn-ghost ps-3 pe-2 cursor-grab active:cursor-grabbing" type="button"
x-sort:handle data-handle
aria-label="{% trans 'Drag to reorder, or use the left and right arrow keys' %}"><i
class="fa-solid fa-grip-vertical fa-fw"></i></button>
<button class="join-item btn btn-sm pe-4 gap-2" type="button" data-toggle
aria-label="{{ chip.meta.label }}">
<span class="level-position badge badge-xs badge-neutral">{{ chip.position }}</span>
<span class="level-label"><i class="{{ chip.meta.icon }} fa-fw me-1"></i>{{ chip.meta.label }}</span>
</button>
</div>
<input type="hidden" name="chain" value="{{ chip.key }}">
<input type="hidden" name="level" value="{{ chip.key }}" {% if not chip.enabled %}disabled{% endif %}>
</div>
{% endfor %}
</div>
<c-ui.help-icon
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
</div>
<div class="join" role="group" id="showing" _="on change trigger updated">
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-projected"
autocomplete="off" aria-label="{% trans 'Projected' %}"
value="projected" {% if showing == 'projected' %}checked{% endif %}>
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-current"
autocomplete="off" value="current" aria-label="{% trans 'Current' %}"
{% if showing == 'current' %}checked{% endif %}>
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-final"
autocomplete="off" value="final" aria-label="{% trans 'Final total' %}"
{% if showing == 'final' %}checked{% endif %}>
</div>
</div>
<div id="overview-results" class="show-loading"
hx-get="{% url 'insights_overview_results' %}"
hx-trigger="load, updated from:window"
hx-include="#picker-form, #picker-type, #view-type, #level-chain, #showing"
hx-sync="this:replace"></div>
<script>
// The chips are the source of truth for the chain: their DOM order is the
// hierarchy and their hidden inputs are what gets submitted. Dragging is
// therefore instant, and the server only reads the result back.
function setupLevelChain(root) {
function refresh() {
var chips = Array.prototype.slice.call(root.querySelectorAll('.level-chip'));
var enabled = chips.filter(function (chip) {
return !chip.classList.contains('level-off');
});
chips.forEach(function (chip) {
var off = chip.classList.contains('level-off');
var toggle = chip.querySelector('[data-toggle]');
var label = chip.querySelector('.level-label');
var position = chip.querySelector('.level-position');
chip.querySelector('input[name="level"]').disabled = off;
// An off level reads as daisyUI's disabled button, but stays
// clickable. Using .btn-disabled would set pointer-events:none
// and strand it in the off state.
toggle.classList.toggle('btn-primary', !off);
toggle.classList.toggle('btn-ghost', off);
label.classList.toggle('line-through', off);
label.classList.toggle('opacity-50', off);
chip.classList.toggle('opacity-80', off);
position.textContent = off ? '' : (enabled.indexOf(chip) + 1);
position.hidden = off;
// Same reasoning for the last one standing: mark it refused
// rather than disabled, so it still looks switched on.
var last = !off && enabled.length === 1;
toggle.setAttribute('aria-disabled', last ? 'true' : 'false');
toggle.classList.toggle('cursor-not-allowed', last);
toggle.title = last ? (root.dataset.lastLevelTitle || '') : '';
});
}
function commit() {
refresh();
window.dispatchEvent(new CustomEvent('updated'));
}
function move(chip, step) {
var sibling = step < 0 ? chip.previousElementSibling : chip.nextElementSibling;
if (!sibling) return false;
if (step < 0) {
root.insertBefore(chip, sibling);
} else {
root.insertBefore(sibling, chip);
}
return true;
}
root.addEventListener('click', function (event) {
var toggle = event.target.closest('[data-toggle]');
if (!toggle || !root.contains(toggle)) return;
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.closest('.level-chip').classList.toggle('level-off');
commit();
});
// Dragging is mouse and touch only, so the handle also reorders with
// the arrow keys rather than leaving the chain keyboard inaccessible.
root.addEventListener('keydown', function (event) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
var handle = event.target.closest('[data-handle]');
if (!handle || !root.contains(handle)) return;
if (!move(handle.closest('.level-chip'), event.key === 'ArrowLeft' ? -1 : 1)) return;
event.preventDefault();
handle.focus();
commit();
});
// Reached from the x-sort handler once Sortable has moved the chip.
root.levelChainCommit = commit;
refresh();
}
function levelChainSorted() {
var root = document.getElementById('level-chain');
if (root && root.levelChainCommit) root.levelChainCommit();
}
// Wired up here rather than with a hyperscript init, so it does not
// depend on which of the two runs first after an htmx swap.
setupLevelChain(document.getElementById('level-chain'));
</script>
</div>