Add summary page.

This commit is contained in:
Yaro Kasear 2025-10-16 14:42:33 -05:00
parent ade5ceaae5
commit c8f5c7011e
8 changed files with 142 additions and 437 deletions

View file

@ -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):

View file

@ -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():

View file

@ -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"},

View file

@ -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)

View file

@ -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>

View file

@ -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 doesnt 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}

View 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 %}

View file

@ -10,6 +10,7 @@ dependencies = [
"flask_sqlalchemy", "flask_sqlalchemy",
"html5lib", "html5lib",
"jinja_markdown", "jinja_markdown",
"matplotlib",
"pandas", "pandas",
"pyodbc", "pyodbc",
"python-dotenv", "python-dotenv",