Compare commits

..

No commits in common. "5234cbdd616b8fb1b0054d3180fac977799b9a4d" and "ae54277e5898df490888a3988b54208dae87acdb" have entirely different histories.

25 changed files with 691 additions and 654 deletions

View file

@ -2,45 +2,29 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request, abort, current_app, url_for
from hashlib import md5
from flask import Blueprint, jsonify, request, abort
from urllib.parse import urlencode
from werkzeug.exceptions import HTTPException
from crudkit.api._cursor import encode_cursor, decode_cursor
from crudkit.core.service import _is_truthy
MAX_JSON = 1_000_000
def _etag_for(obj) -> str:
v = getattr(obj, "updated_at", None) or obj.id
return md5(str(v).encode()).hexdigest()
def _json_payload() -> dict:
if request.content_length and request.content_length > MAX_JSON:
abort(413)
if not request.is_json:
abort(415)
payload = request.get_json(silent=False)
if not isinstance(payload, dict):
abort(400)
return payload
def _args_flat() -> dict[str, str]:
return request.args.to_dict(flat=True) # type: ignore[arg-type]
def _json_error(e: Exception, status: int = 400):
if isinstance(e, HTTPException):
status = e.code or status
msg = e.description
else:
msg = str(e)
if current_app.debug:
return jsonify({"status": "error", "error": msg, "type": e.__class__.__name__}), status
return jsonify({"status": "error", "error": msg}), status
def _bool_param(d: dict[str, str], key: str, default: bool) -> bool:
return _is_truthy(d.get(key, "1" if default else "0"))
def _safe_int(value: str | None, default: int) -> int:
try:
return int(value) if value is not None else default
except Exception:
return default
def _link_with_params(base_url: str, **params) -> str:
q = {k: v for k, v in params.items() if v is not None}
return f"{base_url}?{urlencode(q)}"
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
"""
REST:
@ -66,75 +50,65 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
@bp.errorhandler(Exception)
def _handle_any(e: Exception):
return _json_error(e)
@bp.errorhandler(404)
def _not_found(_e):
return jsonify({"status": "error", "error": "not found"}), 404
# ---------- REST ----------
if rest:
@bp.get("/")
def rest_list():
args = _args_flat()
args = request.args.to_dict(flat=True)
# support cursor pagination transparently; fall back to limit/offset
try:
items = service.list(args)
return jsonify([o.as_dict() for o in items])
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.get("/<int:obj_id>")
def rest_get(obj_id: int):
item = service.get(obj_id, request.args)
if item is None:
abort(404)
etag = _etag_for(item)
if request.if_none_match and (etag in request.if_none_match):
return "", 304
resp = jsonify(item.as_dict())
resp.set_etag(etag)
return resp
try:
item = service.get(obj_id, request.args)
if item is None:
abort(404)
return jsonify(item.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.post("/")
def rest_create():
payload = _json_payload()
payload = request.get_json(silent=True) or {}
try:
obj = service.create(payload)
resp = jsonify(obj.as_dict())
resp.status_code = 201
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
resp.headers["Location"] = f"{request.base_url.rstrip('/')}/{obj.id}"
return resp
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.patch("/<int:obj_id>")
def rest_update(obj_id: int):
payload = _json_payload()
payload = request.get_json(silent=True) or {}
try:
obj = service.update(obj_id, payload)
return jsonify(obj.as_dict())
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.delete("/<int:obj_id>")
def rest_delete(obj_id: int):
hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
hard = (request.args.get("hard") in ("1", "true", "yes"))
try:
obj = service.delete(obj_id, hard=hard)
if obj is None:
abort(404)
return ("", 204)
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
# ---------- RPC (your existing routes) ----------
if rpc:
# your original functions verbatim, shortened here for sanity
@bp.get("/get")
def rpc_get():
print("⚠️ WARNING: Deprecated RPC call used: /get")
id_ = int(request.args.get("id", 0))
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
@ -144,52 +118,48 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
abort(404)
return jsonify(item.as_dict())
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.get("/list")
def rpc_list():
print("⚠️ WARNING: Deprecated RPC call used: /list")
args = _args_flat()
args = request.args.to_dict(flat=True)
try:
items = service.list(args)
return jsonify([obj.as_dict() for obj in items])
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.post("/create")
def rpc_create():
print("⚠️ WARNING: Deprecated RPC call used: /create")
payload = _json_payload()
payload = request.get_json(silent=True) or {}
try:
obj = service.create(payload)
return jsonify(obj.as_dict()), 201
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.patch("/update")
def rpc_update():
print("⚠️ WARNING: Deprecated RPC call used: /update")
id_ = int(request.args.get("id", 0))
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
payload = _json_payload()
payload = request.get_json(silent=True) or {}
try:
obj = service.update(id_, payload)
return jsonify(obj.as_dict())
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.delete("/delete")
def rpc_delete():
print("⚠️ WARNING: Deprecated RPC call used: /delete")
id_ = int(request.args.get("id", 0))
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
hard = (request.args.get("hard") in ("1", "true", "yes"))
try:
obj = service.delete(id_, hard=hard)
return ("", 204) if obj is not None else abort(404)
except Exception as e:
return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
return bp

View file

@ -1,66 +1,18 @@
from functools import lru_cache
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast
from typing import Any, Dict, Iterable, List, Tuple, Set
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper
from sqlalchemy.orm.state import InstanceState
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty
Base = declarative_base()
@lru_cache(maxsize=512)
def _column_names_for_model(cls: type) -> tuple[str, ...]:
try:
mapper = inspect(cls)
return tuple(prop.key for prop in mapper.column_attrs)
except Exception:
names: list[str] = []
for c in cls.__mro__:
if hasattr(c, "__table__"):
names.extend(col.name for col in c.__table__.columns)
return tuple(dict.fromkeys(names))
def _sa_state(obj: Any) -> Optional[InstanceState[Any]]:
"""Safely get SQLAlchemy InstanceState (or None)."""
try:
st = inspect(obj)
return cast(Optional[InstanceState[Any]], st)
except Exception:
return None
def _sa_mapper(obj: Any) -> Optional[Mapper]:
"""Safely get Mapper for a maooed instance (or None)."""
try:
st = inspect(obj)
mapper = getattr(st, "mapper", None)
return cast(Optional[Mapper], mapper)
except Exception:
return None
def _safe_get_loaded_attr(obj, name):
st = _sa_state(obj)
if st is None:
return None
try:
st_dict = getattr(st, "dict", {})
if name in st_dict:
return st_dict[name]
attrs = getattr(st, "attrs", None)
attr = None
if attrs is not None:
try:
attr = attrs[name]
except Exception:
try:
get = getattr(attrs, "get", None)
if callable(get):
attr = get(name)
except Exception:
attr = None
st = inspect(obj)
attr = st.attrs.get(name)
if attr is not None:
val = attr.loaded_value
return None if val is NO_VALUE else val
if name in st.dict:
return st.dict.get(name)
return None
except Exception:
return None
@ -81,11 +33,14 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool:
def _serialize_simple_obj(obj) -> Dict[str, Any]:
"""Columns only (no relationships)."""
out: Dict[str, Any] = {}
for name in _column_names_for_model(type(obj)):
try:
out[name] = getattr(obj, name)
except Exception:
out[name] = None
for cls in obj.__class__.__mro__:
if hasattr(cls, "__table__"):
for col in cls.__table__.columns:
name = col.name
try:
out[name] = getattr(obj, name)
except Exception:
out[name] = None
return out
def _serialize_loaded_rel(obj, name, *, depth: int, seen: Set[Tuple[type, Any]], embed: Set[str]) -> Any:
@ -249,16 +204,12 @@ class CRUDMixin:
# Determine which relationships to consider
try:
mapper = _sa_mapper(self)
embed_set = set(str(x).split(".", 1)[0] for x in (embed or []))
if mapper is None:
return data
st = _sa_state(self)
if st is None:
return data
st = inspect(self)
mapper = st.mapper
embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names
for name, prop in mapper.relationships.items():
# Only touch relationships that are already loaded; never lazy-load here.
rel_loaded = getattr(st, "attrs", {}).get(name)
rel_loaded = st.attrs.get(name)
if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE:
continue
@ -315,10 +266,13 @@ class CRUDMixin:
val = None
# If it's a scalar ORM object (relationship), serialize its columns
mapper = _sa_mapper(val)
if mapper is not None:
out[name] = _serialize_simple_obj(val)
continue
try:
st = inspect(val) # will raise if not an ORM object
if getattr(st, "mapper", None) is not None:
out[name] = _serialize_simple_obj(val)
continue
except Exception:
pass
# If it's a collection and no subfields were requested, emit a light list
if isinstance(val, (list, tuple)):

View file

@ -21,7 +21,6 @@ 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__)
@ -46,6 +45,7 @@ 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,12 +74,13 @@ 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):

View file

@ -12,6 +12,7 @@ _engine = None
SessionLocal = None
def init_db(database_url: str, engine_kwargs: Dict[str, Any], session_kwargs: Dict[str, Any]) -> None:
print("AM I EVEN BEING RUN?!")
global _engine, SessionLocal
print(database_url)
_engine = create_engine(database_url, **engine_kwargs)

View file

@ -26,7 +26,6 @@ def _fields_for_model(model: str):
"condition",
"notes",
"owner.id",
"image.filename",
]
fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label",
@ -283,12 +282,6 @@ def init_entry_routes(app):
f["label"] = ""
f["label_spec"] = "New User"
break
elif model == "room":
for f in fields_spec:
if f.get("name") == "label" and f.get("type") == "display":
f["label"] = ""
f["label_spec"] = "New Room"
break
obj = cls()
ScopedSession = current_app.extensions["crudkit"]["Session"]
@ -332,10 +325,6 @@ def init_entry_routes(app):
if "work_item" in payload and "work_item_id" not in payload:
payload["work_item_id"] = payload.pop("work_item")
if model == "room":
if "room_function_id" in payload and "function_id" not in payload:
payload["function_id"] = payload.pop("room_function_id")
# Parent first, no commit yet
obj = svc.create(payload, actor="create_entry", commit=False)
@ -433,6 +422,7 @@ def init_entry_routes(app):
return {"status": "success", "payload": payload}
except Exception as e:
print(e)
return {"status": "failure", "error": str(e)}
app.register_blueprint(bp_entry)

View file

@ -15,6 +15,7 @@ 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,
@ -25,6 +26,41 @@ 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",
@ -35,7 +71,7 @@ def init_index_routes(app):
logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"})
return render_template("index.html", logs=logs)
return render_template("index.html", logs=logs, chart_data=chart_data)
@bp_index.get("/LICENSE")
def license():

View file

@ -24,7 +24,7 @@ def init_listing_routes(app):
limit_qs = request.args.get("limit")
page = int(request.args.get("page", 1) or 1)
per_page = int(per_page_qs) if (per_page_qs and per_page_qs.isdigit()) else (
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 18
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 15
)
sort = request.args.get("sort")
fields_qs = request.args.get("fields")
@ -54,13 +54,12 @@ def init_listing_routes(app):
elif model.lower() == 'user':
spec = {"fields": [
"label", "last_name", "first_name", "supervisor.label",
"robot.overlord", "staff", "active", "title",
"robot.overlord", "staff", "active",
], "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"},

View file

@ -1,95 +1,142 @@
from flask import Blueprint, render_template, url_for
from urllib.parse import urlencode
import math
import pandas as pd
import crudkit
from flask import Blueprint, request, jsonify
from sqlalchemy import select, func, case
bp_reports = Blueprint("reports", __name__)
from ..db import get_session
from ..models.inventory import Inventory
from ..models.device_type import DeviceType
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 = Blueprint("reports", __name__, url_prefix="/api/reports")
rows = inventory_service.list({
"limit": 0,
"sort": "device_type.description",
"fields": ["id", "condition", "device_type.description"],
@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
})
df = pd.DataFrame([r.as_dict() for r in rows])
items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True)
return jsonify({"items": items[:top_n]})
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,
@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)
)
# 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]
# 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))
# Drop zero-only rows
pt = pt.loc[(pt != 0).any(axis=1)]
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))
# 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)
rows = session.execute(stmt).all()
return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows])

View file

@ -1,4 +0,0 @@
.inventory-dropdown {
border-color: rgb(222, 226, 230) !important;
overflow-y: auto;
}

View file

@ -1,39 +1,6 @@
const ComboBox = globalThis.ComboBox ?? (globalThis.ComboBox = {});
ComboBox.utilities = {
sortList(id) {
const select = document.getElementById(id);
if (!select) return;
const prevScroll = select.scrollTop;
const prevValue = select.value;
const options = Array.from(select.options);
let placeholder = null;
if (options.length && (
options[0].value === '' ||
options[0].disabled && (options[0].hidden || options[0].getAttribute('aria-hidden') === 'true')
)) {
placeholder = options.shift();
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
options.sort((a, b) => collator.compare(a.textContent.trim(), b.textContent.trim()));
select.innerHTML = '';
if (placeholder) select.appendChild(placeholder);
for (const opt of options) select.appendChild(opt);
if (prevValue) {
select.value = prevValue;
if (select.value !== prevValue) {
select.selectedIndex = placeholder ? 0 : -1;
}
}
select.scrollTop = prevScroll;
},
changeAdd(id) {
const input = document.getElementById(`input-${id}`);
const add = document.getElementById(`add-${id}`);
@ -98,7 +65,6 @@ ComboBox.utilities = {
opt.value = data.id;
opt.textContent = data[labelAttr] ?? val;
list.appendChild(opt);
this.sortList(id);
input.value = '';
},
@ -115,9 +81,8 @@ ComboBox.utilities = {
if (res.ok) {
selected.remove();
this.sortList(id);
toastMessage(`Deleted ${id} successfully.`, 'success');
this.handleSelect(id);
this.changeRemove(id);
return;
}
@ -141,7 +106,7 @@ ComboBox.utilities = {
const opt = list?.selectedOptions?.[0];
if (!opt) return;
const val = opt.value;
const val = opt.value; // id of the row
const newText = (input?.value ?? '').trim();
if (!newText) return;
@ -168,6 +133,7 @@ ComboBox.utilities = {
return;
}
// not ok -> try to show the server error
try { data = await res.json(); } catch { data = { error: await res.text() }; }
toastMessage(`Edit failed: ${data?.error || `HTTP ${res.status}`}`, 'danger');
} catch (e) {

View file

@ -1,95 +0,0 @@
const DropDown = globalThis.DropDown ?? (globalThis.DropDown = {});
DropDown.utilities = {
filterList(id) {
value = document.getElementById(`${id}-filter`).value;
list = document.querySelectorAll(`#${id}-dropdown li`);
list.forEach(item => {
const txt = item.textContent.toLowerCase();
if (txt.includes(value)) {
item.style.display = 'list-item';
} else {
item.style.display = 'none';
};
});
},
selectItem(id, value) {
const btn = document.getElementById(`${id}-button`);
const txt = document.getElementById(`${id}-${value}`).textContent;
const inp = document.getElementById(id);
btn.dataset.value = value;
btn.textContent = txt;
inp.value = value;
},
};
(() => {
const VISIBLE_ITEMS = 10;
function setMenuMaxHeight(buttonEl) {
const menu = buttonEl?.nextElementSibling;
if (!menu || !menu.classList.contains('dropdown-menu')) return;
const input = menu.querySelector('input.form-control');
const firstItem = menu.querySelector('.dropdown-item');
if (!firstItem) return;
// Measure even if the menu is closed
const computed = getComputedStyle(menu);
const wasHidden = computed.display === 'none' || computed.visibility === 'hidden';
if (wasHidden) {
menu.style.visibility = 'hidden';
menu.style.display = 'block';
}
const inputH = input ? input.getBoundingClientRect().height : 0;
const itemH = firstItem.getBoundingClientRect().height || 0;
const itemCount = Math.min(
VISIBLE_ITEMS,
menu.querySelectorAll('.dropdown-item').length
);
const target = Math.ceil(inputH + itemH * itemCount);
menu.style.maxHeight = `${target + 10}px`;
menu.style.overflowY = 'auto';
if (wasHidden) {
menu.style.display = '';
menu.style.visibility = '';
}
}
function onShow(e) {
setMenuMaxHeight(e.target);
}
function onResize() {
document.querySelectorAll('.dropdown-toggle[data-bs-toggle="dropdown"]').forEach(btn => {
const menu = btn.nextElementSibling;
if (menu && menu.classList.contains('dropdown-menu') && menu.classList.contains('show')) {
setMenuMaxHeight(btn);
}
});
}
function init(root = document) {
// Delegate so dynamically-added dropdowns work too
root.addEventListener('show.bs.dropdown', onShow);
window.addEventListener('resize', onResize);
}
// Expose for manyal calls or tests
DropDown.utilities.setMenuMaxHeight = setMenuMaxHeight;
DropDown.init = init;
// Auto-init
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init());
} else {
init();
}
})();

View file

@ -1,36 +0,0 @@
const MarkDown = {
parseOptions: { gfm: true, breaks: false },
sanitizeOptions: { ADD_ATTR: ['target', 'rel'] },
toHTML(md) {
const raw = marked.parse(md || "", this.parseOptions);
return DOMPurify.sanitize(raw, this.sanitizeOptions);
},
enhance(root) {
if (!root) return;
for (const a of root.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
for (const q of root.querySelectorAll('blockquote')) {
q.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
}
for (const l of root.querySelectorAll('ul')) {
l.classList.add('list-group');
}
for (const l of root.querySelectorAll('li')) {
l.classList.add('list-group-item');
}
},
renderInto(el, md) {
if (!el) return;
el.innerHTML = this.toHTML(md);
this.enhance(el);
}
};

View file

@ -1,7 +0,0 @@
function readJSONScript(id, fallback = "") {
const el = document.getElementById(id);
if (!el) return fallback;
const txt = el.textContent?.trim();
if (!txt) return fallback;
try { return JSON.parse(txt); } catch { return fallback; }
}

View file

@ -36,12 +36,6 @@
<a href="{{ url_for('listing.show_list', model='user') }}"
class="nav-link link-success fw-semibold">Users</a>
</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>
{% block header %}
{% endblock %}
@ -49,13 +43,6 @@
<input type="text" id="searchText" class="form-control me-3">
<button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button>
</div>
<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">
<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>
</a>
</div>
</nav>
</header>
@ -139,7 +126,7 @@
const list = JSON.parse(sessionStorage.getItem(key) || '[]');
list.push({ message, type });
sessionStorage.setItem(key, JSON.stringify(list));
} catch { }
} catch {}
};
{% block script %}
@ -154,7 +141,7 @@
for (const t of list) {
window.toastMessage(t.message, t.type || 'info');
}
} catch { }
} catch {}
});
</script>
</body>

View file

@ -2,7 +2,7 @@
{% macro combobox(id, name, placeholder, items, value_attr='id', label_attr='name',
add_disabled=True, remove_disabled=True, edit_disabled=True) %}
<div class="combobox">
<div class="col combobox">
<div class="d-flex">
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
placeholder="{{ placeholder }}" id="input-{{id}}" oninput="ComboBox.utilities.changeAdd('{{ id }}');">

View file

@ -1,88 +1,83 @@
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
{# show label unless hidden/custom #}
{% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true
%}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{% if link_href %}
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %}
{{ field_label }}
<label for="{{ field_name }}"
{% if label_attrs %}{% for k,v in label_attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% if link_href %}
</a>
{% endif %}
</label>
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %}
{{ field_label }}
{% if link_href %}
</a>
{% endif %}
</label>
{% endif %}
{% if field_type == 'select' %}
{#
<select name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %} {%- if not options %} disabled{% endif %}>
{% if options %}
<option value="">-- Select --</option>
{% for opt in options %}
<option value="{{ opt.value }}" {% if opt.value|string==value|string %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
{% else %}
<option value="">-- No selection available --</option>
{% endif %}
</select>
#}
{% if options %}
{% if value %}
{% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %}
{% else %}
{% set sel_label = "-- Select --" %}
{% endif %}
<button type="button" class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}">{{ sel_label }}</button>
<div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown">
<input type="text" class="form-control mt-0 border-top-0 border-start-0 border-end-0 rounded-bottom-0"
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
{% for opt in options %}
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')" id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
{% endfor %}
</div>
{% else %}
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No selection available --</button>
{% endif %}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value }}">
<select name="{{ field_name }}" id="{{ field_name }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}
{%- if not options %} disabled{% endif %}>
{% if options %}
<option value="">-- Select --</option>
{% for opt in options %}
<option value="{{ opt.value }}" {% if opt.value|string == value|string %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
{% else %}
<option value="">-- No selection available --</option>
{% endif %}
</select>
{% elif field_type == 'textarea' %}
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
<textarea name="{{ field_name }}" id="{{ field_name }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
{% elif field_type == 'checkbox' %}
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" {% if value %}checked{% endif %} {% if
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
%}>
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1"
{% if value %}checked{% endif %}
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == 'hidden' %}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}">
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}">
{% elif field_type == 'display' %}
<div {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor
%}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
<div {% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
{% elif field_type == "date" %}
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "time" %}
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "datetime" %}
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
%}>
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% else %}
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% endif %}
{% if help %}
<div class="form-text">{{ help }}</div>
{% endif %}
<div class="form-text">{{ help }}</div>
{% endif %}

View file

@ -1,5 +1,5 @@
<div class="table-responsive" style="max-height: 80vh;">
<table class="table table-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
<table class="table table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
<thead>
<tr>
{% for col in columns %}

View file

@ -1,16 +1,8 @@
{% extends 'base.html' %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/dropdown.css') }}">
{% endblock %}
{% block main %}
<div class="container mt-5">
{{ form | safe }}
</div>
{% endblock %}
{% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/dropdown.js') }}" defer></script>
{% endblock %}
{% endblock %}

View file

@ -1,5 +1,15 @@
{% 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 %}
<h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1>
<p class="lead text-center">Find out about all of your assets.</p>
@ -9,5 +19,256 @@
<p class="display-6 text-center">Active Worklogs</p>
{{ logs | safe }}
</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>
{% 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

@ -28,76 +28,86 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
});
document.addEventListener('DOMContentLoaded', () => {
renderView(getMarkdown());
});
// used by entry_buttons submit
window.getMarkdown = function () {
return readJSONScript('noteContent', "");
};
function setMarkdown(md) {
const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? "");
}
function changeMode() {
const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch');
if (!toggle.checked) {
MarkDown.renderInto(container, getMarkdown());
return;
function getMarkdown() {
const el = document.getElementById('noteContent');
return el ? (JSON.parse(el.textContent) || "") : "";
}
const current = getMarkdown();
container.innerHTML = `
<textarea class="form-control w-100 auto-md" id="editor" name="notes">${escapeForTextarea(current)}</textarea>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="saveEdit()">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview()">Preview</button>
</div>
<div class="mt-2 border rounded p-2 d-none" id="preview" aria-live="polite"></div>
`;
const ta = document.getElementById('editor');
autoGrow(ta);
ta.addEventListener('input', () => autoGrow(ta));
}
function saveEdit() {
const ta = document.getElementById('editor');
const value = ta ? ta.value : "";
setMarkdown(value);
MarkDown.renderInto(document.getElementById('editContainer'), value);
document.getElementById('editSwitch').checked = false;
}
function cancelEdit() {
document.getElementById('editSwitch').checked = false;
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
}
function togglePreview() {
const ta = document.getElementById('editor');
const preview = document.getElementById('preview');
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
MarkDown.renderInto(preview, ta ? ta.value : "");
function setMarkdown(md) {
const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? "");
}
}
function autoGrow(ta) {
if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
function renderView(md) {
const container = document.getElementById('editContainer');
if (!container) return;
const html = marked.parse(md || "", {gfm: true});
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] });
for (const a of container.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
for (const t of container.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}
function escapeForTextarea(s) {
return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
function changeMode() {
const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch');
if (!toggle.checked) return renderView(getMarkdown());
const current = getMarkdown();
container.innerHTML = `
<textarea class="form-control w-100 auto-md" id="editor" name="notes">${escapeForTextarea(current)}</textarea>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="saveEdit()">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview()">Preview</button>
</div>
<div class="mt-2 border rounded p-2 d-none" id="preview" aria-live="polite"></div>
`;
const ta = document.getElementById('editor');
autoGrow(ta);
ta.addEventListener('input', () => autoGrow(ta));
}
function saveEdit() {
const ta = document.getElementById('editor');
const value = ta ? ta.value : "";
setMarkdown(value);
renderView(value);
document.getElementById('editSwitch').checked = false;
}
function cancelEdit() {
document.getElementById('editSwitch').checked = false;
renderView(getMarkdown());
}
function togglePreview() {
const ta = document.getElementById('editor');
const preview = document.getElementById('preview');
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
const html = marked.parse(ta ? ta.value : "");
preview.innerHTML = DOMPurify.sanitize(html);
}
}
function autoGrow(ta) {
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
function escapeForTextarea(s) {
return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>

View file

@ -6,13 +6,10 @@ Inventory Manager - {{ model|title }} Listing
{% block main %}
<div class="mx-5">
<div class="d-flex justify-content-between">
<div class="btn-group h-50 align-self-end">
<button type="button" class="btn btn-primary mb-2"
onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button>
</div>
<h1 class="display-6 text-center">{{ model|title }} Listing</h1>
<div></div>
<h1 class="display-4 text-center mt-2">{{ model|title }} Listing</h1>
<div class="btn-group">
<button type="button" class="btn btn-primary mb-3"
onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button>
</div>
{{ table | safe }}

View file

@ -23,34 +23,18 @@
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
<div class="row">
<div class="col">
<label for="brand" class="form-label">Brand</label>
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
</div>
<div class="col">
<label for="devicetype" class="form-label">Device Type</label>
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
</div>
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
</div>
</div>
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
<div class="row">
<div class="col">
<label for="area" class="form-label">Area</label>
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
</div>
<div class="col">
<label for="roomfunction" class="form-label">Description</label>
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
</div>
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
</div>
<div class="row mt-3">
<label for="rooms" class="form-label">
Rooms
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label>
<div class="col">
{{ rooms | safe }}
</div>

View file

@ -1,40 +0,0 @@
{% 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

@ -16,7 +16,7 @@
<ul class="list-group mt-3">
{% for n in items %}
<li class="list-group-item" id="note-{{ n.id }}">
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
@ -63,15 +63,44 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script>
// State (kept global for compatibility with your form serialization)
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
// ---------- DRY UTILITIES ----------
function renderMarkdown(md) {
// One place to parse + sanitize
const raw = marked.parse(md || "");
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
}
function enhanceLinks(root) {
if (!root) return;
for (const a of root.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
}
function enhanceTables(root) {
if (!root) return;
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}
function renderHTML(el, md) {
if (!el) return;
el.innerHTML = renderMarkdown(md);
enhanceLinks(el);
enhanceTables(el);
}
function getMarkdown(id) {
return readJSONScript(`md-${id}`, "");
const el = document.getElementById(`md-${id}`);
return el ? JSON.parse(el.textContent || '""') : "";
}
function setMarkdown(id, md) {
@ -85,14 +114,14 @@
function autoGrow(ta) {
if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto';
ta.style.height = (ta.scrollHeight + 5) + 'px';
}
// ---------- RENDERERS ----------
function renderExistingView(id) {
MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
const container = document.getElementById(`editContainer${id}`);
renderHTML(container, getMarkdown(id));
}
function renderEditor(id) {
@ -130,7 +159,8 @@
const left = document.createElement('div');
left.className = 'w-100 markdown-body';
MarkDown.renderInto(left, md || '');
left.innerHTML = renderMarkdown(md || '');
enhanceLinks(left);
const right = document.createElement('div');
right.className = 'ms-3 d-flex flex-column align-items-end';
@ -233,7 +263,7 @@
if (!preview) return;
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
MarkDown.renderInto(preview, ta ? ta.value : "");
preview.innerHTML = renderMarkdown(ta ? ta.value : "");
}
}

View file

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