From c8f5c7011e8754181c11ddf1bf85f8e249ba4274 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 16 Oct 2025 14:42:33 -0500 Subject: [PATCH] Add summary page. --- inventory/__init__.py | 5 +- inventory/routes/index.py | 38 +---- inventory/routes/listing.py | 3 +- inventory/routes/reports.py | 215 ++++++++++--------------- inventory/templates/base.html | 16 +- inventory/templates/index.html | 261 ------------------------------- inventory/templates/summary.html | 40 +++++ pyproject.toml | 1 + 8 files changed, 142 insertions(+), 437 deletions(-) create mode 100644 inventory/templates/summary.html diff --git a/inventory/__init__.py b/inventory/__init__.py index 49347e0..0dc7e3f 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -21,6 +21,7 @@ from .routes.index import init_index_routes from .routes.listing import init_listing_routes from .routes.search import init_search_routes from .routes.settings import init_settings_routes +from .routes.reports import init_reports_routes def create_app(config_cls=crudkit.DevConfig) -> Flask: app = Flask(__name__) @@ -45,7 +46,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: print(f"Effective DB URL: {str(runtime.engine.url)}") from . import models as _models - from .routes.reports import bp_reports _models.Base.metadata.create_all(bind=runtime.engine) # ensure extensions carries the scoped_session from integrations.init_app @@ -74,13 +74,12 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: _models.WorkNote, ]) - app.register_blueprint(bp_reports) - init_entry_routes(app) init_index_routes(app) init_listing_routes(app) init_search_routes(app) init_settings_routes(app) + init_reports_routes(app) @app.teardown_appcontext def _remove_session(_exc): diff --git a/inventory/routes/index.py b/inventory/routes/index.py index 794c5ad..b6e156a 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -15,7 +15,6 @@ bp_index = Blueprint("index", __name__) def init_index_routes(app): @bp_index.get("/") def index(): - inventory_service = crudkit.crud.get_service(Inventory) work_log_service = crudkit.crud.get_service(WorkLog) work_logs = work_log_service.list({ "complete__ne": 1, @@ -26,41 +25,6 @@ def init_index_routes(app): ], "sort": "start_time" }) - inventory_report_rows = inventory_service.list({ - "fields": ["condition", "device_type.description"], - "limit": 0 - }) - rows = [item.as_dict() for item in inventory_report_rows] - chart_data = {} - if rows: - df = pd.DataFrame(rows) - - xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int) - - top_labels = ( - xtab.sum(axis=0) - .sort_values(ascending=False) - .index.tolist() - ) - xtab = xtab[top_labels] - - preferred_order = [ - "Deployed", "Working", "Unverified", - "Partially Inoperable", "Inoperable", - "Removed", "Disposed" - ] - conditions = [c for c in preferred_order if c in xtab.index] + [c for c in xtab.index if c not in preferred_order] - xtab = xtab.loc[conditions] - - chart_data = { - "labels": top_labels, - "datasets": [{ - "label": cond, - "data": xtab.loc[cond].to_list() - } - for cond in xtab.index] - } - columns = [ {"field": "start_time", "label": "Start", "format": "date"}, {"field": "contact.label", "label": "Contact", @@ -71,7 +35,7 @@ def init_index_routes(app): logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) - return render_template("index.html", logs=logs, chart_data=chart_data) + return render_template("index.html", logs=logs) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/listing.py b/inventory/routes/listing.py index 95301e9..8a9d8e4 100644 --- a/inventory/routes/listing.py +++ b/inventory/routes/listing.py @@ -54,12 +54,13 @@ def init_listing_routes(app): elif model.lower() == 'user': spec = {"fields": [ "label", "last_name", "first_name", "supervisor.label", - "robot.overlord", "staff", "active", + "robot.overlord", "staff", "active", "title", ], "sort": "first_name,last_name"} columns = [ {"field": "label", "label": "Full Name"}, {"field": "last_name"}, {"field": "first_name"}, + {"field": "title"}, {"field": "supervisor.label", "label": "Supervisor", "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}}, {"field": "staff", "format": "yesno"}, diff --git a/inventory/routes/reports.py b/inventory/routes/reports.py index d12bff9..2d69cba 100644 --- a/inventory/routes/reports.py +++ b/inventory/routes/reports.py @@ -1,142 +1,95 @@ -import math +from flask import Blueprint, render_template, url_for +from urllib.parse import urlencode + import pandas as pd -from flask import Blueprint, request, jsonify -from sqlalchemy import select, func, case +import crudkit -from ..db import get_session -from ..models.inventory import Inventory -from ..models.device_type import DeviceType +bp_reports = Blueprint("reports", __name__) -bp_reports = Blueprint("reports", __name__, url_prefix="/api/reports") +def init_reports_routes(app): + @bp_reports.get('/summary') + def summary(): + inventory_model = crudkit.crud.get_model('inventory') + inventory_service = crudkit.crud.get_service(inventory_model) -@bp_reports.get("/inventory/availability") -def inventory_availability(): - """ - Returns Chart.js-ready JSON with labels = device types - and datasets = Deployed / Available / Unavailable counts - Query params: - top_n: int (default 40) -> Limit how many device types to show by total desc - """ - top_n = int(request.args.get("top_n", "40")) - session = get_session() - - deployed = func.sum(case((Inventory.condition == "Deployed", 1), else_=0)).label("deployed") - available = func.sum(case((Inventory.condition.in_(("Working", "Unverified")), 1), else_=0)).label("available") - unavailable = func.sum( - case((~Inventory.condition.in_(("Deployed", "Working", "Unverified")), 1), else_=0) - ).label("unavailable") - - stmt = ( - select(DeviceType.description.label("device_type"), deployed, available, unavailable) - .select_from(Inventory) - .join(DeviceType, Inventory.type_id == DeviceType.id) - .group_by(DeviceType.description) - ) - - rows = session.execute(stmt).all() - - if not rows: - return jsonify({"labels": [], "datasets": []}) - - totals = [(dt, d + a + u) for dt, d, a, u in rows] - totals.sort(key=lambda t: t[1], reverse=True) - keep = {dt for dt, _ in totals[:top_n]} - - labels = [dt for dt, _ in totals if dt in keep] - d_data, a_data, u_data = [], [], [] - by_dt = {dt: (d, a, u) for dt, d, a, u in rows} - for dt in labels: - d, a, u = by_dt[dt] - d_data.append(int(d)) - a_data.append(int(a)) - u_data.append(int(u)) - - payload = { - "labels": labels, - "datasets": [ - {"label": "Deployed", "data": d_data}, - {"label": "Available", "data": a_data}, - {"label": "Unavailable", "data": u_data}, - ], - } - - return jsonify(payload) - -@bp_reports.get("/inventory/spares") -def inventory_spares(): - """ - Query params: - ratio: float (default 0.10) -> 10% spare target - min: int (default 2) -> floor for critical device types - critical: comma list -> applies 'min' floor - top_n: int (default 20) - """ - import math - ratio = float(request.args.get("ratio", "0.10")) - min_floor = int(request.args.get("min", "2")) - critical = set(x.strip() for x in request.args.get("critical", "Monitor,Desktop PC,Laptop").split(",")) - top_n = int(request.args.get("top_n", "20")) - - session = get_session() - deployed = func.sum(case((Inventory.condition == "Deployed", 1), else_=0)) - available = func.sum(case((Inventory.condition.in_(("Working","Unverified")), 1), else_=0)) - stmt = ( - select(DeviceType.description, deployed.label("deployed"), available.label("available")) - .select_from(Inventory) - .join(DeviceType, Inventory.type_id == DeviceType.id) - .group_by(DeviceType.description) - ) - rows = session.execute(stmt).all() - - items = [] - for dev, dep, avail in rows: - dep = int(dep or 0) - avail = int(avail or 0) - target = math.ceil(dep * ratio) - if dev in critical: - target = max(target, min_floor) - deficit = max(target - avail, 0) - if dep == 0 and avail == 0 and deficit == 0: - continue - items.append({ - "device_type": dev, - "deployed": dep, - "available": avail, - "target_spares": target, - "deficit": deficit + rows = inventory_service.list({ + "limit": 0, + "sort": "device_type.description", + "fields": ["id", "condition", "device_type.description"], }) - items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True) - return jsonify({"items": items[:top_n]}) + df = pd.DataFrame([r.as_dict() for r in rows]) -@bp_reports.get("/inventory/rows") -def inventory_rows(): - """ - Flat rows for PivotTable: device_type, condition. - Optional filters: condition_in=Working,Unverified device_type_in=Monitor,Laptop - """ - session = get_session() - stmt = ( - select( - DeviceType.description.label("device_type"), - Inventory.condition.label("condition") + if "id" in df.columns: + df = df.drop_duplicates(subset="id") + + pt = df.pivot_table( + index="device_type.description", + columns="condition", + values="id", + aggfunc="count", + fill_value=0, ) - .select_from(Inventory) - .join(DeviceType, Inventory.type_id == DeviceType.id) - ) - # simple whitelist filters - cond_in = request.args.get("condition_in") - if cond_in: - vals = [s.strip() for s in cond_in.split(",") if s.strip()] - if vals: - stmt = stmt.where(Inventory.condition.in_(vals)) + # Reorder/exclude like before + order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"] + exclude = ["Removed", "Disposed"] + cols = [c for c in order if c in pt.columns] + [c for c in pt.columns if c not in order and c not in exclude] + pt = pt[cols] - dt_in = request.args.get("device_type_in") - if dt_in: - vals = [s.strip() for s in dt_in.split(",") if s.strip()] - if vals: - stmt = stmt.where(DeviceType.description.in_(vals)) + # Drop zero-only rows + pt = pt.loc[(pt != 0).any(axis=1)] - rows = session.execute(stmt).all() - return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows]) + # Totals + pt["Total"] = pt.sum(axis=1) + total_row = pt.sum(axis=0).to_frame().T + total_row.index = ["Total"] + pt = pd.concat([pt, total_row], axis=0) + + # Names off + pt.index.name = None + pt.columns.name = None + + # Build link helpers. url_for can't take dotted kwarg keys, so build query strings. + base_list_url = url_for("listing.show_list", model="inventory") + + def q(h): + return f"{base_list_url}?{urlencode(h)}" if h else None + + # Column headers with links (except Total) + col_headers = [] + for col in pt.columns.tolist(): + if col == "Total": + col_headers.append({"label": col, "href": None}) + else: + col_headers.append({"label": col, "href": q({"condition": col})}) + + # Rows with header links and cell links + table_rows = [] + for idx in pt.index.tolist(): + # Row header link: only if not Total + if idx == "Total": + row_href = None + else: + row_href = q({"device_type.description": idx}) + + # Cells: combine filters, respecting Total row/col rules + cells = [] + for col in pt.columns.tolist(): + val = int(pt.at[idx, col]) + params = {} + if idx != "Total": + params["device_type.description"] = idx + if col != "Total": + params["condition"] = col + href = q(params) if params else None # None for TotalĂ—Total + cells.append({"value": f"{val:,}", "href": href}) + table_rows.append({"label": idx, "href": row_href, "cells": cells}) + + return render_template( + "summary.html", + col_headers=col_headers, + table_rows=table_rows, + ) + + app.register_blueprint(bp_reports) diff --git a/inventory/templates/base.html b/inventory/templates/base.html index 6dfbabc..dfbc595 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -36,6 +36,12 @@ Users + {% block header %} {% endblock %} @@ -44,8 +50,10 @@ - - + + @@ -131,7 +139,7 @@ const list = JSON.parse(sessionStorage.getItem(key) || '[]'); list.push({ message, type }); sessionStorage.setItem(key, JSON.stringify(list)); - } catch {} + } catch { } }; {% block script %} @@ -146,7 +154,7 @@ for (const t of list) { window.toastMessage(t.message, t.type || 'info'); } - } catch {} + } catch { } }); diff --git a/inventory/templates/index.html b/inventory/templates/index.html index b6c429c..a6a3d6e 100644 --- a/inventory/templates/index.html +++ b/inventory/templates/index.html @@ -1,15 +1,5 @@ {% extends 'base.html' %} -{% block style %} - .chart-container { height: 420px; } - - .pivot-cell { min-width: 0; min-height: 0; } - - .table-responsive { max-height: 420px; overflow: auto; } - - #pivot thead th { position: sticky; top: 0; z-index: 1; background: var(--bs-body-bg); } -{% endblock %} - {% block main %}

{{ title or "Inventory Manager" }}

Find out about all of your assets.

@@ -19,256 +9,5 @@

Active Worklogs

{{ logs | safe }} - -
-

Inventory Report

- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
- -
-
-
- -
- -
-
{% endblock %} - -{% block scriptincludes %} - -{% endblock %} - -{% block script %} -const state = { - rows: 'device_type', - cols: 'condition', - filterCondition: new Set(), - topN: 40, - data: [] -}; - -const el = { - rows: document.getElementById('rows-dim'), - cols: document.getElementById('cols-dim'), - cond: document.getElementById('filter-condition'), - topN: document.getElementById('top-n'), - totals: document.getElementById('show-totals'), - table: document.getElementById('pivot'), - export: document.getElementById('export'), - refresh: document.getElementById('refresh'), - canvas: document.getElementById('pivot-chart') -}; - -let chart; - -async function fetchRows() { - // apply server-side filters when possible - const condList = [...state.filterCondition]; - const qs = condList.length ? `?condition_in=${encodeURIComponent(condList.join(','))}` : ''; - const res = await fetch(`/api/reports/inventory/rows${qs}`); - state.data = await res.json(); -} - -function uniq(values) { - return [...new Set(values)]; -} - -function pivotize(rows, rowKey, colKey) { - // build sets - const rowVals = uniq(rows.map(r => r[rowKey])); - const colVals = uniq(rows.map(r => r[colKey])); - // counts - const counts = new Map(); // key `${r}|||${c}` -> n - for (const r of rowVals) for (const c of colVals) counts.set(`${r}|||${c}`, 0); - for (const rec of rows) { - const k = `${rec[rowKey]}|||${rec[colKey]}`; - counts.set(k, (counts.get(k) || 0) + 1); - } - // totals per row - const totals = new Map(); - for (const r of rowVals) { - let t = 0; - for (const c of colVals) t += counts.get(`${r}|||${c}`) || 0; - totals.set(r, t); - } - // sort rows by total desc, take topN - const sortedRows = rowVals.sort((a,b) => (totals.get(b)||0) - (totals.get(a)||0)) - .slice(0, state.topN); - // recompute column totals for visible rows - const colTotals = new Map(); - for (const c of colVals) { - let t = 0; - for (const r of sortedRows) t += counts.get(`${r}|||${c}`) || 0; - colTotals.set(c, t); - } - return { rows: sortedRows, cols: colVals, counts, rowTotals: totals, colTotals }; -} - -function renderTable(piv) { - const { rows, cols, counts, rowTotals, colTotals } = piv; - const showTotals = el.totals.checked; - - // header - let thead = ''+escapeHtml(state.rows)+''; - for (const c of cols) thead += `${escapeHtml(c)}`; - if (showTotals) thead += 'Total'; - thead += ''; - - // body - let tbody = ''; - for (const r of rows) { - tbody += `${escapeHtml(r)}`; - for (const c of cols) { - const n = counts.get(`${r}|||${c}`) || 0; - tbody += `${n}`; - } - if (showTotals) tbody += `${rowTotals.get(r) || 0}`; - tbody += ''; - } - if (showTotals) { - tbody += 'Total'; - let grand = 0; - for (const c of cols) { - const t = colTotals.get(c) || 0; - grand += t; - tbody += `${t}`; - } - tbody += `${grand}`; - } - tbody += ''; - - el.table.innerHTML = thead + tbody; -} - -function renderChart(piv) { - const { rows, cols, counts } = piv; - - // Build Chart.js datasets to mirror the table (stacked horizontal) - const datasets = cols.map(c => ({ - label: c, - data: rows.map(r => counts.get(`${r}|||${c}`) || 0) - })); - - // height based on rows, with a clamp so it doesn’t grow forever - const perRow = 24, pad = 96, maxH = 520; - el.canvas.height = Math.min(rows.length * perRow + pad, maxH); - - if (chart) chart.destroy(); - chart = new Chart(el.canvas.getContext('2d'), { - type: 'bar', - data: { labels: rows, datasets }, - options: { - indexAxis: 'y', - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { position: 'top' }, - title: { display: false }, - tooltip: { mode: 'index', intersect: false } - }, - scales: { - x: { stacked: true, title: { display: true, text: 'Count' } }, - y: { stacked: true, ticks: { autoSkip: false } } - } - } - }); -} - -function exportCsv(piv) { - const { rows, cols, counts, rowTotals, colTotals } = piv; - const showTotals = el.totals.checked; - - let csv = [ [state.rows, ...cols] ]; - if (showTotals) csv[0].push('Total'); - - for (const r of rows) { - const line = [r]; - let rt = 0; - for (const c of cols) { - const n = counts.get(`${r}|||${c}`) || 0; - line.push(n); - rt += n; - } - if (showTotals) line.push(rt); - csv.push(line); - } - if (showTotals) { - const totalLine = ['Total', ...cols.map(c => colTotals.get(c) || 0)]; - totalLine.push(totalLine.slice(1).reduce((a,b)=>a+b,0)); - csv.push(totalLine); - } - - const blob = new Blob([csv.map(row => row.map(csvEscape).join(',')).join('\r\n')], {type: 'text/csv'}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = 'inventory_pivot.csv'; - document.body.appendChild(a); a.click(); a.remove(); - URL.revokeObjectURL(url); -} - -function getSelectedOptions(selectEl) { - return [...selectEl.options].filter(o => o.selected).map(o => o.value); -} - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); -} -function csvEscape(v) { - const s = String(v ?? ''); - return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s; -} - -async function refresh() { - state.rows = el.rows.value; - state.cols = el.cols.value; - state.topN = parseInt(el.topN.value || '40', 10); - state.filterCondition = new Set(getSelectedOptions(el.cond)); - await fetchRows(); - const piv = pivotize(state.data, state.rows, state.cols); - renderTable(piv); - renderChart(piv); - el.export.onclick = () => exportCsv(piv); -} - -el.refresh.addEventListener('click', refresh); -refresh(); -{% endblock %} diff --git a/inventory/templates/summary.html b/inventory/templates/summary.html new file mode 100644 index 0000000..4a76680 --- /dev/null +++ b/inventory/templates/summary.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block main %} +

Inventory Summary

+
+ + + + + {% for col in col_headers %} + {% if col.href %} + + {% else %} + + {% endif %} + {% endfor %} + + + + {% for row in table_rows %} + + + {% for cell in row.cells %} + {% if cell.href %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
Device Type{{ col.label }}{{ col.label }}
+ {% if row.href %} + {{ row.label }} + {% else %} + {{ row.label }} + {% endif %} + {{ cell.value }}{{ cell.value }}
+
+{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index f0888ad..a121c7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "flask_sqlalchemy", "html5lib", "jinja_markdown", + "matplotlib", "pandas", "pyodbc", "python-dotenv",