Compare commits
18 commits
5234cbdd61
...
24e49341e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24e49341e8 | ||
|
|
c3ad0fba84 | ||
|
|
d0551d52a1 | ||
|
|
4ef4d5e23f | ||
|
|
8481a40553 | ||
|
|
dc3482f887 | ||
|
|
2845d340da | ||
|
|
4c8a8d4ac7 | ||
|
|
c20d085ab5 | ||
|
|
acefd96958 | ||
|
|
f9d950c425 | ||
|
|
db287fb8ac | ||
|
|
11998b6b31 | ||
|
|
f249a935d5 | ||
|
|
46b3e2600f | ||
|
|
3a2a8a06d9 | ||
|
|
c31da91716 | ||
|
|
51520da5af |
33 changed files with 1297 additions and 252 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
inventory/static/uploads/*
|
inventory/static/uploads/*
|
||||||
!inventory/static/uploads/.gitkeep
|
!inventory/static/uploads/.gitkeep
|
||||||
.venv/
|
.venv/
|
||||||
|
.vscode/
|
||||||
.env
|
.env
|
||||||
*.db*
|
*.db*
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,8 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
|
||||||
DELETE /api/<model>/delete?id=123[&hard=1]
|
DELETE /api/<model>/delete?id=123[&hard=1]
|
||||||
"""
|
"""
|
||||||
model_name = model.__name__.lower()
|
model_name = model.__name__.lower()
|
||||||
# bikeshed if you want pluralization; this is the least-annoying default
|
|
||||||
collection = (base_prefix or model_name).lower()
|
|
||||||
plural = collection if collection.endswith('s') else f"{collection}s"
|
|
||||||
|
|
||||||
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
|
bp = Blueprint(model_name, __name__, url_prefix=f"/api/{model_name}")
|
||||||
|
|
||||||
@bp.errorhandler(Exception)
|
@bp.errorhandler(Exception)
|
||||||
def _handle_any(e: Exception):
|
def _handle_any(e: Exception):
|
||||||
|
|
@ -105,7 +102,7 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
|
||||||
obj = service.create(payload)
|
obj = service.create(payload)
|
||||||
resp = jsonify(obj.as_dict())
|
resp = jsonify(obj.as_dict())
|
||||||
resp.status_code = 201
|
resp.status_code = 201
|
||||||
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
|
resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False)
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _json_error(e)
|
return _json_error(e)
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,8 @@ class CRUDService(Generic[T]):
|
||||||
opt = opt.load_only(*cols)
|
opt = opt.load_only(*cols)
|
||||||
query = query.options(opt)
|
query = query.options(opt)
|
||||||
|
|
||||||
|
# inside CRUDService._apply_firsthop_strategies
|
||||||
|
# ...
|
||||||
# NEW: if a first-hop to-one relationship’s target table is present in filter expressions,
|
# NEW: if a first-hop to-one relationship’s target table is present in filter expressions,
|
||||||
# make sure we actually JOIN it (outer) so filters don’t create a cartesian product.
|
# make sure we actually JOIN it (outer) so filters don’t create a cartesian product.
|
||||||
if plan.filter_tables:
|
if plan.filter_tables:
|
||||||
|
|
@ -422,14 +424,19 @@ class CRUDService(Generic[T]):
|
||||||
for rel in mapper.relationships:
|
for rel in mapper.relationships:
|
||||||
if rel.uselist:
|
if rel.uselist:
|
||||||
continue # only first-hop to-one here
|
continue # only first-hop to-one here
|
||||||
target_tbl = getattr(rel.mapper.class_, "__table__", None)
|
target_cls = rel.mapper.class_
|
||||||
|
target_tbl = getattr(target_cls, "__table__", None)
|
||||||
if target_tbl is None:
|
if target_tbl is None:
|
||||||
continue
|
continue
|
||||||
if target_tbl in plan.filter_tables:
|
if target_tbl in plan.filter_tables:
|
||||||
if rel.key in joined_rel_keys:
|
if rel.key in joined_rel_keys:
|
||||||
continue # already joined via join_paths
|
continue # already joined via join_paths
|
||||||
query = query.join(getattr(root_alias, rel.key), isouter=True)
|
|
||||||
|
# alias when joining same-entity relationships (User->User supervisor)
|
||||||
|
ta = aliased(target_cls) if target_cls is self.model else target_cls
|
||||||
|
query = query.join(getattr(root_alias, rel.key).of_type(ta), isouter=True)
|
||||||
joined_rel_keys.add(rel.key)
|
joined_rel_keys.add(rel.key)
|
||||||
|
|
||||||
if log.isEnabledFor(logging.DEBUG):
|
if log.isEnabledFor(logging.DEBUG):
|
||||||
info = []
|
info = []
|
||||||
for base_alias, rel_attr, target_alias in plan.join_paths:
|
for base_alias, rel_attr, target_alias in plan.join_paths:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ from sqlalchemy.orm.base import NO_VALUE
|
||||||
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
|
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import crudkit
|
||||||
|
|
||||||
_ALLOWED_ATTRS = {
|
_ALLOWED_ATTRS = {
|
||||||
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
||||||
"min", "max", "step", "maxlength", "minlength",
|
"min", "max", "step", "maxlength", "minlength",
|
||||||
|
|
@ -107,6 +109,53 @@ def register_template_globals(app=None):
|
||||||
app.add_template_global(fn, name)
|
app.add_template_global(fn, name)
|
||||||
installed.add(name)
|
installed.add(name)
|
||||||
|
|
||||||
|
def _fields_for_label_params(label_spec, related_model):
|
||||||
|
"""
|
||||||
|
Build a 'fields' list suitable for CRUDService.list() so labels render
|
||||||
|
without triggering lazy loads. Always includes 'id'.
|
||||||
|
"""
|
||||||
|
simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model)
|
||||||
|
fields = set(["id"])
|
||||||
|
for c in simple_cols:
|
||||||
|
fields.add(c)
|
||||||
|
for rel_name, col_name in rel_paths:
|
||||||
|
if col_name == "__all__":
|
||||||
|
# just ensure relationship object is present; ask for rel.id
|
||||||
|
fields.add(f"{rel_name}.id")
|
||||||
|
else:
|
||||||
|
fields.add(f"{rel_name}.{col_name}")
|
||||||
|
return list(fields)
|
||||||
|
|
||||||
|
def _fk_options_via_service(related_model, label_spec, *, options_params: dict | None = None):
|
||||||
|
svc = crudkit.crud.get_service(related_model)
|
||||||
|
|
||||||
|
# default to unlimited results for dropdowns
|
||||||
|
params = {"limit": 0}
|
||||||
|
if options_params:
|
||||||
|
params.update(options_params) # caller can override limit if needed
|
||||||
|
|
||||||
|
# ensure fields needed to render the label are present (avoid lazy loads)
|
||||||
|
fields = _fields_for_label_params(label_spec, related_model)
|
||||||
|
if fields:
|
||||||
|
existing = params.get("fields")
|
||||||
|
if isinstance(existing, str):
|
||||||
|
existing = [s.strip() for s in existing.split(",") if s.strip()]
|
||||||
|
if isinstance(existing, (list, tuple)):
|
||||||
|
params["fields"] = list(dict.fromkeys(list(existing) + fields))
|
||||||
|
else:
|
||||||
|
params["fields"] = fields
|
||||||
|
|
||||||
|
# only set a default sort if caller didn’t supply one
|
||||||
|
if "sort" not in params:
|
||||||
|
simple_cols, _ = _extract_label_requirements(label_spec, related_model)
|
||||||
|
params["sort"] = (simple_cols[0] if simple_cols else "id")
|
||||||
|
|
||||||
|
rows = svc.list(params)
|
||||||
|
return [
|
||||||
|
{"value": str(r.id), "label": _label_from_obj(r, label_spec)}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
def expand_projection(model_cls, fields):
|
def expand_projection(model_cls, fields):
|
||||||
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
||||||
out = set(fields)
|
out = set(fields)
|
||||||
|
|
@ -647,10 +696,12 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
|
||||||
if "label_deps" in spec:
|
if "label_deps" in spec:
|
||||||
field["label_deps"] = spec["label_deps"]
|
field["label_deps"] = spec["label_deps"]
|
||||||
|
|
||||||
|
opts_params = spec.get("options_params") or spec.get("options_filter") or spec.get("options_where")
|
||||||
|
|
||||||
if rel_prop:
|
if rel_prop:
|
||||||
if field["type"] is None:
|
if field["type"] is None:
|
||||||
field["type"] = "select"
|
field["type"] = "select"
|
||||||
if field["type"] == "select" and field.get("options") is None and session is not None:
|
if field["type"] == "select" and field.get("options") is None:
|
||||||
related_model = rel_prop.mapper.class_
|
related_model = rel_prop.mapper.class_
|
||||||
label_spec = (
|
label_spec = (
|
||||||
spec.get("label_spec")
|
spec.get("label_spec")
|
||||||
|
|
@ -658,7 +709,11 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
|
||||||
or getattr(related_model, "__crud_label__", None)
|
or getattr(related_model, "__crud_label__", None)
|
||||||
or "id"
|
or "id"
|
||||||
)
|
)
|
||||||
field["options"] = _fk_options(session, related_model, label_spec)
|
field["options"] = _fk_options_via_service(
|
||||||
|
related_model,
|
||||||
|
label_spec,
|
||||||
|
options_params=opts_params
|
||||||
|
)
|
||||||
return field
|
return field
|
||||||
|
|
||||||
col = mapper.columns.get(name)
|
col = mapper.columns.get(name)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{# show label unless hidden/custom #}
|
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
|
||||||
{% if field_type != 'hidden' and field_label %}
|
{% if field_type != 'hidden' and field_label %}
|
||||||
<label for="{{ field_name }}"
|
<label for="{{ field_name }}"
|
||||||
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
<!-- FORM: {{ model_name|lower }} -->
|
||||||
<form method="POST" id="{{ model_name|lower }}_form">
|
<form method="POST" id="{{ model_name|lower }}_form">
|
||||||
{% macro render_row(row) %}
|
{% macro render_row(row) %}
|
||||||
|
<!-- ROW: {{ row['name'] }} -->
|
||||||
{% if row.fields or row.children or row.legend %}
|
{% if row.fields or row.children or row.legend %}
|
||||||
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
|
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
|
||||||
<fieldset
|
<fieldset
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os, logging, sys
|
import os, logging, sys
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask, render_template, request, current_app
|
||||||
from jinja_markdown import MarkdownExtension
|
from jinja_markdown import MarkdownExtension
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from sqlalchemy.pool import Pool
|
from sqlalchemy.pool import Pool
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
from werkzeug.middleware.profiler import ProfilerMiddleware
|
from werkzeug.middleware.profiler import ProfilerMiddleware
|
||||||
|
|
||||||
import crudkit
|
import crudkit
|
||||||
|
|
@ -42,6 +43,27 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}, 500
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
@app.errorhandler(HTTPException)
|
||||||
|
def handle_http(e: HTTPException):
|
||||||
|
code = e.code
|
||||||
|
if request.accept_mimetypes.best == 'application/json':
|
||||||
|
return {
|
||||||
|
"type": "about:blank",
|
||||||
|
"title": e.name,
|
||||||
|
"status": code,
|
||||||
|
"detail": e.description
|
||||||
|
}, code
|
||||||
|
return render_template('errors/default.html', code=code, name=e.name, description=e.description), code
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_uncaught(e: Exception):
|
||||||
|
current_app.logger.exception("Unhandled exception")
|
||||||
|
|
||||||
|
if request.accept_mimetypes.best == 'application/json':
|
||||||
|
return {"title": "Internal Server Error", "status": 500}, 500
|
||||||
|
|
||||||
|
return render_template("errors/500.html"), 500
|
||||||
|
|
||||||
crudkit.init_crud(app)
|
crudkit.init_crud(app)
|
||||||
print(f"Effective DB URL: {str(runtime.engine.url)}")
|
print(f"Effective DB URL: {str(runtime.engine.url)}")
|
||||||
|
|
||||||
|
|
@ -69,6 +91,7 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
_models.Inventory,
|
_models.Inventory,
|
||||||
_models.RoomFunction,
|
_models.RoomFunction,
|
||||||
_models.Room,
|
_models.Room,
|
||||||
|
_models.Status,
|
||||||
_models.User,
|
_models.User,
|
||||||
_models.WorkLog,
|
_models.WorkLog,
|
||||||
_models.WorkNote,
|
_models.WorkNote,
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ from .image import Image
|
||||||
from .inventory import Inventory
|
from .inventory import Inventory
|
||||||
from .room_function import RoomFunction
|
from .room_function import RoomFunction
|
||||||
from .room import Room
|
from .room import Room
|
||||||
|
from .status import Status
|
||||||
from .user import User
|
from .user import User
|
||||||
from .work_log import WorkLog
|
from .work_log import WorkLog
|
||||||
from .work_note import WorkNote
|
from .work_note import WorkNote
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Area", "Brand", "DeviceType", "Image", "Inventory",
|
"Area", "Brand", "DeviceType", "Image", "Inventory",
|
||||||
"RoomFunction", "Room", "User", "WorkLog", "WorkNote",
|
"RoomFunction", "Room", "Status", "User", "WorkLog", "WorkNote",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Unicode
|
from sqlalchemy import Boolean, Integer, Unicode
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ class DeviceType(Base, CRUDMixin):
|
||||||
__tablename__ = 'item'
|
__tablename__ = 'item'
|
||||||
|
|
||||||
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
|
||||||
|
target: Mapped[int] = mapped_column(Integer, nullable=True, default=0)
|
||||||
|
|
||||||
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type')
|
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ class Inventory(Base, CRUDMixin):
|
||||||
name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
|
name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
|
||||||
serial: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
|
serial: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
|
||||||
|
|
||||||
condition: Mapped[str] = mapped_column(Unicode(255))
|
condition: Mapped[Optional['Status']] = relationship('Status', back_populates='inventory')
|
||||||
|
condition_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('status.id'), nullable=True, index=True)
|
||||||
|
|
||||||
model: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
model: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||||
notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||||
shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
|
shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
|
||||||
|
|
|
||||||
33
inventory/models/status.py
Normal file
33
inventory/models/status.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy import Boolean, Enum as SAEnum, Unicode
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.sql import expression as sql
|
||||||
|
|
||||||
|
from crudkit.core.base import Base, CRUDMixin
|
||||||
|
|
||||||
|
class StatusCategory(str, enum.Enum):
|
||||||
|
ACTIVE = "Active"
|
||||||
|
AVAILABLE = "Available"
|
||||||
|
PENDING = "Pending"
|
||||||
|
FAULTED = "Faulted"
|
||||||
|
DECOMMISSIONED = "Decommissioned"
|
||||||
|
DISPOSED = "Disposed"
|
||||||
|
ADMINISTRATIVE = "Administrative"
|
||||||
|
|
||||||
|
status_type = SAEnum(
|
||||||
|
StatusCategory,
|
||||||
|
name="status_category_enum",
|
||||||
|
validate_strings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Status(Base, CRUDMixin):
|
||||||
|
__tablename__ = "status"
|
||||||
|
|
||||||
|
description: Mapped[str] = mapped_column(Unicode(255), nullable=False, index=True, unique=True)
|
||||||
|
category: Mapped[StatusCategory] = mapped_column(status_type, nullable=False)
|
||||||
|
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
|
||||||
|
|
||||||
|
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='condition')
|
||||||
|
|
@ -17,7 +17,8 @@ class WorkLog(Base, CRUDMixin):
|
||||||
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
|
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
|
||||||
contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
|
||||||
updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan', order_by='WorkNote.timestamp.desc()')
|
updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan',
|
||||||
|
order_by="desc(WorkNote.timestamp), desc(WorkNote.id)", lazy="selectin")
|
||||||
|
|
||||||
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
|
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
|
||||||
work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('inventory.id'), nullable=True, index=True)
|
work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('inventory.id'), nullable=True, index=True)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from crudkit.core import normalize_payload
|
||||||
|
|
||||||
bp_entry = Blueprint("entry", __name__)
|
bp_entry = Blueprint("entry", __name__)
|
||||||
|
|
||||||
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"]
|
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room", "status"]
|
||||||
|
|
||||||
def _fields_for_model(model: str):
|
def _fields_for_model(model: str):
|
||||||
fields: list[str] = []
|
fields: list[str] = []
|
||||||
|
|
@ -33,35 +33,27 @@ def _fields_for_model(model: str):
|
||||||
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
|
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
|
||||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||||
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"},
|
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
"attrs": {"class": "form-control", "placeholder": "Device Name"}, "label_attrs": {"class": "ms-2"}},
|
||||||
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"},
|
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
"attrs": {"class": "form-control", "placeholder": "Serial Number"}, "label_attrs": {"class": "ms-2"}},
|
||||||
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"},
|
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
"attrs": {"class": "form-control", "placeholder": "Bar Code"}, "label_attrs": {"class": "ms-2"}},
|
||||||
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"},
|
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}},
|
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "ms-2"}},
|
||||||
{"name": "model", "row": "device", "wrap": {"class": "col"},
|
{"name": "model", "row": "device", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label": "Model #", "label_attrs": {"class": "form-label"}},
|
"attrs": {"class": "form-control", "placeholder": "Model Number"}, "label": "Model #", "label_attrs": {"class": "ms-2"}},
|
||||||
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"},
|
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}},
|
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "ms-2"}},
|
||||||
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"},
|
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
|
||||||
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}},
|
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}},
|
||||||
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"},
|
"options_params": {"active__eq": True}},
|
||||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col form-floating"},
|
||||||
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"},
|
||||||
"label_spec": "{name} - {room_function.description}"},
|
"label_spec": "{name} - {room_function.description}"},
|
||||||
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col"},
|
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col form-floating"},
|
||||||
"type": "select", "options": [
|
"label_attrs": {"class": "ms-2"}, "label_spec": "{description}"},
|
||||||
{"label": "Deployed", "value": "Deployed"},
|
|
||||||
{"label": "Working", "value": "Working"},
|
|
||||||
{"label": "Unverified", "value": "Unverified"},
|
|
||||||
{"label": "Partially Inoperable", "value": "Partially Inoperable"},
|
|
||||||
{"label": "Inoperable", "value": "Inoperable"},
|
|
||||||
{"label": "Removed", "value": "Removed"},
|
|
||||||
{"label": "Disposed", "value": "Disposed"},
|
|
||||||
],
|
|
||||||
"label_attrs": {"class": "form-label"}, "attrs": {"class": "form-control"}},
|
|
||||||
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
|
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
|
||||||
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
|
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
|
||||||
"wrap": {"class": "h-100 w-100"}},
|
"wrap": {"class": "h-100 w-100"}},
|
||||||
|
|
@ -89,30 +81,44 @@ def _fields_for_model(model: str):
|
||||||
"title",
|
"title",
|
||||||
"active",
|
"active",
|
||||||
"staff",
|
"staff",
|
||||||
"supervisor.id"
|
"supervisor.id",
|
||||||
|
"inventory.label",
|
||||||
|
"inventory.brand.name",
|
||||||
|
"inventory.model",
|
||||||
|
"inventory.device_type.description",
|
||||||
|
"inventory.condition.category",
|
||||||
|
"work_logs.work_item",
|
||||||
|
"work_logs.start_time",
|
||||||
|
"work_logs.end_time",
|
||||||
|
"work_logs.complete",
|
||||||
]
|
]
|
||||||
fields_spec = [
|
fields_spec = [
|
||||||
{"name": "label", "row": "label", "label": "", "type": "display",
|
{"name": "label", "row": "label", "label": "", "type": "display",
|
||||||
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
|
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
|
||||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||||
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"},
|
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "ms-2"},
|
||||||
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
|
||||||
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"},
|
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "ms-2"},
|
||||||
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
|
||||||
{"name": "title", "label": "Title", "label_attrs": {"class": "form-label"},
|
{"name": "title", "label": "Title", "label_attrs": {"class": "ms-2"},
|
||||||
"attrs": {"placeholder": "President of the Universe", "class": "form-control"},
|
"attrs": {"placeholder": "President of the Universe", "class": "form-control"},
|
||||||
"row": "name", "wrap": {"class": "col-3"}},
|
"row": "name", "wrap": {"class": "col form-floating"}},
|
||||||
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
|
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "ms-2 link-label"},
|
||||||
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
|
"label_spec": "{label}", "row": "details", "wrap": {"class": "col form-floating"},
|
||||||
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}},
|
||||||
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
|
"options_params": {"active__eq": True, "staff__eq": True}},
|
||||||
|
{"name": "location", "label": "Room", "label_attrs": {"class": "ms-2"},
|
||||||
"label_spec": "{name} - {room_function.description}",
|
"label_spec": "{name} - {room_function.description}",
|
||||||
"row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}},
|
"row": "details", "wrap": {"class": "col form-floating"}, "attrs": {"class": "form-control"}},
|
||||||
{"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"},
|
{"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"},
|
||||||
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
||||||
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
|
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
|
||||||
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
||||||
|
{"name": "inventory", "label": "Inventory", "type": "template", "row": "inventory",
|
||||||
|
"template": "user_inventory.html", "wrap": {"class": "col"}},
|
||||||
|
{"name": "work_logs", "label": "Work Logs", "row": "inventory", "type": "template",
|
||||||
|
"template": "user_worklogs.html", "wrap": {"class": "col"}},
|
||||||
]
|
]
|
||||||
layout = [
|
layout = [
|
||||||
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||||
|
|
@ -120,6 +126,7 @@ def _fields_for_model(model: str):
|
||||||
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
|
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
|
||||||
{"name": "checkboxes", "order": 30, "parent": "details",
|
{"name": "checkboxes", "order": 30, "parent": "details",
|
||||||
"attrs": {"class": "col d-flex flex-column justify-content-end"}},
|
"attrs": {"class": "col d-flex flex-column justify-content-end"}},
|
||||||
|
{"name": "inventory", "order": 40, "attrs": {"class": "row"}},
|
||||||
]
|
]
|
||||||
|
|
||||||
elif model == "worklog":
|
elif model == "worklog":
|
||||||
|
|
@ -134,26 +141,28 @@ def _fields_for_model(model: str):
|
||||||
"updates.id",
|
"updates.id",
|
||||||
"updates.content",
|
"updates.content",
|
||||||
"updates.timestamp",
|
"updates.timestamp",
|
||||||
"updates.is_deleted",
|
"updates.is_deleted"
|
||||||
]
|
]
|
||||||
fields_spec = [
|
fields_spec = [
|
||||||
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
|
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
|
||||||
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
|
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
|
||||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||||
{"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact",
|
{"name": "contact", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Contact",
|
||||||
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
|
||||||
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}},
|
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}},
|
||||||
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
|
"options_params": {"active__eq": True}},
|
||||||
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
{"name": "work_item", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Work Item",
|
||||||
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}},
|
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
|
||||||
|
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}},
|
||||||
|
"options_params": {"condition__nin": ["Removed", "Disposed"]}},
|
||||||
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
|
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
|
||||||
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "Start"},
|
"wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "Start"},
|
||||||
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
|
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
|
||||||
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "End"},
|
"wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "End"},
|
||||||
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
|
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
|
||||||
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
|
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
|
||||||
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "form-label"},
|
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "ms-2"},
|
||||||
"type": "template", "template": "update_list.html"},
|
"type": "template", "template": "update_list.html"},
|
||||||
]
|
]
|
||||||
layout = [
|
layout = [
|
||||||
|
|
@ -173,18 +182,43 @@ def _fields_for_model(model: str):
|
||||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||||
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
|
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
|
||||||
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col mb-3"}},
|
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col mb-3 form-floating"}},
|
||||||
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
|
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
|
||||||
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col"}, "label_spec": "{name}"},
|
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}, "label_spec": "{name}"},
|
||||||
{"name": "room_function", "label": "Description", "label_spec": "{description}",
|
{"name": "room_function", "label": "Description", "label_spec": "{description}",
|
||||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details",
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"}, "row": "details",
|
||||||
"wrap": {"class": "col"}},
|
"wrap": {"class": "col form-floating"}},
|
||||||
]
|
]
|
||||||
layout = [
|
layout = [
|
||||||
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||||
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
||||||
{"name": "details", "order": 20, "attrs": {"class": "row"}},
|
{"name": "details", "order": 20, "attrs": {"class": "row"}},
|
||||||
]
|
]
|
||||||
|
elif model == "status":
|
||||||
|
fields_spec = [
|
||||||
|
{"name": "label", "label": "", "type": "display", "attrs": {"class": "display-6 mb-3"},
|
||||||
|
"row": "label", "wrap": {"class": "col"}, "label_spec": "{description} ({category})"},
|
||||||
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||||
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||||
|
{"name": "description", "row": "details", "label": "Description", "attrs": {"class": "form-control"},
|
||||||
|
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}},
|
||||||
|
{"name": "category", "row": "details", "label": "Category", "attrs": {"class": "form-control"},
|
||||||
|
"type": "select", "wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "options": [
|
||||||
|
{"label": "Active", "value": "Active"},
|
||||||
|
{"label": "Available", "value": "Available"},
|
||||||
|
{"label": "Pending", "value": "Pending"},
|
||||||
|
{"label": "Faulted", "value": "Faulted"},
|
||||||
|
{"label": "Decommissioned", "value": "Decommissioned"},
|
||||||
|
{"label": "Disposed", "value": "Disposed"},
|
||||||
|
{"label": "Administrative", "value": "Administrative"},
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
layout = [
|
||||||
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||||
|
{"name": "details", "order": 10, "attrs": {"class": "row"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (fields, fields_spec, layout)
|
return (fields, fields_spec, layout)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import crudkit
|
||||||
|
|
||||||
from crudkit.ui.fragments import render_table
|
from crudkit.ui.fragments import render_table
|
||||||
|
|
||||||
|
from ..models.device_type import DeviceType
|
||||||
from ..models.inventory import Inventory
|
from ..models.inventory import Inventory
|
||||||
from ..models.work_log import WorkLog
|
from ..models.work_log import WorkLog
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ 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():
|
||||||
|
# 1. work log stuff (leave it)
|
||||||
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,
|
||||||
|
|
@ -32,10 +34,69 @@ def init_index_routes(app):
|
||||||
{"field": "work_item.label", "label": "Work Item",
|
{"field": "work_item.label", "label": "Work Item",
|
||||||
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}
|
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}
|
||||||
]
|
]
|
||||||
|
|
||||||
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)
|
# 2. get device types with targets
|
||||||
|
device_type_service = crudkit.crud.get_service(DeviceType)
|
||||||
|
dt_rows = device_type_service.list({
|
||||||
|
'limit': 0,
|
||||||
|
'target__gt': 0,
|
||||||
|
'fields': [
|
||||||
|
'description',
|
||||||
|
'target'
|
||||||
|
],
|
||||||
|
"sort": "description",
|
||||||
|
})
|
||||||
|
|
||||||
|
# turn into df
|
||||||
|
device_types = pd.DataFrame([d.as_dict() for d in dt_rows])
|
||||||
|
|
||||||
|
# if nobody has targets, just show empty table
|
||||||
|
if device_types.empty:
|
||||||
|
empty_df = pd.DataFrame(columns=['id', 'description', 'target', 'actual', 'needed'])
|
||||||
|
return render_template("index.html", logs=logs, needed_inventory=empty_df)
|
||||||
|
|
||||||
|
# 3. now we can safely collect ids from the DF
|
||||||
|
dt_ids = device_types['id'].tolist()
|
||||||
|
|
||||||
|
# 4. build inventory filter
|
||||||
|
dt_filter = {
|
||||||
|
'$or': [{'device_type_id': d} for d in dt_ids],
|
||||||
|
# drop this if you decided to ignore condition
|
||||||
|
'condition.category': 'Available'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. fetch inventory
|
||||||
|
inventory_service = crudkit.crud.get_service(Inventory)
|
||||||
|
inv_rows = inventory_service.list({
|
||||||
|
'limit': 0,
|
||||||
|
**dt_filter,
|
||||||
|
'fields': ['device_type.description'],
|
||||||
|
'sort': 'device_type.description',
|
||||||
|
})
|
||||||
|
inventory_df = pd.DataFrame([i.as_dict() for i in inv_rows])
|
||||||
|
|
||||||
|
# if there is no inventory for these device types, actual = 0
|
||||||
|
if inventory_df.empty:
|
||||||
|
device_types['actual'] = 0
|
||||||
|
device_types['needed'] = device_types['target']
|
||||||
|
return render_template("index.html", logs=logs, needed_inventory=device_types)
|
||||||
|
|
||||||
|
# 6. aggregate counts
|
||||||
|
inv_counts = (
|
||||||
|
inventory_df['device_type.description']
|
||||||
|
.value_counts()
|
||||||
|
.rename('actual')
|
||||||
|
.reset_index()
|
||||||
|
.rename(columns={'device_type.description': 'description'})
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. merge
|
||||||
|
merged = device_types.merge(inv_counts, on='description', how='left')
|
||||||
|
merged['actual'] = merged['actual'].fillna(0).astype(int)
|
||||||
|
merged['needed'] = (merged['target'] - merged['actual']).clip(lower=0)
|
||||||
|
|
||||||
|
return render_template("index.html", logs=logs, needed_inventory=merged)
|
||||||
|
|
||||||
@bp_index.get("/LICENSE")
|
@bp_index.get("/LICENSE")
|
||||||
def license():
|
def license():
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ def init_listing_routes(app):
|
||||||
if model.lower() == 'inventory':
|
if model.lower() == 'inventory':
|
||||||
spec = {"fields": [
|
spec = {"fields": [
|
||||||
"label", "name", "barcode", "serial", "brand.name", "model",
|
"label", "name", "barcode", "serial", "brand.name", "model",
|
||||||
"device_type.description", "condition", "owner.label", "location.label",
|
"device_type.description", "condition.description", "owner.label", "location.label",
|
||||||
]}
|
]}
|
||||||
columns = [
|
columns = [
|
||||||
{"field": "label"},
|
{"field": "label"},
|
||||||
|
|
@ -46,7 +46,7 @@ def init_listing_routes(app):
|
||||||
{"field": "brand.name", "label": "Brand"},
|
{"field": "brand.name", "label": "Brand"},
|
||||||
{"field": "model"},
|
{"field": "model"},
|
||||||
{"field": "device_type.description", "label": "Device Type"},
|
{"field": "device_type.description", "label": "Device Type"},
|
||||||
{"field": "condition"},
|
{"field": "condition.description", "label": "Condition"},
|
||||||
{"field": "owner.label", "label": "Contact",
|
{"field": "owner.label", "label": "Contact",
|
||||||
"link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
|
"link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
|
||||||
{"field": "location.label", "label": "Room"},
|
{"field": "location.label", "label": "Room"},
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,206 @@
|
||||||
from flask import Blueprint, render_template, url_for
|
from datetime import datetime, timedelta
|
||||||
|
from email.utils import format_datetime
|
||||||
|
from flask import Blueprint, render_template, url_for, make_response, request
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
from crudkit.ui.fragments import render_table
|
||||||
|
|
||||||
import crudkit
|
import crudkit
|
||||||
|
|
||||||
bp_reports = Blueprint("reports", __name__)
|
bp_reports = Blueprint("reports", __name__)
|
||||||
|
|
||||||
|
def service_unavailable(detail="This feature is termporarily offline. Please try again later.", retry_seconds=3600):
|
||||||
|
retry_at = format_datetime(datetime.utcnow() + timedelta(seconds=retry_seconds))
|
||||||
|
html = render_template("errors/default.html", code=503, name="Service Unavailable", description=detail)
|
||||||
|
resp = make_response(html, 503)
|
||||||
|
resp.headers["Retry-After"] = retry_at
|
||||||
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
|
return resp
|
||||||
|
|
||||||
def init_reports_routes(app):
|
def init_reports_routes(app):
|
||||||
@bp_reports.get('/summary')
|
@bp_reports.get('/summary')
|
||||||
def summary():
|
def summary():
|
||||||
inventory_model = crudkit.crud.get_model('inventory')
|
inventory_model = crudkit.crud.get_model('inventory')
|
||||||
inventory_service = crudkit.crud.get_service(inventory_model)
|
inventory_service = crudkit.crud.get_service(inventory_model)
|
||||||
|
device_type_model = crudkit.crud.get_model('devicetype')
|
||||||
|
device_type_service = crudkit.crud.get_service(device_type_model)
|
||||||
|
|
||||||
|
needs = device_type_service.list({"limit": 0, "sort": "description", "fields": ["description", "target"]})
|
||||||
|
needs = pd.DataFrame([n.as_dict() for n in needs])
|
||||||
|
|
||||||
rows = inventory_service.list({
|
rows = inventory_service.list({
|
||||||
"limit": 0,
|
"limit": 0,
|
||||||
"sort": "device_type.description",
|
"sort": "device_type.description",
|
||||||
"fields": ["id", "condition", "device_type.description"],
|
"fields": [
|
||||||
|
"id",
|
||||||
|
"device_type.description",
|
||||||
|
"condition.category", # enum
|
||||||
|
"condition.description", # not used for pivot, but handy to have
|
||||||
|
],
|
||||||
})
|
})
|
||||||
df = pd.DataFrame([r.as_dict() for r in rows])
|
|
||||||
|
|
||||||
|
data = [r.as_dict() for r in rows]
|
||||||
|
if not data:
|
||||||
|
return render_template("summary.html", col_headers=[], table_rows=[])
|
||||||
|
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
|
||||||
|
# Dedup by id just in case you have over-eager joins
|
||||||
if "id" in df.columns:
|
if "id" in df.columns:
|
||||||
df = df.drop_duplicates(subset="id")
|
df = df.drop_duplicates(subset="id")
|
||||||
|
|
||||||
|
# Normalize text columns
|
||||||
|
df["device_type.description"] = (
|
||||||
|
df.get("device_type.description")
|
||||||
|
.fillna("(Unspecified)")
|
||||||
|
.astype(str)
|
||||||
|
)
|
||||||
|
|
||||||
|
# condition.category might be Enum(StatusCategory). We want the human values, e.g. "Active".
|
||||||
|
if "condition.category" in df.columns:
|
||||||
|
def _enum_value(x):
|
||||||
|
# StatusCategory is str, enum.Enum, so x.value is the nice string ("Active").
|
||||||
|
try:
|
||||||
|
return x.value
|
||||||
|
except AttributeError:
|
||||||
|
# Fallback if already a string or something weird
|
||||||
|
s = str(x)
|
||||||
|
# If someone handed us "StatusCategory.ACTIVE", fall back to the right half and title-case
|
||||||
|
return s.split(".", 1)[-1].capitalize() if s.startswith("StatusCategory.") else s
|
||||||
|
df["condition.category"] = df["condition.category"].map(_enum_value)
|
||||||
|
|
||||||
|
# Build the pivot by CATEGORY
|
||||||
|
cat_col = "condition.category"
|
||||||
|
if cat_col not in df.columns:
|
||||||
|
# No statuses at all; show a flat, zero-only pivot so the template stays sane
|
||||||
|
pt = pd.DataFrame(index=sorted(df["device_type.description"].unique()))
|
||||||
|
else:
|
||||||
pt = df.pivot_table(
|
pt = df.pivot_table(
|
||||||
index="device_type.description",
|
index="device_type.description",
|
||||||
columns="condition",
|
columns=cat_col,
|
||||||
values="id",
|
values="id",
|
||||||
aggfunc="count",
|
aggfunc="count",
|
||||||
fill_value=0,
|
fill_value=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reorder/exclude like before
|
if "target" in needs.columns:
|
||||||
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
|
needs["target"] = pd.to_numeric(needs["target"], errors="coerce").astype("Int64")
|
||||||
exclude = ["Removed", "Disposed"]
|
needs = needs.fillna({"target": pd.NA})
|
||||||
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.merge(needs, left_index=True, right_on="description")
|
||||||
pt = pt[cols]
|
# Make the human label the index so the left-most column renders as names, not integers
|
||||||
|
if "description" in pt.columns:
|
||||||
|
pt = pt.set_index("description")
|
||||||
|
|
||||||
# Drop zero-only rows
|
# Keep a handle on the category columns produced by the pivot BEFORE merge
|
||||||
pt = pt.loc[(pt != 0).any(axis=1)]
|
category_cols = list(df[cat_col].unique()) if cat_col in df.columns else []
|
||||||
|
category_cols = [c for c in category_cols if c in pt.columns]
|
||||||
|
|
||||||
|
# Cast only count columns to int
|
||||||
|
if category_cols:
|
||||||
|
pt[category_cols] = pt[category_cols].fillna(0).astype("int64")
|
||||||
|
# And make sure target is integer too (nullable so missing stays missing)
|
||||||
|
if "target" in pt.columns:
|
||||||
|
pt["target"] = pd.to_numeric(pt["target"], errors="coerce").astype("Int64")
|
||||||
|
|
||||||
|
# Column ordering: show the operationally meaningful ones first, hide junk unless asked
|
||||||
|
preferred_order = ["Active", "Available", "Pending", "Faulted", "Decommissioned"]
|
||||||
|
exclude_labels = {"Disposed", "Administrative"}
|
||||||
|
|
||||||
|
# Only tread the category columns as count columns
|
||||||
|
count_cols = [c for c in category_cols if c not in exclude_labels]
|
||||||
|
ordered = [c for c in preferred_order if c in count_cols] + [c for c in count_cols if c not in preferred_order]
|
||||||
|
|
||||||
|
# Planning columns: keep them visible, not part of totals
|
||||||
|
planning_cols = []
|
||||||
|
if "target" in pt.columns:
|
||||||
|
# Derive on_hand/need for convenience; Available might not exist in tiny datasets
|
||||||
|
on_hand = pt[ordered].get("Available")
|
||||||
|
if on_hand is not None:
|
||||||
|
pt["on_hand"] = on_hand
|
||||||
|
pt["need"] = (pt["target"].fillna(0) - pt["on_hand"]).clip(lower=0).astype("Int64")
|
||||||
|
planning_cols = ["target", "on_hand", "need"]
|
||||||
|
else:
|
||||||
|
planning_cols = ["target"]
|
||||||
|
|
||||||
|
# Reindex to the exact list we’ll render, so headers and cells are guaranteed to match
|
||||||
|
if not pt.empty:
|
||||||
|
pt = pt.reindex(columns=ordered + planning_cols)
|
||||||
|
# Keep rows that have any counts OR have a target (so planning rows with zero on-hand don't vanish)
|
||||||
|
if pt.shape[1] > 0:
|
||||||
|
keep_mask = (pt[ordered] != 0).any(axis=1) if ordered else False
|
||||||
|
if "target" in pt.columns:
|
||||||
|
keep_mask = keep_mask | pt["target"].notna()
|
||||||
|
pt = pt.loc[keep_mask]
|
||||||
|
|
||||||
# Totals
|
# Totals
|
||||||
pt["Total"] = pt.sum(axis=1)
|
if not pt.empty and ordered:
|
||||||
total_row = pt.sum(axis=0).to_frame().T
|
# Per-row totals (counts only)
|
||||||
total_row.index = ["Total"]
|
pt["Total"] = pt[ordered].sum(axis=1)
|
||||||
|
# Build totals row (counts only).
|
||||||
|
total_row = pd.DataFrame([pt[ordered].sum()], index=["Total"])
|
||||||
|
total_row["Total"] = total_row[ordered].sum(axis=1)
|
||||||
pt = pd.concat([pt, total_row], axis=0)
|
pt = pd.concat([pt, total_row], axis=0)
|
||||||
|
|
||||||
# Names off
|
# Strip pandas names
|
||||||
pt.index.name = None
|
pt.index.name = None
|
||||||
pt.columns.name = None
|
pt.columns.name = None
|
||||||
|
|
||||||
# Build link helpers. url_for can't take dotted kwarg keys, so build query strings.
|
# Construct headers from the exact columns in the pivot (including Total if present)
|
||||||
base_list_url = url_for("listing.show_list", model="inventory")
|
base_list_url = url_for("listing.show_list", model="inventory")
|
||||||
|
def q(params: dict | None):
|
||||||
|
return f"{base_list_url}?{urlencode(params)}" if params else None
|
||||||
|
|
||||||
def q(h):
|
columns_for_render = list(pt.columns) if not pt.empty else []
|
||||||
return f"{base_list_url}?{urlencode(h)}" if h else None
|
|
||||||
|
# Prettu display labels for headers (keys stay raw)
|
||||||
|
friendly = {
|
||||||
|
"target": "Target",
|
||||||
|
"on_hand": "On Hand",
|
||||||
|
"need": "Need",
|
||||||
|
"Total": "Total",
|
||||||
|
}
|
||||||
|
def label_for(col: str) -> str:
|
||||||
|
return friendly.get(col, col)
|
||||||
|
|
||||||
# Column headers with links (except Total)
|
|
||||||
col_headers = []
|
col_headers = []
|
||||||
for col in pt.columns.tolist():
|
for col in columns_for_render:
|
||||||
if col == "Total":
|
# Only make category columns clickable; planning/Total are informational
|
||||||
col_headers.append({"label": col, "href": None})
|
if col == "Total" or col in planning_cols:
|
||||||
|
col_headers.append({"label": label_for(col), "href": None})
|
||||||
else:
|
else:
|
||||||
col_headers.append({"label": col, "href": q({"condition": col})})
|
col_headers.append({"label": label_for(col), "href": q({cat_col: col})})
|
||||||
|
|
||||||
# Rows with header links and cell links
|
# Build rows. Cells iterate over the SAME list used for headers. No surprises.
|
||||||
table_rows = []
|
table_rows = []
|
||||||
for idx in pt.index.tolist():
|
index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique())
|
||||||
# Row header link: only if not Total
|
for idx in index_for_render:
|
||||||
if idx == "Total":
|
is_total_row = (idx == "Total")
|
||||||
row_href = None
|
row_href = None if is_total_row else q({"device_type.description": idx})
|
||||||
else:
|
|
||||||
row_href = q({"device_type.description": idx})
|
|
||||||
|
|
||||||
# Cells: combine filters, respecting Total row/col rules
|
|
||||||
cells = []
|
cells = []
|
||||||
for col in pt.columns.tolist():
|
for col in columns_for_render:
|
||||||
val = int(pt.at[idx, col])
|
# Safe fetch
|
||||||
|
val = pt.at[idx, col] if (not pt.empty and idx in pt.index and col in pt.columns) else (0 if col in ordered or col == "Total" else pd.NA)
|
||||||
|
# Pretty foramtting: counts/Total as ints; planning may be nullable
|
||||||
|
if col in ordered or col == "Total":
|
||||||
|
val = int(val) if pd.notna(val) else 0
|
||||||
|
s = f"{val:,}"
|
||||||
|
else:
|
||||||
|
# planning cols: show blank for <NA>, integer otherwise
|
||||||
|
if pd.isna(val):
|
||||||
|
s = ""
|
||||||
|
else:
|
||||||
|
s = f"{int(val):,}"
|
||||||
params = {}
|
params = {}
|
||||||
if idx != "Total":
|
if not is_total_row:
|
||||||
params["device_type.description"] = idx
|
params["device_type.description"] = idx
|
||||||
if col != "Total":
|
if col not in ("Total", *planning_cols):
|
||||||
params["condition"] = col
|
params[cat_col] = col
|
||||||
href = q(params) if params else None # None for Total×Total
|
href = q(params) if params else None
|
||||||
cells.append({"value": f"{val:,}", "href": href})
|
cells.append({"value": s, "href": href})
|
||||||
|
|
||||||
table_rows.append({"label": idx, "href": row_href, "cells": cells})
|
table_rows.append({"label": idx, "href": row_href, "cells": cells})
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|
@ -92,4 +209,83 @@ def init_reports_routes(app):
|
||||||
table_rows=table_rows,
|
table_rows=table_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@bp_reports.get("/problems")
|
||||||
|
def problems():
|
||||||
|
inventory_model = crudkit.crud.get_model('inventory')
|
||||||
|
inventory_svc = crudkit.crud.get_service(inventory_model)
|
||||||
|
|
||||||
|
rows = inventory_svc.list({
|
||||||
|
"limit": 0,
|
||||||
|
"$or": [
|
||||||
|
{"owner.active__eq": False},
|
||||||
|
{"owner_id": None}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
"owner.label",
|
||||||
|
"label",
|
||||||
|
"brand.name",
|
||||||
|
"model",
|
||||||
|
"device_type.description",
|
||||||
|
"location.label",
|
||||||
|
"condition"
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
orphans = render_table(rows, [
|
||||||
|
{"field": "owner.label", "label": "Owner", "link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
|
||||||
|
{"field": "label", "label": "Device"},
|
||||||
|
{"field": "brand.name", "label": "Brand"},
|
||||||
|
{"field": "model"},
|
||||||
|
{"field": "device_type.description", "label": "Device Type"},
|
||||||
|
{"field": "location.label", "label": "Location"},
|
||||||
|
{"field": "condition"},
|
||||||
|
], opts={"object_class": "inventory"})
|
||||||
|
|
||||||
|
rows = inventory_svc.list({
|
||||||
|
"fields": ["id", "name", "serial", "barcode", "brand.name", "model", "device_type.description", "owner.label", "location.label"],
|
||||||
|
"limit": 0,
|
||||||
|
"$or": [
|
||||||
|
{"name__ne": None},
|
||||||
|
{"serial__ne": None},
|
||||||
|
{"barcode__ne": None},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
duplicates = pd.DataFrame([r.as_dict() for r in rows]).set_index("id", drop=True)
|
||||||
|
subset = ["name", "serial", "barcode"]
|
||||||
|
|
||||||
|
mask = (
|
||||||
|
(duplicates["name"].notna() & duplicates.duplicated("name", keep=False)) |
|
||||||
|
(duplicates["serial"].notna() & duplicates.duplicated("serial", keep=False)) |
|
||||||
|
(duplicates["barcode"].notna() & duplicates.duplicated("barcode", keep=False))
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicates = duplicates.loc[mask].sort_values(subset)
|
||||||
|
# you already have this
|
||||||
|
cols = [
|
||||||
|
{"name": "name", "label": "Name"},
|
||||||
|
{"name": "serial", "label": "Serial #"},
|
||||||
|
{"name": "barcode", "label": "Bar Code"},
|
||||||
|
{"name": "brand.name", "label": "Brand"},
|
||||||
|
{"name": "model", "label": "Model"},
|
||||||
|
{"name": "device_type.description", "label": "Device Type"},
|
||||||
|
{"name": "owner.label", "label": "Owner"},
|
||||||
|
{"name": "location.label", "label": "Location"},
|
||||||
|
]
|
||||||
|
|
||||||
|
col_names = [c["name"] for c in cols if c["name"] in duplicates.columns]
|
||||||
|
col_labels = [c["label"] for c in cols if c["name"] in duplicates.columns]
|
||||||
|
|
||||||
|
out = duplicates[col_names].fillna("")
|
||||||
|
|
||||||
|
# Best for Jinja: list of dicts (each row keyed by column name)
|
||||||
|
duplicates = (
|
||||||
|
out.reset_index()
|
||||||
|
.rename(columns={"index": "id"})
|
||||||
|
.to_dict(orient="records")
|
||||||
|
)
|
||||||
|
headers_for_template = ["ID"] + col_labels
|
||||||
|
|
||||||
|
return render_template("problems.html", orphans=orphans, duplicates=duplicates, duplicate_columns=headers_for_template)
|
||||||
|
|
||||||
app.register_blueprint(bp_reports)
|
app.register_blueprint(bp_reports)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ def init_search_routes(app):
|
||||||
{"field": "location.label", "label": "Location"},
|
{"field": "location.label", "label": "Location"},
|
||||||
]
|
]
|
||||||
inventory_results = inventory_service.list({
|
inventory_results = inventory_service.list({
|
||||||
'notes|label|owner.label__icontains': q,
|
'notes|label|model|owner.label__icontains': q,
|
||||||
'fields': [
|
'fields': [
|
||||||
"label",
|
"label",
|
||||||
"name",
|
"name",
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@ def init_settings_routes(app):
|
||||||
function_service = crudkit.crud.get_service(function_model)
|
function_service = crudkit.crud.get_service(function_model)
|
||||||
room_model = crudkit.crud.get_model('room')
|
room_model = crudkit.crud.get_model('room')
|
||||||
room_service = crudkit.crud.get_service(room_model)
|
room_service = crudkit.crud.get_service(room_model)
|
||||||
|
status_model = crudkit.crud.get_model('status')
|
||||||
|
status_service = crudkit.crud.get_service(status_model)
|
||||||
|
|
||||||
brands = brand_service.list({"sort": "name", "limit": 0})
|
brands = brand_service.list({"sort": "name", "limit": 0})
|
||||||
device_types = device_type_service.list({"sort": "description", "limit": 0})
|
device_types = device_type_service.list({"sort": "description", "limit": 0, "fields": ["description", "target"]})
|
||||||
areas = area_service.list({"sort": "name", "limit": 0})
|
areas = area_service.list({"sort": "name", "limit": 0})
|
||||||
functions = function_service.list({"sort": "description", "limit": 0})
|
functions = function_service.list({"sort": "description", "limit": 0})
|
||||||
rooms = room_service.list({
|
rooms = room_service.list({
|
||||||
|
|
@ -42,6 +44,16 @@ def init_settings_routes(app):
|
||||||
],
|
],
|
||||||
opts={"object_class": 'room'})
|
opts={"object_class": 'room'})
|
||||||
|
|
||||||
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms)
|
statuses = status_service.list({
|
||||||
|
"sort": "category",
|
||||||
|
"limit": 0,
|
||||||
|
"fields": [
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
statuses = render_table(statuses, opts={"object_class": 'status'})
|
||||||
|
|
||||||
|
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses)
|
||||||
|
|
||||||
app.register_blueprint(bp_settings)
|
app.register_blueprint(bp_settings)
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,11 @@
|
||||||
border-color: rgb(222, 226, 230) !important;
|
border-color: rgb(222, 226, 230) !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-label {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-label a {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const DropDown = globalThis.DropDown ?? (globalThis.DropDown = {});
|
||||||
|
|
||||||
DropDown.utilities = {
|
DropDown.utilities = {
|
||||||
filterList(id) {
|
filterList(id) {
|
||||||
value = document.getElementById(`${id}-filter`).value;
|
value = document.getElementById(`${id}-filter`).value.toLowerCase();
|
||||||
list = document.querySelectorAll(`#${id}-dropdown li`);
|
list = document.querySelectorAll(`#${id}-dropdown li`);
|
||||||
|
|
||||||
list.forEach(item => {
|
list.forEach(item => {
|
||||||
|
|
@ -21,7 +21,7 @@ DropDown.utilities = {
|
||||||
const inp = document.getElementById(id);
|
const inp = document.getElementById(id);
|
||||||
|
|
||||||
btn.dataset.value = value;
|
btn.dataset.value = value;
|
||||||
btn.textContent = txt;
|
btn.value = txt;
|
||||||
|
|
||||||
inp.value = value;
|
inp.value = value;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
|
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('reports.problems') }}">Problems</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,4 @@
|
||||||
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
|
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
|
||||||
{% 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 }}
|
|
||||||
{% if link_href %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</label>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if field_type == 'select' %}
|
{% if field_type == 'select' %}
|
||||||
{#
|
{#
|
||||||
|
|
@ -30,24 +18,41 @@
|
||||||
#}
|
#}
|
||||||
{% if options %}
|
{% if options %}
|
||||||
{% if value %}
|
{% if value %}
|
||||||
{% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %}
|
{% set opts = options or [] %}
|
||||||
|
{% set selected = opts | selectattr('value', 'equalto', value) | list %}
|
||||||
|
{% if not selected %}
|
||||||
|
{% set selected = opts | selectattr('value', 'equalto', value|string) | list %}
|
||||||
|
{% endif %}
|
||||||
|
{% set sel = selected[0] if selected else none %}
|
||||||
|
|
||||||
|
{% if sel %}
|
||||||
|
{% set sel_label = sel['label'] %}
|
||||||
|
{% elif value_label %}
|
||||||
|
{% set sel_label = value_label %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set sel_label = "-- Select --" %}
|
{% set sel_label = "-- Select --" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
|
{% else %}
|
||||||
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}">{{ sel_label }}</button>
|
{% set sel_label = "-- Select --" %}
|
||||||
<div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown">
|
{% 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 -->
|
||||||
|
<input type="button" class="form-control 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 }}" value="{{ sel_label }}">
|
||||||
|
<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"
|
<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 }}')">
|
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
|
||||||
{% for opt in options %}
|
{% for opt in options %}
|
||||||
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
|
<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>
|
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')"
|
||||||
|
id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No selection available --</button>
|
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No
|
||||||
|
selection available --</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value }}">
|
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else '' }}">
|
||||||
|
|
||||||
{% elif field_type == 'textarea' %}
|
{% elif field_type == 'textarea' %}
|
||||||
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
|
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
|
||||||
|
|
@ -86,3 +91,15 @@
|
||||||
{% if help %}
|
{% if help %}
|
||||||
<div class="form-text">{{ help }}</div>
|
<div class="form-text">{{ help }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% 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 }}
|
||||||
|
{% if link_href %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- TABLE {{ kwargs['object_class'] if kwargs else '(NO MODEL ASSOCIATED)' }} -->
|
||||||
<div class="table-responsive" style="max-height: 80vh;">
|
<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-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,101 @@
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="submit" class="btn btn-primary" id="submit">Save</button>
|
<button type="submit" class="btn btn-outline-primary" id="submit">Save</button>
|
||||||
<button type="button" class="btn btn-outline-primary" onclick="location.href='{{ url_for("entry.entry_new", model=(field['attrs']['data-model'])) }}'">New</button>
|
<button type="button" class="btn btn-outline-success"
|
||||||
|
onclick="location.href='{{ url_for('entry.entry_new', model=field['attrs']['data-model']) }}'"
|
||||||
|
id="new">New</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" id="delete" onclick="deleteEntry()">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.newDrafts = window.newDrafts || [];
|
window.newDrafts = window.newDrafts || [];
|
||||||
window.deletedIds = window.deletedIds || [];
|
window.deletedIds = window.deletedIds || [];
|
||||||
|
const LIST_URL = {{ url_for('listing.show_list', model = field['attrs']['data-model']) | tojson }};
|
||||||
|
|
||||||
|
// Build delete URL only if we have an id, or leave it empty string
|
||||||
|
{% set model = field['attrs']['data-model'] %}
|
||||||
|
{% set obj_id = field['template_ctx']['values'].get('id') %}
|
||||||
|
{% set delete_url = obj_id and url_for('crudkit.' ~model ~ '.rest_delete', obj_id = obj_id) %}
|
||||||
|
const DELETE_URL = {{ (delete_url or '') | tojson }};
|
||||||
|
|
||||||
|
// Form metadata
|
||||||
|
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
|
||||||
|
const model = {{ field['attrs']['data-model'] | tojson }};
|
||||||
|
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
|
||||||
|
const hasId = idVal !== null && idVal !== undefined;
|
||||||
|
|
||||||
|
if (!hasId) {
|
||||||
|
const delBtn = document.getElementById('delete');
|
||||||
|
delBtn.disabled = true;
|
||||||
|
delBtn.classList.add('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry() {
|
||||||
|
const delBtn = document.getElementById('delete');
|
||||||
|
if (!DELETE_URL) return;
|
||||||
|
if (!window.confirm('Delete this entry?')) return;
|
||||||
|
|
||||||
|
delBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(DELETE_URL, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
if (res.status !== 204) {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) {
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
data = ct.includes('application/json') ? JSON.parse(text) : { message: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data && (data.detail || data.error || data.message)) ||
|
||||||
|
`Request failed with ${res.status} ${res.statusText}`;
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueToast((data && (data.detail || data.message)) || 'Item deleted.', 'success');
|
||||||
|
location.assign(LIST_URL);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') {
|
||||||
|
toastMessage('Network timeout while deleting item.', 'danger');
|
||||||
|
} else if (err?.status === 409) {
|
||||||
|
toastMessage(`Delete blocked: ${err.message}`, 'warning');
|
||||||
|
} else {
|
||||||
|
toastMessage(`Network error: ${String(err?.message || err)}`, 'danger');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
delBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if field['attrs']['data-model'] == 'worklog' %}
|
||||||
|
function collectExistingUpdateIds() {
|
||||||
|
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
|
||||||
|
.map(el => Number(el.id.slice(3)))
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
|
||||||
|
|
||||||
|
function collectEditedUpdates() {
|
||||||
|
const updates = [];
|
||||||
|
const deleted = new Set(collectDeletedIds());
|
||||||
|
for (const id of collectExistingUpdateIds()) {
|
||||||
|
if (deleted.has(id)) continue;
|
||||||
|
updates.push({ id, content: getMarkdown(id) });
|
||||||
|
}
|
||||||
|
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
function formToJson(form) {
|
function formToJson(form) {
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
|
|
@ -36,45 +126,21 @@
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectExistingUpdateIds() {
|
// URLs for create/update
|
||||||
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
|
const createUrl = {{ url_for('entry.create_entry', model = field['attrs']['data-model']) | tojson }};
|
||||||
.map(el => Number(el.id.slice(3)))
|
|
||||||
.filter(Number.isFinite);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectEditedUpdates() {
|
|
||||||
const updates = [];
|
|
||||||
const deleted = new Set(collectDeletedIds());
|
|
||||||
for (const id of collectExistingUpdateIds()) {
|
|
||||||
if(deleted.has(id)) continue; // skip ones marked for deletion
|
|
||||||
updates.push({ id, content: getMarkdown(id) });
|
|
||||||
}
|
|
||||||
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
|
|
||||||
return updates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
|
|
||||||
|
|
||||||
// much simpler, and correct
|
|
||||||
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
|
|
||||||
const model = {{ field['attrs']['data-model'] | tojson }};
|
|
||||||
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
|
|
||||||
const hasId = idVal !== null && idVal !== undefined;
|
|
||||||
|
|
||||||
// Never call url_for for update on the "new" page.
|
|
||||||
// Create URL is fine to build server-side:
|
|
||||||
const createUrl = {{ url_for('entry.create_entry', model=field['attrs']['data-model']) | tojson }};
|
|
||||||
// Update URL is assembled on the client to avoid BuildError on "new":
|
|
||||||
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
|
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
|
||||||
|
|
||||||
formEl.addEventListener("submit", async e => {
|
formEl.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('submit');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
const json = formToJson(formEl);
|
const json = formToJson(formEl);
|
||||||
|
|
||||||
if (model === 'inventory' && typeof getMarkdown === 'function') {
|
if (model === 'inventory' && typeof getMarkdown === 'function') {
|
||||||
const md = getMarkdown();
|
const md = getMarkdown();
|
||||||
json.notes = (typeof md === 'string') ? getMarkdown().trim() : '';
|
json.notes = (typeof md === 'string') ? md.trim() : '';
|
||||||
} else if (model === 'worklog') {
|
} else if (model === 'worklog') {
|
||||||
json.updates = collectEditedUpdates();
|
json.updates = collectEditedUpdates();
|
||||||
json.delete_update_ids = collectDeletedIds();
|
json.delete_update_ids = collectDeletedIds();
|
||||||
|
|
@ -89,33 +155,34 @@
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(json),
|
body: JSON.stringify(json),
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
const reply = await res.json();
|
const reply = await res.json();
|
||||||
|
|
||||||
if (reply.status === 'success') {
|
if (reply.status === 'success') {
|
||||||
if (!hasId && reply.id) {
|
|
||||||
window.queueToast('Created successfully.', 'success');
|
|
||||||
window.newDrafts = [];
|
window.newDrafts = [];
|
||||||
window.deletedIds = [];
|
window.deletedIds = [];
|
||||||
window.location.assign(`/entry/${model}/${reply.id}`);
|
if (!hasId && reply.id) {
|
||||||
|
queueToast('Created successfully.', 'success');
|
||||||
|
location.assign(`/entry/${model}/${reply.id}`);
|
||||||
return;
|
return;
|
||||||
} else {
|
}
|
||||||
window.queueToast('Updated successfully.', 'success');
|
queueToast('Updated successfully.', 'success');
|
||||||
|
|
||||||
if (model === 'worklog') {
|
if (model === 'worklog') {
|
||||||
for (const id of collectDeletedIds()) {
|
for (const id of collectDeletedIds()) {
|
||||||
const li = document.getElementById(`note-${id}`);
|
document.getElementById(`note-${id}`)?.remove();
|
||||||
if (li) li.remove();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.newDrafts = [];
|
location.replace(location.href);
|
||||||
window.deletedIds = [];
|
} else {
|
||||||
window.location.replace(window.location.href);
|
toastMessage(reply.message || 'Server reported failure.', 'danger');
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastMessage(`Network error: ${String(err)}`, 'danger');
|
toastMessage(`Network error: ${String(err)}`, 'danger');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
8
inventory/templates/errors/500.html
Normal file
8
inventory/templates/errors/500.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Internal Server Error{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<h1 class="display-1 text-center">Internal Server Error</h1>
|
||||||
|
<div class="alert alert-danger text-center">This service has encountered an error. This error has been logged. Please try again later.</div>
|
||||||
|
{% endblock %}
|
||||||
8
inventory/templates/errors/default.html
Normal file
8
inventory/templates/errors/default.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{{ code }} {{ name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<h1 class="display-1 text-center">{{ code }} - {{ name }}</h1>
|
||||||
|
<div class="alert alert-danger text-center">{{ description }}</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -5,9 +5,44 @@
|
||||||
<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 mx-5">
|
<div class="row mx-5">
|
||||||
<div class="col pivot-cell ms-5">
|
<div class="col">
|
||||||
<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">
|
||||||
|
<p class="display-6 text-center">Supply Status</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered table-striped table-hover">
|
||||||
|
{% if not needed_inventory.empty %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>On Hand</th>
|
||||||
|
<th>Needed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in needed_inventory.itertuples() %}
|
||||||
|
<tr class="{{ 'table-warning' if row.needed else '' }}"
|
||||||
|
onclick="location.href='{{ url_for('listing.show_list', model='inventory', device_type_id__eq=row.id) }}&condition.category=Available'"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<td>{{ row.description }}</td>
|
||||||
|
<td>{{ row.target }}</td>
|
||||||
|
<td>{{ row.actual }}</td>
|
||||||
|
<td class="{{ 'fw-bold' if row.needed else '' }}">{{ row.needed }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% else %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="4" class="text-center">No data.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
36
inventory/templates/problems.html
Normal file
36
inventory/templates/problems.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<h1 class="display-4 mb-3 text-center">Records With Problems</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<p>Equipment Without Active Owner</p>
|
||||||
|
{{ orphans | safe }}
|
||||||
|
<p>Duplicate Inventory Entries</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-info table-bordered table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for col in duplicate_columns %}
|
||||||
|
<th>
|
||||||
|
{{ col }}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for record in duplicates %}
|
||||||
|
<tr style="cursor: pointer;"
|
||||||
|
onclick="location.href='{{ url_for('entry.entry', model='inventory', id=record['id']) }}'">
|
||||||
|
{% for cell in record.values() %}
|
||||||
|
<td>
|
||||||
|
{{ cell }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -2,7 +2,66 @@
|
||||||
{% from 'components/combobox.html' import combobox %}
|
{% from 'components/combobox.html' import combobox %}
|
||||||
|
|
||||||
{% block styleincludes %}
|
{% block styleincludes %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
.dt-target {
|
||||||
|
width: 6ch;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* keep the row highlight, but keep the input looking normal */
|
||||||
|
.dt-option.selected .dt-target {
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* nuke the blue focus ring/border inside selected rows */
|
||||||
|
.dt-option .dt-target:focus {
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-target::-webkit-outer-spin-button,
|
||||||
|
.dt-target::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-option {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection styling for the row */
|
||||||
|
@supports (background-color: AccentColor) {
|
||||||
|
.dt-option.selected { background-color: AccentColor; }
|
||||||
|
.dt-option.selected .dt-label { color: AccentColorText; }
|
||||||
|
|
||||||
|
/* Hard force the input to be opaque and not inherit weirdness */
|
||||||
|
.dt-option .dt-target,
|
||||||
|
.dt-option .dt-target:focus {
|
||||||
|
background-color: Field !important; /* system input bg */
|
||||||
|
color: FieldText !important; /* system input text */
|
||||||
|
box-shadow: none; /* not the halo issue, but be thorough */
|
||||||
|
border-color: var(--bs-border-color); /* keep Bootstrap-ish border */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (background-color: AccentColor) {
|
||||||
|
.dt-option.selected { background-color: var(--bs-list-group-active-bg, #0d6efd); }
|
||||||
|
.dt-option.selected .dt-label { color: var(--bs-list-group-active-color, #fff); }
|
||||||
|
|
||||||
|
.dt-option .dt-target,
|
||||||
|
.dt-option .dt-target:focus {
|
||||||
|
background-color: var(--bs-body-bg) !important;
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
@ -10,12 +69,12 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul class="nav nav-pills nav-fill">
|
<ul class="nav nav-pills nav-fill">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button type="button" class="nav-link active" id="device-tab"
|
<button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab"
|
||||||
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
|
data-bs-target="#device-tab-pane">Devices</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button type="button" class="nav-link" id="location-tab"
|
<button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab"
|
||||||
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
|
data-bs-target="#location-tab-pane">Locations</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -24,12 +83,51 @@
|
||||||
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
|
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label for="brand" class="form-label">Brand</label>
|
<label for="brand" class="form-label">Brands</label>
|
||||||
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
|
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label for="devicetype" class="form-label">Device Type</label>
|
<label for="devicetype" class="form-label">Device Types</label>
|
||||||
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
|
{# { combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id',
|
||||||
|
'description') } #}
|
||||||
|
{# Going to specialize the combobox widget here. #}
|
||||||
|
<div class="combobox">
|
||||||
|
<div class="d-flex">
|
||||||
|
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
|
||||||
|
placeholder="Enter the description of a device type." id="input-devicetype"
|
||||||
|
oninput="enableDTAddButton()">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-primary border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 disabled"
|
||||||
|
id="add-devicetype" onclick="addDTItem()" disabled>Add</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-info border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 d-none"
|
||||||
|
id="edit-devicetype" onclick="editDTItem()">Edit</button>
|
||||||
|
<button type="button" class="btn btn-danger border-bottom-0 rounded-bottom-0 rounded-start-0 disabled"
|
||||||
|
id="remove-devicetype" onclick="deleteDTItem()" disabled>Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="border h-100 ps-3 pe-0 overflow-auto" id="device-type-list">
|
||||||
|
{% for t in device_types %}
|
||||||
|
<div id="devicetype-option-{{ t['id'] }}" data-inv-id="{{ t['id'] }}"
|
||||||
|
class="d-flex justify-content-between align-items-center user-select-none dt-option">
|
||||||
|
<span class="align-middle dt-label">{{ t['description'] }}</span>
|
||||||
|
<input type="number"
|
||||||
|
class="form-control form-control-sm dt-target"
|
||||||
|
id="devicetype-target-{{ t['id'] }}" name="devicetype-target-{{ t['id'] }}"
|
||||||
|
value="{{ t['target'] if t['target'] else 0 }}" min="0" max="999">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col">
|
||||||
|
<label for="status" class="form-label">
|
||||||
|
Conditions
|
||||||
|
<a href="{{ url_for('entry.entry_new', model='status') }}"
|
||||||
|
class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||||
|
</label>
|
||||||
|
{{ statuses | safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,11 +135,11 @@
|
||||||
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
|
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label for="area" class="form-label">Area</label>
|
<label for="area" class="form-label">Areas</label>
|
||||||
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
|
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label for="roomfunction" class="form-label">Description</label>
|
<label for="roomfunction" class="form-label">Descriptions</label>
|
||||||
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
|
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,7 +147,8 @@
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<label for="rooms" class="form-label">
|
<label for="rooms" class="form-label">
|
||||||
Rooms
|
Rooms
|
||||||
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
<a href="{{ url_for('entry.entry_new', model='room') }}"
|
||||||
|
class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||||
</label>
|
</label>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{{ rooms | safe }}
|
{{ rooms | safe }}
|
||||||
|
|
@ -63,5 +162,266 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scriptincludes %}
|
{% block scriptincludes %}
|
||||||
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
const brands = document.getElementById('brand');
|
||||||
|
const dtlist = document.getElementById('device-type-list');
|
||||||
|
const dtinput = document.getElementById('input-devicetype');
|
||||||
|
|
||||||
|
const height = getComputedStyle(brands).height;
|
||||||
|
dtlist.style.height = height;
|
||||||
|
dtlist.style.maxHeight = height;
|
||||||
|
dtlist.style.minHeight = height;
|
||||||
|
|
||||||
|
document.querySelectorAll('.dt-target').forEach((el) => {
|
||||||
|
el.addEventListener('change', async (ev) => {
|
||||||
|
const num = ev.target.value;
|
||||||
|
const id = ev.target.parentElement.dataset.invId;
|
||||||
|
|
||||||
|
let res, data;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({target: num})
|
||||||
|
});
|
||||||
|
|
||||||
|
const ct = res.headers.get('Content-Type') || '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const msg = data?.error || `Create failed (${res.status})`;
|
||||||
|
toastMessage(msg, 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toastMessage('Network error setting target number', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastMessage('Updated target number.', 'success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (ev) => {
|
||||||
|
const addButton = document.getElementById('add-devicetype');
|
||||||
|
const editButton = document.getElementById('edit-devicetype');
|
||||||
|
const deleteButton = document.getElementById('remove-devicetype');
|
||||||
|
|
||||||
|
if (!ev.target.closest('#device-type-list')) return;
|
||||||
|
|
||||||
|
// Do not toggle selection when interacting with the input itself
|
||||||
|
if (ev.target.closest('.dt-target')) return;
|
||||||
|
|
||||||
|
const node = ev.target.closest('.dt-option');
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// clear others
|
||||||
|
document.querySelectorAll('.dt-option')
|
||||||
|
.forEach(n => { n.classList.remove('selected', 'active'); n.removeAttribute('aria-selected'); });
|
||||||
|
|
||||||
|
// select this one
|
||||||
|
node.classList.add('selected', 'active');
|
||||||
|
node.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
// set the visible input to the label, not the whole row
|
||||||
|
const label = node.querySelector('.dt-label');
|
||||||
|
dtinput.value = (label ? label.textContent : node.textContent).replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
addButton.classList.add('d-none');
|
||||||
|
editButton.classList.remove('d-none');
|
||||||
|
deleteButton.classList.remove('disabled');
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.enableDTAddButton = function enableDTAddButton() {
|
||||||
|
const addButton = document.getElementById('add-devicetype');
|
||||||
|
if (addButton.classList.contains('d-none')) return;
|
||||||
|
|
||||||
|
addButton.disabled = dtinput.value === '';
|
||||||
|
if (addButton.disabled) {
|
||||||
|
addButton.classList.add('disabled');
|
||||||
|
} else {
|
||||||
|
addButton.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addDTItem = async function addDTItem() {
|
||||||
|
const input = document.getElementById('input-devicetype');
|
||||||
|
const list = document.getElementById('device-type-list');
|
||||||
|
const addButton = document.getElementById('add-devicetype');
|
||||||
|
const editButton = document.getElementById('edit-devicetype');
|
||||||
|
|
||||||
|
const value = (input.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
toastMessage('Type a device type first.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addButton.disabled = true;
|
||||||
|
addButton.classList.add('disabled');
|
||||||
|
|
||||||
|
let res, data;
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url_for("crudkit.devicetype.rest_create") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ description: value, target: 0 })
|
||||||
|
});
|
||||||
|
|
||||||
|
const ct = res.headers.get('Content-Type') || '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 201) {
|
||||||
|
const msg = data?.error || `Create failed (${res.status})`;
|
||||||
|
toastMessage(msg, 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toastMessage('Network error creating device type.', 'danger');
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
addButton.disabled = false;
|
||||||
|
addButton.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = data?.id ?? data?.obj?.id;
|
||||||
|
const description = String(data?.description ?? value);
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.id = `devicetype-option-${id}`;
|
||||||
|
row.dataset.invId = id;
|
||||||
|
row.className = 'd-flex justify-content-between align-items-center user-select-none dt-option';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'align-middle dt-label';
|
||||||
|
label.textContent = description;
|
||||||
|
|
||||||
|
const qty = document.createElement('input');
|
||||||
|
qty.type = 'number';
|
||||||
|
qty.min = '0';
|
||||||
|
qty.max = '999';
|
||||||
|
qty.value = '0';
|
||||||
|
qty.id = `devicetype-target-${id}`;
|
||||||
|
qty.name = `devicetype-target-${id}`;
|
||||||
|
qty.className = 'form-control form-control-sm dt-target';
|
||||||
|
|
||||||
|
row.append(label, qty);
|
||||||
|
list.appendChild(row);
|
||||||
|
|
||||||
|
list.querySelectorAll('.dt-option').forEach(n => {
|
||||||
|
n.classList.remove('selected', 'active');
|
||||||
|
n.removeAttribute('aria-selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
row.scrollIntoView({ block: 'nearest' });
|
||||||
|
|
||||||
|
toastMessage(`Created new device type: ${description}`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.editDTItem = async function editDTItem() {
|
||||||
|
const input = document.getElementById('input-devicetype');
|
||||||
|
const addButton = document.getElementById('add-devicetype');
|
||||||
|
const editButton = document.getElementById('edit-devicetype');
|
||||||
|
const option = document.querySelector('.dt-option.selected');
|
||||||
|
|
||||||
|
const value = (input.value || option.dataset.invId).trim();
|
||||||
|
if (!value) {
|
||||||
|
toastMessage('Type a device type first.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res, data;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ description: value })
|
||||||
|
});
|
||||||
|
|
||||||
|
const ct = res.headers.get('Content-Type') || '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const msg = data?.error || `Create failed (${res.status})`;
|
||||||
|
toastMessage(msg, 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toastMessage('Network error creating device type.', 'danger');
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
editButton.disabled = true;
|
||||||
|
editButton.classList.add('disabled', 'd-none');
|
||||||
|
addButton.classList.remove('d-none');
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
option.querySelector('.dt-label').textContent = value;
|
||||||
|
|
||||||
|
toastMessage(`Updated device type: ${value}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.deleteDTItem = async function deleteDTItem() {
|
||||||
|
const input = document.getElementById('input-devicetype');
|
||||||
|
const addButton = document.getElementById('add-devicetype');
|
||||||
|
const editButton = document.getElementById('edit-devicetype');
|
||||||
|
const option = document.querySelector('.dt-option.selected');
|
||||||
|
const deleteButton = document.getElementById('remove-devicetype');
|
||||||
|
const value = (input.value || '').trim();
|
||||||
|
|
||||||
|
let res, data;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
option.remove();
|
||||||
|
toastMessage(`Deleted ${value} successfully.`, 'success');
|
||||||
|
editButton.disabled = true;
|
||||||
|
editButton.classList.add('disabled', 'd-none');
|
||||||
|
deleteButton.disabled = true;
|
||||||
|
deleteButton.classList.add('disabled');
|
||||||
|
addButton.classList.remove('d-none');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = 'Delete failed.';
|
||||||
|
try {
|
||||||
|
const err = await res.json();
|
||||||
|
msg = err?.error || msg;
|
||||||
|
} catch {
|
||||||
|
const txt = await res.text();
|
||||||
|
if (txt) msg = txt;
|
||||||
|
}
|
||||||
|
toastMessage(msg, 'danger');
|
||||||
|
} catch (e) {
|
||||||
|
toastMessage(`Delete failed: ${e?.message || e}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,28 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
|
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
|
||||||
<div class="table-responsive mx-5">
|
<div class="table-responsive mx-5 overflow-y-auto border" style="max-height: 70vh;">
|
||||||
<table class="table table-sm table-striped table-hover table-bordered align-middle">
|
<table class="table table-sm table-striped table-hover table-bordered align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-nowrap">Device Type</th>
|
<th class="text-nowrap position-sticky top-0 bg-body border">Device Type</th>
|
||||||
{% for col in col_headers %}
|
{% for col in col_headers %}
|
||||||
{% if col.href %}
|
{% if col.href %}
|
||||||
<th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
|
<th class="text-end position-sticky top-0 bg-body border"><a
|
||||||
|
class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
|
||||||
{% else %}
|
{% else %}
|
||||||
<th class="text-end">{{ col.label }}</th>
|
<th class="text-end position-sticky top-0 bg-body border">{{ col.label }}</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in table_rows %}
|
{% for row in table_rows %}
|
||||||
<tr>
|
{% set need_more = (row['cells'][-2]['value'] | int > 0) %}
|
||||||
<th class="text-nowrap">
|
<tr
|
||||||
|
class="{% if need_more %}table-warning{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
|
||||||
|
<th class="text-nowrap{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
|
||||||
{% if row.href %}
|
{% if row.href %}
|
||||||
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
|
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -27,14 +31,18 @@
|
||||||
</th>
|
</th>
|
||||||
{% for cell in row.cells %}
|
{% for cell in row.cells %}
|
||||||
{% if cell.href %}
|
{% if cell.href %}
|
||||||
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
|
<td
|
||||||
|
class="text-end{% if need_more and loop.index == (row.cells|length - 1) %} fw-bold{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
|
||||||
|
<a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td class="text-end">{{ cell.value }}</td>
|
<td
|
||||||
|
class="text-end{% if need_more and loop.index == (row.cells|length - 1) %} fw-bold{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
|
||||||
|
{{ cell.value }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{% set items = (field.template_ctx.instance.updates or []) %}
|
{% set items = (field.template_ctx.instance.updates or []) %}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3 form-floating">
|
||||||
<label class="form-label">Add update</label>
|
|
||||||
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
|
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
|
||||||
|
<label class="form-label">Add update</label>
|
||||||
<div class="mt-2 d-flex gap-2">
|
<div class="mt-2 d-flex gap-2">
|
||||||
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button>
|
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>
|
||||||
|
|
|
||||||
33
inventory/templates/user_inventory.html
Normal file
33
inventory/templates/user_inventory.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<label class="form-label mt-2">Assigned Inventory</label>
|
||||||
|
{% set inv = field['template_ctx']['values']['inventory'] %}
|
||||||
|
|
||||||
|
<div class="table-responsive border overflow-y-auto" style="max-height: 45vh;">
|
||||||
|
<table class="table table-sm table-bordered table-striped table-hover mb-0">
|
||||||
|
{% if inv %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">Device</th>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">Brand</th>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">Model</th>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for i in inv if i['condition.category'] not in ['Disposed', 'Administrative'] %}
|
||||||
|
<tr style="cursor: pointer;" onclick="location.href='{{ url_for('entry.entry', model='inventory', id=i.id) }}'">
|
||||||
|
<td>{{ i.label }}</td>
|
||||||
|
<td>{{ i['brand.name'] }}</td>
|
||||||
|
<td>{{ i.model }}</td>
|
||||||
|
<td>{{ i['device_type.description'] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% else %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="4" class="text-center">No data.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
37
inventory/templates/user_worklogs.html
Normal file
37
inventory/templates/user_worklogs.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<!-- WORK LOGS -->
|
||||||
|
|
||||||
|
<label class="form-label mt-2">Work Logs</label>
|
||||||
|
{% set wl = field['template_ctx']['values']['work_logs'] %}
|
||||||
|
{% set check %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg text-success" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"/>
|
||||||
|
</svg>
|
||||||
|
{% endset %}
|
||||||
|
{% set x %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg text-danger" viewBox="0 0 16 16">
|
||||||
|
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
|
||||||
|
</svg>
|
||||||
|
{% endset %}
|
||||||
|
|
||||||
|
<div class="table-responsive border overflow-y-auto" style="max-height: 45vh;">
|
||||||
|
<table class="table table-sm table-striped table-bordered table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">Device</th>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">Start</th>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border">End</th>
|
||||||
|
<th class="position-sticky top-0 bg-body z-1 border"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in wl %}
|
||||||
|
<tr onclick="location.href='{{ url_for('entry.entry', model='worklog', id=l.id) }}'" style="cursor: pointer;">
|
||||||
|
<td>{{ l['work_item']['label'] if l['work_item'] else '-' }}</td>
|
||||||
|
<td>{{ l.start_time if l.start_time else '-' }}</td>
|
||||||
|
<td>{{ l.end_time if l.end_time else '-' }}</td>
|
||||||
|
<td class="text-center align-items-center {{ 'bg-success-subtle' if l.complete else 'bg-danger-subtle' }}">{{ check if l.complete else x }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue