Add summary page.
This commit is contained in:
parent
ade5ceaae5
commit
c8f5c7011e
8 changed files with 142 additions and 437 deletions
|
|
@ -21,6 +21,7 @@ from .routes.index import init_index_routes
|
||||||
from .routes.listing import init_listing_routes
|
from .routes.listing import init_listing_routes
|
||||||
from .routes.search import init_search_routes
|
from .routes.search import init_search_routes
|
||||||
from .routes.settings import init_settings_routes
|
from .routes.settings import init_settings_routes
|
||||||
|
from .routes.reports import init_reports_routes
|
||||||
|
|
||||||
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
@ -45,7 +46,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
print(f"Effective DB URL: {str(runtime.engine.url)}")
|
print(f"Effective DB URL: {str(runtime.engine.url)}")
|
||||||
|
|
||||||
from . import models as _models
|
from . import models as _models
|
||||||
from .routes.reports import bp_reports
|
|
||||||
_models.Base.metadata.create_all(bind=runtime.engine)
|
_models.Base.metadata.create_all(bind=runtime.engine)
|
||||||
|
|
||||||
# ensure extensions carries the scoped_session from integrations.init_app
|
# ensure extensions carries the scoped_session from integrations.init_app
|
||||||
|
|
@ -74,13 +74,12 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
_models.WorkNote,
|
_models.WorkNote,
|
||||||
])
|
])
|
||||||
|
|
||||||
app.register_blueprint(bp_reports)
|
|
||||||
|
|
||||||
init_entry_routes(app)
|
init_entry_routes(app)
|
||||||
init_index_routes(app)
|
init_index_routes(app)
|
||||||
init_listing_routes(app)
|
init_listing_routes(app)
|
||||||
init_search_routes(app)
|
init_search_routes(app)
|
||||||
init_settings_routes(app)
|
init_settings_routes(app)
|
||||||
|
init_reports_routes(app)
|
||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def _remove_session(_exc):
|
def _remove_session(_exc):
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ bp_index = Blueprint("index", __name__)
|
||||||
def init_index_routes(app):
|
def init_index_routes(app):
|
||||||
@bp_index.get("/")
|
@bp_index.get("/")
|
||||||
def index():
|
def index():
|
||||||
inventory_service = crudkit.crud.get_service(Inventory)
|
|
||||||
work_log_service = crudkit.crud.get_service(WorkLog)
|
work_log_service = crudkit.crud.get_service(WorkLog)
|
||||||
work_logs = work_log_service.list({
|
work_logs = work_log_service.list({
|
||||||
"complete__ne": 1,
|
"complete__ne": 1,
|
||||||
|
|
@ -26,41 +25,6 @@ def init_index_routes(app):
|
||||||
],
|
],
|
||||||
"sort": "start_time"
|
"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 = [
|
columns = [
|
||||||
{"field": "start_time", "label": "Start", "format": "date"},
|
{"field": "start_time", "label": "Start", "format": "date"},
|
||||||
{"field": "contact.label", "label": "Contact",
|
{"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"})
|
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")
|
@bp_index.get("/LICENSE")
|
||||||
def license():
|
def license():
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,13 @@ def init_listing_routes(app):
|
||||||
elif model.lower() == 'user':
|
elif model.lower() == 'user':
|
||||||
spec = {"fields": [
|
spec = {"fields": [
|
||||||
"label", "last_name", "first_name", "supervisor.label",
|
"label", "last_name", "first_name", "supervisor.label",
|
||||||
"robot.overlord", "staff", "active",
|
"robot.overlord", "staff", "active", "title",
|
||||||
], "sort": "first_name,last_name"}
|
], "sort": "first_name,last_name"}
|
||||||
columns = [
|
columns = [
|
||||||
{"field": "label", "label": "Full Name"},
|
{"field": "label", "label": "Full Name"},
|
||||||
{"field": "last_name"},
|
{"field": "last_name"},
|
||||||
{"field": "first_name"},
|
{"field": "first_name"},
|
||||||
|
{"field": "title"},
|
||||||
{"field": "supervisor.label", "label": "Supervisor",
|
{"field": "supervisor.label", "label": "Supervisor",
|
||||||
"link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
"link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
||||||
{"field": "staff", "format": "yesno"},
|
{"field": "staff", "format": "yesno"},
|
||||||
|
|
|
||||||
|
|
@ -1,142 +1,95 @@
|
||||||
import math
|
from flask import Blueprint, render_template, url_for
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
import crudkit
|
||||||
from sqlalchemy import select, func, case
|
|
||||||
|
|
||||||
from ..db import get_session
|
bp_reports = Blueprint("reports", __name__)
|
||||||
from ..models.inventory import Inventory
|
|
||||||
from ..models.device_type import DeviceType
|
|
||||||
|
|
||||||
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")
|
rows = inventory_service.list({
|
||||||
def inventory_availability():
|
"limit": 0,
|
||||||
"""
|
"sort": "device_type.description",
|
||||||
Returns Chart.js-ready JSON with labels = device types
|
"fields": ["id", "condition", "device_type.description"],
|
||||||
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
|
|
||||||
})
|
})
|
||||||
items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True)
|
df = pd.DataFrame([r.as_dict() for r in rows])
|
||||||
return jsonify({"items": items[:top_n]})
|
|
||||||
|
|
||||||
@bp_reports.get("/inventory/rows")
|
if "id" in df.columns:
|
||||||
def inventory_rows():
|
df = df.drop_duplicates(subset="id")
|
||||||
"""
|
|
||||||
Flat rows for PivotTable: device_type, condition.
|
pt = df.pivot_table(
|
||||||
Optional filters: condition_in=Working,Unverified device_type_in=Monitor,Laptop
|
index="device_type.description",
|
||||||
"""
|
columns="condition",
|
||||||
session = get_session()
|
values="id",
|
||||||
stmt = (
|
aggfunc="count",
|
||||||
select(
|
fill_value=0,
|
||||||
DeviceType.description.label("device_type"),
|
|
||||||
Inventory.condition.label("condition")
|
|
||||||
)
|
|
||||||
.select_from(Inventory)
|
|
||||||
.join(DeviceType, Inventory.type_id == DeviceType.id)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# simple whitelist filters
|
# Reorder/exclude like before
|
||||||
cond_in = request.args.get("condition_in")
|
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
|
||||||
if cond_in:
|
exclude = ["Removed", "Disposed"]
|
||||||
vals = [s.strip() for s in cond_in.split(",") if s.strip()]
|
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]
|
||||||
if vals:
|
pt = pt[cols]
|
||||||
stmt = stmt.where(Inventory.condition.in_(vals))
|
|
||||||
|
|
||||||
dt_in = request.args.get("device_type_in")
|
# Drop zero-only rows
|
||||||
if dt_in:
|
pt = pt.loc[(pt != 0).any(axis=1)]
|
||||||
vals = [s.strip() for s in dt_in.split(",") if s.strip()]
|
|
||||||
if vals:
|
|
||||||
stmt = stmt.where(DeviceType.description.in_(vals))
|
|
||||||
|
|
||||||
rows = session.execute(stmt).all()
|
# Totals
|
||||||
return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows])
|
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)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@
|
||||||
<a href="{{ url_for('listing.show_list', model='user') }}"
|
<a href="{{ url_for('listing.show_list', model='user') }}"
|
||||||
class="nav-link link-success fw-semibold">Users</a>
|
class="nav-link link-success fw-semibold">Users</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -44,8 +50,10 @@
|
||||||
<button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button>
|
<button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('settings.settings') }}" class="link-success fw-semibold ms-3">
|
<a href="{{ url_for('settings.settings') }}" class="link-success fw-semibold ms-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-gear-fill" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor"
|
||||||
<path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
|
class="bi bi-gear-fill" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,7 +139,7 @@
|
||||||
const list = JSON.parse(sessionStorage.getItem(key) || '[]');
|
const list = JSON.parse(sessionStorage.getItem(key) || '[]');
|
||||||
list.push({ message, type });
|
list.push({ message, type });
|
||||||
sessionStorage.setItem(key, JSON.stringify(list));
|
sessionStorage.setItem(key, JSON.stringify(list));
|
||||||
} catch {}
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
|
@ -146,7 +154,7 @@
|
||||||
for (const t of list) {
|
for (const t of list) {
|
||||||
window.toastMessage(t.message, t.type || 'info');
|
window.toastMessage(t.message, t.type || 'info');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
{% extends 'base.html' %}
|
{% 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 %}
|
{% block main %}
|
||||||
<h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1>
|
<h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1>
|
||||||
<p class="lead text-center">Find out about all of your assets.</p>
|
<p class="lead text-center">Find out about all of your assets.</p>
|
||||||
|
|
@ -19,256 +9,5 @@
|
||||||
<p class="display-6 text-center">Active Worklogs</p>
|
<p class="display-6 text-center">Active Worklogs</p>
|
||||||
{{ logs | safe }}
|
{{ logs | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col pivot-cell me-5">
|
|
||||||
<p class="display-6 text-center">Inventory Report</p>
|
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-end mb-2">
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Rows</label>
|
|
||||||
<select id="rows-dim" class="form-select form-select-sm">
|
|
||||||
<option value="device_type" selected>Device Type</option>
|
|
||||||
<option value="condition">Condition</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Columns</label>
|
|
||||||
<select id="cols-dim" class="form-select form-select-sm">
|
|
||||||
<option value="condition" selected>Condition</option>
|
|
||||||
<option value="device_type">Device Type</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Filter: Condition</label>
|
|
||||||
<select id="filter-condition" class="form-select form-select-sm" multiple size="4">
|
|
||||||
<option>Deployed</option>
|
|
||||||
<option>Working</option>
|
|
||||||
<option>Unverified</option>
|
|
||||||
<option>Partially Inoperable</option>
|
|
||||||
<option>Inoperable</option>
|
|
||||||
<option>Removed</option>
|
|
||||||
<option>Disposed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label">Top N rows</label>
|
|
||||||
<input id="top-n" type="number" min="5" max="200" value="40" class="form-control form-control-sm">
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="show-totals" checked>
|
|
||||||
<label class="form-check-label" for="show-totals">Show totals</label>
|
|
||||||
</div>
|
|
||||||
<button id="refresh" class="btn btn-sm btn-primary">Update</button>
|
|
||||||
<button id="export" class="btn btn-sm btn-outline-secondary">Export CSV</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive" style="overflow: auto;">
|
|
||||||
<table id="pivot" class="table table-sm table-bordered align-middle mb-2"></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="pivot-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scriptincludes %}
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
{% 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 = '<thead><tr><th>'+escapeHtml(state.rows)+'</th>';
|
|
||||||
for (const c of cols) thead += `<th>${escapeHtml(c)}</th>`;
|
|
||||||
if (showTotals) thead += '<th>Total</th>';
|
|
||||||
thead += '</tr></thead>';
|
|
||||||
|
|
||||||
// body
|
|
||||||
let tbody = '<tbody>';
|
|
||||||
for (const r of rows) {
|
|
||||||
tbody += `<tr><th class="text-nowrap">${escapeHtml(r)}</th>`;
|
|
||||||
for (const c of cols) {
|
|
||||||
const n = counts.get(`${r}|||${c}`) || 0;
|
|
||||||
tbody += `<td class="text-end">${n}</td>`;
|
|
||||||
}
|
|
||||||
if (showTotals) tbody += `<td class="text-end fw-semibold">${rowTotals.get(r) || 0}</td>`;
|
|
||||||
tbody += '</tr>';
|
|
||||||
}
|
|
||||||
if (showTotals) {
|
|
||||||
tbody += '<tr class="table-light fw-semibold"><th>Total</th>';
|
|
||||||
let grand = 0;
|
|
||||||
for (const c of cols) {
|
|
||||||
const t = colTotals.get(c) || 0;
|
|
||||||
grand += t;
|
|
||||||
tbody += `<td class="text-end">${t}</td>`;
|
|
||||||
}
|
|
||||||
tbody += `<td class="text-end">${grand}</td></tr>`;
|
|
||||||
}
|
|
||||||
tbody += '</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 %}
|
|
||||||
|
|
|
||||||
40
inventory/templates/summary.html
Normal file
40
inventory/templates/summary.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block main %}
|
||||||
|
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
|
||||||
|
<div class="table-responsive mx-5">
|
||||||
|
<table class="table table-sm table-striped table-hover table-bordered align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-nowrap">Device Type</th>
|
||||||
|
{% for col in col_headers %}
|
||||||
|
{% if col.href %}
|
||||||
|
<th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
|
||||||
|
{% else %}
|
||||||
|
<th class="text-end">{{ col.label }}</th>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in table_rows %}
|
||||||
|
<tr>
|
||||||
|
<th class="text-nowrap">
|
||||||
|
{% if row.href %}
|
||||||
|
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ row.label }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
{% if cell.href %}
|
||||||
|
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
|
||||||
|
{% else %}
|
||||||
|
<td class="text-end">{{ cell.value }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -10,6 +10,7 @@ dependencies = [
|
||||||
"flask_sqlalchemy",
|
"flask_sqlalchemy",
|
||||||
"html5lib",
|
"html5lib",
|
||||||
"jinja_markdown",
|
"jinja_markdown",
|
||||||
|
"matplotlib",
|
||||||
"pandas",
|
"pandas",
|
||||||
"pyodbc",
|
"pyodbc",
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue