Lots of work for reports support.
This commit is contained in:
parent
b8cd972090
commit
31cc630dcf
9 changed files with 544 additions and 26 deletions
|
|
@ -1,12 +1,34 @@
|
||||||
from typing import Type, TypeVar, Generic, Optional
|
from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
|
||||||
from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic
|
from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.orm.util import AliasedClass
|
||||||
|
from sqlalchemy.engine import Engine, Connection
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
from crudkit.core.base import Version
|
from crudkit.core.base import Version
|
||||||
from crudkit.core.spec import CRUDSpec
|
from crudkit.core.spec import CRUDSpec
|
||||||
from crudkit.backend import BackendInfo, make_backend_info
|
from crudkit.backend import BackendInfo, make_backend_info
|
||||||
|
|
||||||
T = TypeVar("T")
|
@runtime_checkable
|
||||||
|
class _HasID(Protocol):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class _HasTable(Protocol):
|
||||||
|
__table__: Any
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class _HasADict(Protocol):
|
||||||
|
def as_dict(self) -> dict: ...
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class _SoftDeletable(Protocol):
|
||||||
|
is_deleted: bool
|
||||||
|
|
||||||
|
class _CRUDModelProto(_HasID, _HasTable, _HasADict, Protocol):
|
||||||
|
"""Minimal surface that our CRUD service relies on. Soft-delete is optional."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=_CRUDModelProto)
|
||||||
|
|
||||||
def _is_truthy(val):
|
def _is_truthy(val):
|
||||||
return str(val).lower() in ('1', 'true', 'yes', 'on')
|
return str(val).lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
|
@ -25,7 +47,9 @@ class CRUDService(Generic[T]):
|
||||||
self.polymorphic = polymorphic
|
self.polymorphic = polymorphic
|
||||||
self.supports_soft_delete = hasattr(model, 'is_deleted')
|
self.supports_soft_delete = hasattr(model, 'is_deleted')
|
||||||
# Cache backend info once. If not provided, derive from session bind.
|
# Cache backend info once. If not provided, derive from session bind.
|
||||||
self.backend = backend or make_backend_info(self.session.get_bind())
|
bind = self.session.get_bind()
|
||||||
|
eng: Engine = bind.engine if isinstance(bind, Connection) else cast(Engine, bind)
|
||||||
|
self.backend = backend or make_backend_info(eng)
|
||||||
|
|
||||||
def get_query(self):
|
def get_query(self):
|
||||||
if self.polymorphic:
|
if self.polymorphic:
|
||||||
|
|
@ -35,7 +59,7 @@ class CRUDService(Generic[T]):
|
||||||
|
|
||||||
# Helper: default ORDER BY for MSSQL when paginating without explicit order
|
# Helper: default ORDER BY for MSSQL when paginating without explicit order
|
||||||
def _default_order_by(self, root_alias):
|
def _default_order_by(self, root_alias):
|
||||||
mapper = inspect(self.model)
|
mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
|
||||||
cols = []
|
cols = []
|
||||||
for col in mapper.primary_key:
|
for col in mapper.primary_key:
|
||||||
try:
|
try:
|
||||||
|
|
@ -64,11 +88,9 @@ class CRUDService(Generic[T]):
|
||||||
spec.parse_includes()
|
spec.parse_includes()
|
||||||
|
|
||||||
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
||||||
query = query.join(
|
rel_attr = cast(InstrumentedAttribute, relationship_attr)
|
||||||
target_alias,
|
target = cast(Any, target_alias)
|
||||||
relationship_attr.of_type(target_alias),
|
query = query.join(target, rel_attr.of_type(target), isouter=True)
|
||||||
isouter=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
||||||
|
|
@ -123,11 +145,9 @@ class CRUDService(Generic[T]):
|
||||||
spec.parse_includes()
|
spec.parse_includes()
|
||||||
|
|
||||||
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
||||||
query = query.join(
|
rel_attr = cast(InstrumentedAttribute, relationship_attr)
|
||||||
target_alias,
|
target = cast(Any, target_alias)
|
||||||
relationship_attr.of_type(target_alias),
|
query = query.join(target, rel_attr.of_type(target), isouter=True)
|
||||||
isouter=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
||||||
|
|
@ -206,7 +226,8 @@ class CRUDService(Generic[T]):
|
||||||
if hard or not self.supports_soft_delete:
|
if hard or not self.supports_soft_delete:
|
||||||
self.session.delete(obj)
|
self.session.delete(obj)
|
||||||
else:
|
else:
|
||||||
obj.is_deleted = True
|
soft = cast(_SoftDeletable, obj)
|
||||||
|
soft.is_deleted = True
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self._log_version("delete", obj, actor)
|
self._log_version("delete", obj, actor)
|
||||||
return obj
|
return obj
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ def create_app(config_cls=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)
|
||||||
|
|
||||||
Session = app.extensions["crudkit"].get("Session")
|
Session = app.extensions["crudkit"].get("Session")
|
||||||
|
|
@ -49,6 +50,7 @@ def create_app(config_cls=DevConfig) -> Flask:
|
||||||
app.register_blueprint(generate_crud_blueprint(_models.User, user_service), url_prefix="/api/user")
|
app.register_blueprint(generate_crud_blueprint(_models.User, user_service), url_prefix="/api/user")
|
||||||
app.register_blueprint(generate_crud_blueprint(_models.WorkLog, work_log_service), url_prefix="/api/work_log")
|
app.register_blueprint(generate_crud_blueprint(_models.WorkLog, work_log_service), url_prefix="/api/work_log")
|
||||||
app.register_blueprint(generate_crud_blueprint(_models.WorkNote, work_note_service), url_prefix="/api/work_note")
|
app.register_blueprint(generate_crud_blueprint(_models.WorkNote, work_note_service), url_prefix="/api/work_note")
|
||||||
|
app.register_blueprint(bp_reports)
|
||||||
|
|
||||||
init_index_routes(app)
|
init_index_routes(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,10 @@ class Inventory(Base, CRUDMixin):
|
||||||
parts.append(f"notes={repr(self.notes)}")
|
parts.append(f"notes={repr(self.notes)}")
|
||||||
|
|
||||||
if self.owner:
|
if self.owner:
|
||||||
parts.append(f"owner={repr(self.owner.identifier)}")
|
parts.append(f"owner={repr(self.owner.label)}")
|
||||||
|
|
||||||
if self.location:
|
if self.location:
|
||||||
parts.append(f"location={repr(self.location.identifier)}")
|
parts.append(f"location={repr(self.location.label)}")
|
||||||
|
|
||||||
return f"<Inventory({', '.join(parts)})>"
|
return f"<Inventory({', '.join(parts)})>"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from typing import List, Optional, TYPE_CHECKING
|
from typing import List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, Unicode
|
from sqlalchemy import Boolean, ForeignKey, Integer, Unicode
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.sql import expression as sql
|
from sqlalchemy.sql import expression as sql
|
||||||
|
|
||||||
|
|
@ -28,3 +29,7 @@ class Room(Base, CRUDMixin):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Room(id={self.id}, name={repr(self.name)}, area={repr(self.area.name)}, function={repr(self.room_function.description)})>"
|
return f"<Room(id={self.id}, name={repr(self.name)}, area={repr(self.area.name)}, function={repr(self.room_function.description)})>"
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def label(self):
|
||||||
|
return f"{self.name} - {self.room_function.description}"
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,73 @@
|
||||||
from flask import Blueprint, current_app, render_template, send_file
|
from flask import Blueprint, current_app, jsonify, render_template, request, send_file
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
from crudkit.core.service import CRUDService
|
from crudkit.core.service import CRUDService
|
||||||
from crudkit.core.spec import CRUDSpec
|
from crudkit.core.spec import CRUDSpec
|
||||||
from crudkit.ui.fragments import render_table
|
from crudkit.ui.fragments import render_table
|
||||||
|
|
||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
|
from ..models.device_type import DeviceType
|
||||||
|
from ..models.inventory import Inventory
|
||||||
from ..models.work_log import WorkLog
|
from ..models.work_log import WorkLog
|
||||||
|
|
||||||
bp_index = Blueprint("index", __name__)
|
bp_index = Blueprint("index", __name__)
|
||||||
|
|
||||||
def init_index_routes(app):
|
def init_index_routes(app):
|
||||||
|
|
||||||
|
@bp_index.get("/api/pivot/inventory")
|
||||||
|
def pivot_inventory():
|
||||||
|
session = get_session()
|
||||||
|
# qs params: rows, cols, where_status, top_n
|
||||||
|
rows = request.args.get("rows", "device_type.description")
|
||||||
|
cols = request.args.get("cols", "condition")
|
||||||
|
top_n = int(request.args.get("top_n", "40"))
|
||||||
|
|
||||||
|
# Map friendly names to columns
|
||||||
|
COLS = {
|
||||||
|
"device_type.description": DeviceType.description.label("row"),
|
||||||
|
"condition": Inventory.condition.label("row"),
|
||||||
|
"status": Inventory.condition.label("row"), # alias if you use 'status' in UI
|
||||||
|
}
|
||||||
|
ROW = COLS.get(rows)
|
||||||
|
COL = Inventory.condition.label("col") if cols == "condition" else DeviceType.description.label("col")
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(ROW, COL, func.count(Inventory.id).label("n"))
|
||||||
|
.select_from(Inventory).join(DeviceType, Inventory.type_id == DeviceType.id)
|
||||||
|
.group_by(ROW, COL)
|
||||||
|
)
|
||||||
|
data = session.execute(stmt).all() # [(row, col, n), ...]
|
||||||
|
|
||||||
|
# reshape into Chart.js: labels = sorted rows by total, datasets per column value
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.DataFrame(data, columns=["row", "col", "n"])
|
||||||
|
if df.empty:
|
||||||
|
return jsonify({"labels": [], "datasets": []})
|
||||||
|
|
||||||
|
totals = df.groupby("row")["n"].sum().sort_values(ascending=False)
|
||||||
|
keep_rows = totals.head(top_n).index.tolist()
|
||||||
|
df = df[df["row"].isin(keep_rows)]
|
||||||
|
|
||||||
|
labels = keep_rows
|
||||||
|
by_col = df.pivot_table(index="row", columns="col", values="n", aggfunc="sum", fill_value=0)
|
||||||
|
by_col = by_col.reindex(labels) # row order
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{"label": str(col), "data": by_col[col].astype(int).tolist()}
|
||||||
|
for col in by_col.columns
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
@bp_index.get("/")
|
@bp_index.get("/")
|
||||||
def index():
|
def index():
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
inventory_service = CRUDService(Inventory, session)
|
||||||
work_log_service = CRUDService(WorkLog, session)
|
work_log_service = CRUDService(WorkLog, session)
|
||||||
work_logs = work_log_service.list({
|
work_logs = work_log_service.list({
|
||||||
"complete__ne": 1,
|
"complete__ne": 1,
|
||||||
|
|
@ -26,6 +79,38 @@ 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]
|
||||||
|
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"},
|
||||||
|
|
@ -38,7 +123,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, columns=columns)
|
return render_template("index.html", logs=logs, chart_data=chart_data)
|
||||||
|
|
||||||
@bp_index.get("/LICENSE")
|
@bp_index.get("/LICENSE")
|
||||||
def license():
|
def license():
|
||||||
|
|
|
||||||
144
inventory/routes/reports.py
Normal file
144
inventory/routes/reports.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import math
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from sqlalchemy import select, func, case
|
||||||
|
|
||||||
|
from ..db import get_session
|
||||||
|
from ..models.inventory import Inventory
|
||||||
|
from ..models.device_type import DeviceType
|
||||||
|
|
||||||
|
bp_reports = Blueprint("reports", __name__, url_prefix="/api/reports")
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
print(rows)
|
||||||
|
|
||||||
|
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)
|
||||||
|
return jsonify({"items": items[:top_n]})
|
||||||
|
|
||||||
|
@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")
|
||||||
|
)
|
||||||
|
.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))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
rows = session.execute(stmt).all()
|
||||||
|
return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows])
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class="pb-5">
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar bg-body-tertiary border border-top-0">
|
<nav class="navbar bg-body-tertiary border border-top-0">
|
||||||
|
|
@ -29,12 +29,15 @@
|
||||||
{% block premain %}
|
{% block premain %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<main class="container mt-5">
|
<main class="container mt-3">
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="bg-body-tertiary border border-bottom-0 position-absolute bottom-0 w-100 pb-1">
|
{% block postmain %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<footer class="bg-body-tertiary border border-bottom-0 position-fixed bottom-0 w-100 pb-1">
|
||||||
<small>
|
<small>
|
||||||
<span class="align-middle">© 2025 Conrad Nelson •
|
<span class="align-middle">© 2025 Conrad Nelson •
|
||||||
<a href="/LICENSE" class="link-underline link-underline-opacity-0">AGPL-3.0-or-later</a> •
|
<a href="/LICENSE" class="link-underline link-underline-opacity-0">AGPL-3.0-or-later</a> •
|
||||||
|
|
@ -46,6 +49,8 @@
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
||||||
|
{% block scriptincludes %}
|
||||||
|
{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,264 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<h1 class="display-1 text-center">{{ title if title else "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>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="display-6 text-center">Active Worklogs</p>
|
||||||
{{ logs | safe }}
|
{{ logs | safe }}
|
||||||
{% endblock %}
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<p class="display-6 text-center">Inventory PivotTable</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="max-height: 520px; overflow: auto;">
|
||||||
|
<table id="pivot" class="table table-sm table-bordered align-middle mb-2"></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container" style="height:420px;">
|
||||||
|
<canvas id="pivot-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue