Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH**

---

## [1.39.6] — 2026-09-28

### Added
- **Grouped inventory view.** The inventory report has a new selector next to the
search box: "Each spool" (unchanged, the default) or "Group by brand, material and
color". Grouped view shows one tile per brand/material/family/color with a count badge
(e.g. 5×), the combined remaining percentage and weight (unweighed spools count as
full), and the header shows groups and spools totals. Click a tile to list its spools
with links, remaining weight and location. Search works in both views.

### Fixed
- **Black spools no longer vanish on the dark theme.** Every spool donut now has a thin
outline on its outer and inner edges (black in the light theme, white in the dark one).

## [1.39.5] — 2026-09-28

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.39.5
1.39.6
35 changes: 34 additions & 1 deletion routes/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,40 @@ def report_stats():
def report_inventory():
q = request.args.get("q", "").strip()
items = db.list_inventory(q or None)
return render_template("reports/inventory.html", items=items, q=q)
if request.args.get("view") == "grouped":
groups = _group_inventory(items)
return render_template("reports/inventory.html", groups=groups, items=items,
q=q, grouped=True)
return render_template("reports/inventory.html", items=items, q=q, grouped=False)


def _group_inventory(items):
"""Agrupa os spools ativos por (marca, material, família, cor, translúcido).

Regra do app: rolo não pesado conta como CHEIO (= nominal). Mantém a ordem de
`list_inventory` (material, marca, família). Devolve dicts prontos p/ o template."""
groups = {}
for s in items:
key = (s["brand"], s["material"], s["family"], s["color_hex"], bool(s["translucent"]))
g = groups.get(key)
if g is None:
g = groups[key] = {
"brand": s["brand"], "material": s["material"], "family": s["family"],
"color_hex": s["color_hex"], "translucent": bool(s["translucent"]),
"spools": [], "remaining_g": 0.0, "nominal_g": 0.0,
}
nominal = s["nominal_weight_g"] or 0
net = s["current_net_g"]
g["remaining_g"] += net if net is not None else nominal
g["nominal_g"] += nominal
g["spools"].append(s)
out = list(groups.values())
for g in out:
g["count"] = len(g["spools"])
g["pct"] = (min(round(g["remaining_g"] / g["nominal_g"] * 100, 1), 100)
if g["nominal_g"] > 0 else 100)
g["locations"] = " ".join(s["location"] or "" for s in g["spools"])
return out


@app.route("/reports/by-material")
Expand Down
6 changes: 6 additions & 0 deletions static/spool.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
--sc-surface/--sc-border ficam próximos demais (contraste ~1.3:1, o quadrado
quase some) — usamos --sc-text-muted/--sc-bg de propósito: mesmo par de
tokens já existente, mas ~6.5:1 de contraste. */
--sc-donut-outline: #fff;
--sc-checker-a: var(--sc-text-muted);
--sc-checker-b: var(--sc-bg);

Expand All @@ -42,6 +43,7 @@

/* ── Design tokens — light ──────────────────────────────────────────────── */
[data-bs-theme="light"] {
--sc-donut-outline: #000;
--sc-bg: #F1F5F9;
--sc-surface: #FFFFFF;
--sc-surface-2: #F8FAFC;
Expand Down Expand Up @@ -419,6 +421,8 @@ h1, h2, h3, h4, h5, h6 { letter-spacing: -0.02em; }

/* ── Donuts ─────────────────────────────────────────────────────────────── */
.donut-track { stroke: var(--sc-border); }
/* Contorno fino nas bordas externa/interna do anel (ver templates/_swatch.html) */
.donut-outline { stroke: var(--sc-donut-outline); }

/* Filamento translúcido/transparente: a cor entra em opacidade parcial por
cima do xadrez global (#sc-checker, definido em base.html), em vez do
Expand Down Expand Up @@ -492,6 +496,8 @@ h1, h2, h3, h4, h5, h6 { letter-spacing: -0.02em; }
}
.sc-inv-tile:hover { background: var(--sc-accent-glow); transform: translateY(-1px); }
.sc-inv-name { max-width: 80px; margin: 4px auto 0; font-size: .72rem; line-height: 1.1; }
.sc-inv-count { font-size: .68rem; margin: 3px; background: var(--sc-accent) !important; color: #000; }
.sc-inv-total { font-weight: 600; }
.sc-inv-sub { max-width: 80px; margin: 0 auto; font-size: .66rem; line-height: 1.15; }

/* ── Brand logo (inline SVG, inherits theme color via currentColor) ─────── */
Expand Down
6 changes: 5 additions & 1 deletion templates/_swatch.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
parcial, em vez de uma cor sólida — um "Transparent" (#FFFFFF) some de
Branco e ganha um padrão de transparência, com o tom (se houver) ainda
visível por cima. Nenhum recurso externo; funciona nos dois temas via
tokens de `static/spool.css` (--sc-surface/--sc-border). #}
tokens de `static/spool.css` (--sc-surface/--sc-border).
Contorno fino nas bordas do anel (`.donut-outline`, preto no claro/branco no
escuro) para o rolo preto não sumir no tema escuro. #}

{% macro swatch(hex, translucent=false, size=12) %}
<span class="sc-swatch{% if translucent %} sc-swatch-translucent{% endif %} d-inline-block rounded me-1"
Expand All @@ -30,6 +32,8 @@
stroke-dasharray="{{ pct }} {{ 100 - pct }}"
transform="rotate(-90 25 25)"
class="donut-arc{% if translucent %} donut-arc-translucent{% endif %}"/>
<circle cx="25" cy="25" r="23.42" fill="none" class="donut-outline" stroke-width="0.8"/>
<circle cx="25" cy="25" r="8.42" fill="none" class="donut-outline" stroke-width="0.8"/>
</svg>
</span>
{% endmacro %}
97 changes: 90 additions & 7 deletions templates/reports/inventory.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,52 @@
{% from "_swatch.html" import donut %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0 fw-bold">{{ _('Inventário') }} <span class="text-muted fs-6">({{ items | length }})</span></h4>
<h4 class="mb-0 fw-bold">{{ _('Inventário') }}
<span class="text-muted fs-6">({% if grouped %}{{ _('{g} grupos · {n} rolos').format(g=groups | length, n=items | length) }}{% else %}{{ items | length }}{% endif %})</span></h4>
</div>

<input class="form-control form-control-sm mb-3" type="search" id="invFilter"
placeholder="{{ _('Buscar...') }}" autocomplete="off" style="max-width:340px">
<form method="get" class="d-flex flex-wrap gap-2 mb-3" id="invForm">
<input class="form-control form-control-sm" type="search" id="invFilter" name="q" value="{{ q }}"
placeholder="{{ _('Buscar...') }}" autocomplete="off" style="max-width:340px">
<select class="form-select form-select-sm" id="invView" name="view" style="max-width:300px"
aria-label="{{ _('Visualização') }}">
<option value="" {% if not grouped %}selected{% endif %}>{{ _('Cada rolo') }}</option>
<option value="grouped" {% if grouped %}selected{% endif %}>{{ _('Agrupar por marca, material e cor') }}</option>
</select>
</form>

<div class="d-flex flex-wrap gap-2" id="invGrid">
{% if grouped %}
{% for g in groups %}
{% set fill = g.color_hex if g.color_hex else '#6c757d' %}
{% if g.remaining_g >= 1000 %}
{% set rem_txt = ('%.1f'|format(g.remaining_g / 1000)) ~ ' kg' %}
{% else %}
{% set rem_txt = (g.remaining_g|int|string) ~ ' g' %}
{% endif %}
<div class="sc-inv-tile sc-inv-group text-center position-relative" role="button" tabindex="0"
data-name="{{ g.brand }} {{ g.material }} {{ g.family }} {{ g.locations }}"
data-brand="{{ g.brand }}" data-material="{{ g.material }}" data-family="{{ g.family }}">
<span class="badge bg-dark position-absolute top-0 end-0 sc-inv-count">{{ g.count }}×</span>
<div class="d-block mx-auto" style="width:46px">
{{ donut(g.pct, fill, translucent=g.translucent, size=46) }}
</div>
<div class="small fw-semibold text-truncate sc-inv-name">{{ g.brand }}</div>
<div class="text-muted text-truncate sc-inv-sub">{{ g.material }}</div>
<div class="text-muted text-truncate sc-inv-sub">{{ g.family }}</div>
<div class="text-muted text-truncate sc-inv-sub sc-inv-total">{{ rem_txt }}</div>
<ul class="d-none sc-inv-members">
{% for s in g.spools %}
<li><a href="{{ url_for('spools_detail', spool_id=s.id) }}">SP-{{ '%04d'|format(s.id) }}</a>
<span class="text-muted ms-2">{% if s.current_net_g is not none %}{{ s.current_net_g|int }} g / {{ s.nominal_weight_g|int }} g{% else %}{{ s.nominal_weight_g|int }} g · {{ _('não pesado') }}{% endif %}</span>
<span class="text-muted ms-2">{{ s.location or '—' }}</span></li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="text-muted py-4">{{ _('Nenhum spool encontrado') }}</div>
{% endfor %}
{% else %}
{% for s in items %}
{% if s.current_net_g is not none and s.nominal_weight_g > 0 %}
{% set pct = [(s.current_net_g / s.nominal_weight_g * 100) | round(1), 100] | min %}
Expand Down Expand Up @@ -44,6 +83,23 @@ <h4 class="mb-0 fw-bold">{{ _('Inventário') }} <span class="text-muted fs-6">({
{% else %}
<div class="text-muted py-4">{{ _('Nenhum spool encontrado') }}</div>
{% endfor %}
{% endif %}
</div>

{# Modal do grupo #}
<div class="modal fade" id="invGroupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="invGroupTitle"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="text-muted small mb-2" id="invGroupSub"></div>
<ul class="list-unstyled mb-0" id="invGroupList"></ul>
</div>
</div>
</div>
</div>

{# Modal de detalhe do rolo #}
Expand Down Expand Up @@ -79,6 +135,8 @@ <h5 class="modal-title">{{ _('Detalhe do Spool') }}</h5>
stroke="none" transform="rotate(-90 25 25)" class="donut-checker"/>
<circle id="invArc" cx="25" cy="25" r="15.9155" fill="none" stroke-width="15"
transform="rotate(-90 25 25)" class="donut-arc"/>
<circle cx="25" cy="25" r="23.42" fill="none" class="donut-outline" stroke-width="0.8"/>
<circle cx="25" cy="25" r="8.42" fill="none" class="donut-outline" stroke-width="0.8"/>
</svg>
<img id="invQr" alt="QR" class="mt-2 bg-white p-1 rounded" style="width:96px;height:96px">
</div>
Expand All @@ -96,12 +154,36 @@ <h5 class="modal-title">{{ _('Detalhe do Spool') }}</h5>
{% block scripts %}
<script nonce="{{ nonce }}">
/* ── Filtro instantâneo dos tiles ──────────────────────────────────────────── */
document.getElementById('invFilter').addEventListener('input', function () {
const q = this.value.toLowerCase();
function applyInvFilter() {
const q = document.getElementById('invFilter').value.toLowerCase();
document.querySelectorAll('#invGrid .sc-inv-tile').forEach(t => {
t.style.display = t.dataset.name.toLowerCase().includes(q) ? '' : 'none';
});
}
document.getElementById('invFilter').addEventListener('input', applyInvFilter);
document.getElementById('invForm').addEventListener('submit', e => e.preventDefault());
/* Troca de visão recarrega no servidor, preservando a busca */
document.getElementById('invView').addEventListener('change', function () {
const u = new URL(window.location.href);
const q = document.getElementById('invFilter').value.trim();
u.search = '';
if (q) u.searchParams.set('q', q);
if (this.value) u.searchParams.set('view', this.value);
window.location.href = u.toString();
});
applyInvFilter();

/* ── Modal do grupo (visão agrupada) ───────────────────────────────────────── */
const invGroupModal = new bootstrap.Modal(document.getElementById('invGroupModal'));
function openGroup(t) {
const d = t.dataset;
document.getElementById('invGroupTitle').textContent = d.brand;
document.getElementById('invGroupSub').textContent = d.material + ' · ' + d.family;
const list = document.getElementById('invGroupList');
list.replaceChildren();
t.querySelectorAll('.sc-inv-members li').forEach(li => list.appendChild(li.cloneNode(true)));
invGroupModal.show();
}

/* ── Modal de detalhe ──────────────────────────────────────────────────────── */
const invModal = new bootstrap.Modal(document.getElementById('invModal'));
Expand Down Expand Up @@ -148,8 +230,9 @@ <h5 class="modal-title">{{ _('Detalhe do Spool') }}</h5>
invModal.show();
}
document.querySelectorAll('#invGrid .sc-inv-tile').forEach(t => {
t.addEventListener('click', () => openInv(t));
t.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openInv(t); } });
const open = t.classList.contains('sc-inv-group') ? openGroup : openInv;
t.addEventListener('click', e => { if (!e.target.closest('a')) open(t); });
t.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(t); } });
});
</script>
{% endblock %}
55 changes: 55 additions & 0 deletions tests/test_inventory_grouped.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Inventário agrupado (?view=grouped) e contorno do donut."""


def _spool(db, hex_="#000000", brand="Marca", material="PLA", family="Basic", nominal=1000.0, loc="A1"):
fid = db.create_filament(brand, material, family, hex_)
return db.create_spool(
filament_id=fid, spool_model_id=None, custom_tare_g=200.0,
nominal_weight_g=nominal, location=loc, purchase_date="",
purchase_price=None, notes="",
)


def _seed(db):
# mesmo filamento: 3 rolos pretos (um sem pesagem) + 1 vermelho de outra marca
fid = db.create_filament("Preta", "PLA", "Basic", "#000000")
ids = [db.create_spool(filament_id=fid, spool_model_id=None, custom_tare_g=200.0,
nominal_weight_g=1000.0, location="Prateleira", purchase_date="",
purchase_price=None, notes="") for _ in range(3)]
db.add_weight_reading(ids[0], gross_weight_g=700, tare_weight_g=200) # 500 g
db.add_weight_reading(ids[1], gross_weight_g=450, tare_weight_g=200) # 250 g
_spool(db, "#ff0000", brand="Outra", loc="Gaveta")
return ids


def test_default_view_one_tile_per_spool(auth_client, db):
_seed(db)
html = auth_client.get("/reports/inventory").get_data(as_text=True)
assert html.count('class="sc-inv-tile text-center"') == 4
assert "sc-inv-group text-center" not in html
assert "3×" not in html


def test_grouped_view(auth_client, db):
_seed(db)
html = auth_client.get("/reports/inventory?view=grouped").get_data(as_text=True)
assert html.count("sc-inv-group text-center") == 2
assert "3×" in html and "1×" in html
# (500 + 250 + 1000) / 3000 = 58.3% ; dasharray "58.3 41.7"
assert 'stroke-dasharray="58.3 41.7"' in html
assert "1750 g" not in html and "1.8 kg" in html


def test_grouped_view_filters_by_q(auth_client, db):
_seed(db)
html = auth_client.get("/reports/inventory?view=grouped&q=Outra").get_data(as_text=True)
assert html.count("sc-inv-group text-center") == 1
assert "1×" in html and "3×" not in html


def test_donut_outline(auth_client, db):
_seed(db)
for url in ("/reports/inventory", "/reports/inventory?view=grouped"):
html = auth_client.get(url).get_data(as_text=True)
assert "donut-outline" in html
assert "--sc-donut-outline" in auth_client.get("/static/spool.css").get_data(as_text=True)
10 changes: 10 additions & 0 deletions translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1310,3 +1310,13 @@ def _(s: str) -> str:
return table.get(s, s)

return _

# Inventário agrupado
_EN["Visualização"] = "View"
_EN["Cada rolo"] = "Each spool"
_EN["Agrupar por marca, material e cor"] = "Group by brand, material and color"
_EN["{g} grupos · {n} rolos"] = "{g} groups · {n} spools"
_ES["Visualização"] = "Vista"
_ES["Cada rolo"] = "Cada bobina"
_ES["Agrupar por marca, material e cor"] = "Agrupar por marca, material y color"
_ES["{g} grupos · {n} rolos"] = "{g} grupos · {n} bobinas"
Loading