diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe85a8..12956df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 713e627..d163067 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.39.5 +1.39.6 diff --git a/routes/reports.py b/routes/reports.py index 7607313..63ddb50 100644 --- a/routes/reports.py +++ b/routes/reports.py @@ -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") diff --git a/static/spool.css b/static/spool.css index 01312fd..72544a6 100644 --- a/static/spool.css +++ b/static/spool.css @@ -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); @@ -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; @@ -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 @@ -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) ─────── */ diff --git a/templates/_swatch.html b/templates/_swatch.html index b7ec93c..417ed58 100644 --- a/templates/_swatch.html +++ b/templates/_swatch.html @@ -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) %} + + {% endmacro %} diff --git a/templates/reports/inventory.html b/templates/reports/inventory.html index a6de4e6..c1f1b79 100644 --- a/templates/reports/inventory.html +++ b/templates/reports/inventory.html @@ -3,13 +3,52 @@ {% from "_swatch.html" import donut %} {% block content %}
-

{{ _('Inventário') }} ({{ items | length }})

+

{{ _('Inventário') }} + ({% if grouped %}{{ _('{g} grupos · {n} rolos').format(g=groups | length, n=items | length) }}{% else %}{{ items | length }}{% endif %})

- +
+ + +
+ {% 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 %} +
+ {{ g.count }}× +
+ {{ donut(g.pct, fill, translucent=g.translucent, size=46) }} +
+
{{ g.brand }}
+
{{ g.material }}
+
{{ g.family }}
+
{{ rem_txt }}
+ +
+ {% else %} +
{{ _('Nenhum spool encontrado') }}
+ {% 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 %} @@ -44,6 +83,23 @@

{{ _('Inventário') }} ({ {% else %}
{{ _('Nenhum spool encontrado') }}
{% endfor %} + {% endif %} +

+ +{# Modal do grupo #} + {# Modal de detalhe do rolo #} @@ -79,6 +135,8 @@ stroke="none" transform="rotate(-90 25 25)" class="donut-checker"/> + + QR @@ -96,12 +154,36 @@ {% block scripts %} {% endblock %} diff --git a/tests/test_inventory_grouped.py b/tests/test_inventory_grouped.py new file mode 100644 index 0000000..f07e95a --- /dev/null +++ b/tests/test_inventory_grouped.py @@ -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) diff --git a/translations.py b/translations.py index a83510f..67cdf07 100644 --- a/translations.py +++ b/translations.py @@ -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"