Compare commits
88 commits
ugly_10_20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6bb3d8780 | ||
|
|
ea1f43dcd3 | ||
|
|
2561127221 | ||
|
|
059d5ee9ba | ||
|
|
27a29d9c66 | ||
|
|
41fafae501 | ||
|
|
82c3ea2b90 | ||
|
|
cc32b2214c | ||
|
|
5a2f480ef7 | ||
|
|
4e2cd2b0e5 | ||
|
|
760fe603c9 | ||
|
|
296f29db0c | ||
|
|
1ee3a05ab9 | ||
|
|
62629c6a3d | ||
|
|
2993bc400a | ||
|
|
6d711474df | ||
|
|
c51ef38d99 | ||
|
|
34d4a28622 | ||
|
|
6ab8ce4069 | ||
|
|
e4b59b124e | ||
|
|
63f3ad1394 | ||
|
|
ff2734fff6 | ||
|
|
585c4abb25 | ||
|
|
429e993009 | ||
|
|
a1cf260072 | ||
|
|
86d24ccb43 | ||
|
|
3864f27cc6 | ||
|
|
a05218b985 | ||
|
|
6f175c103e | ||
|
|
d2061f5c1c | ||
|
|
4c98de2eef | ||
|
|
03804cc476 | ||
|
|
65beb3509c | ||
| ce562d34de | |||
| 5dfc2691e9 | |||
| b4c448d572 | |||
| 24b74f78c0 | |||
| c5cc368ef9 | |||
| e8d9d1a330 | |||
|
|
641ae1470d | ||
|
|
802c3cd028 | ||
|
|
9d22c55aba | ||
|
|
8d5ddca229 | ||
|
|
624ebf09d3 | ||
|
|
1926a3930d | ||
|
|
0b85715c1e | ||
|
|
3f4aee73a3 | ||
|
|
0992cf97eb | ||
|
|
47942cc6b6 | ||
|
|
4fe3dfb8b4 | ||
|
|
8abf9bdcdf | ||
|
|
0fb1991b5a | ||
|
|
292ca0798c | ||
|
|
9ddbacb4de | ||
|
|
5cc47c4a81 | ||
|
|
87ec637ac1 | ||
|
|
fc95c87e84 | ||
|
|
d207f1da2c | ||
|
|
55f18b1cbe | ||
|
|
285db679d9 | ||
|
|
5b8f14c99b | ||
|
|
7d4b76d19f | ||
|
|
d1f00cd9d5 | ||
|
|
d151d68ce9 | ||
|
|
24e49341e8 | ||
|
|
c3ad0fba84 | ||
|
|
d0551d52a1 | ||
|
|
4ef4d5e23f | ||
|
|
8481a40553 | ||
|
|
dc3482f887 | ||
|
|
2845d340da | ||
|
|
4c8a8d4ac7 | ||
|
|
c20d085ab5 | ||
|
|
acefd96958 | ||
|
|
f9d950c425 | ||
|
|
db287fb8ac | ||
|
|
11998b6b31 | ||
|
|
f249a935d5 | ||
|
|
46b3e2600f | ||
|
|
3a2a8a06d9 | ||
|
|
c31da91716 | ||
|
|
51520da5af | ||
|
|
5234cbdd61 | ||
|
|
5718deee6b | ||
|
|
43b3df9938 | ||
|
|
38bae34247 | ||
|
|
6357e5794f | ||
|
|
8d26d5b084 |
56 changed files with 4736 additions and 452 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
|||
inventory/static/uploads/*
|
||||
!inventory/static/uploads/.gitkeep
|
||||
.venv/
|
||||
.vscode/
|
||||
.env
|
||||
*.db*
|
||||
*.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]
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
resp = jsonify(obj.as_dict())
|
||||
resp.status_code = 201
|
||||
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
|
||||
resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False)
|
||||
return resp
|
||||
except Exception as e:
|
||||
return _json_error(e)
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ class CRUDService(Generic[T]):
|
|||
opt = opt.load_only(*cols)
|
||||
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,
|
||||
# make sure we actually JOIN it (outer) so filters don’t create a cartesian product.
|
||||
if plan.filter_tables:
|
||||
|
|
@ -422,14 +424,19 @@ class CRUDService(Generic[T]):
|
|||
for rel in mapper.relationships:
|
||||
if rel.uselist:
|
||||
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:
|
||||
continue
|
||||
if target_tbl in plan.filter_tables:
|
||||
if rel.key in joined_rel_keys:
|
||||
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)
|
||||
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
info = []
|
||||
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 typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import crudkit
|
||||
|
||||
_ALLOWED_ATTRS = {
|
||||
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
||||
"min", "max", "step", "maxlength", "minlength",
|
||||
|
|
@ -107,6 +109,53 @@ def register_template_globals(app=None):
|
|||
app.add_template_global(fn, 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):
|
||||
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
||||
out = set(fields)
|
||||
|
|
@ -647,10 +696,12 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
|
|||
if "label_deps" in spec:
|
||||
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 field["type"] is None:
|
||||
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_
|
||||
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 "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
|
||||
|
||||
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 %}
|
||||
<label for="{{ field_name }}"
|
||||
{% 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">
|
||||
{% macro render_row(row) %}
|
||||
<!-- ROW: {{ row['name'] }} -->
|
||||
{% if row.fields or row.children or row.legend %}
|
||||
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
|
||||
<fieldset
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ from __future__ import annotations
|
|||
|
||||
import os, logging, sys
|
||||
|
||||
from flask import Flask
|
||||
from flask import Flask, render_template, request, current_app
|
||||
from jinja_markdown import MarkdownExtension
|
||||
from pathlib import Path
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.pool import Pool
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from werkzeug.middleware.profiler import ProfilerMiddleware
|
||||
|
||||
import crudkit
|
||||
|
|
@ -17,11 +18,13 @@ from crudkit.integrations.flask import init_app
|
|||
from .debug_pretty import init_pretty
|
||||
|
||||
from .routes.entry import init_entry_routes
|
||||
from .routes.image import init_image_routes
|
||||
from .routes.index import init_index_routes
|
||||
from .routes.listing import init_listing_routes
|
||||
from .routes.search import init_search_routes
|
||||
from .routes.settings import init_settings_routes
|
||||
from .routes.reports import init_reports_routes
|
||||
from .routes.testing import init_testing_routes
|
||||
|
||||
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||
app = Flask(__name__)
|
||||
|
|
@ -42,6 +45,27 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
|||
except Exception as e:
|
||||
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)
|
||||
print(f"Effective DB URL: {str(runtime.engine.url)}")
|
||||
|
||||
|
|
@ -69,17 +93,20 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
|||
_models.Inventory,
|
||||
_models.RoomFunction,
|
||||
_models.Room,
|
||||
_models.Status,
|
||||
_models.User,
|
||||
_models.WorkLog,
|
||||
_models.WorkNote,
|
||||
])
|
||||
|
||||
init_entry_routes(app)
|
||||
init_image_routes(app)
|
||||
init_index_routes(app)
|
||||
init_listing_routes(app)
|
||||
init_search_routes(app)
|
||||
init_settings_routes(app)
|
||||
init_reports_routes(app)
|
||||
init_testing_routes(app)
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _remove_session(_exc):
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ from .image import Image
|
|||
from .inventory import Inventory
|
||||
from .room_function import RoomFunction
|
||||
from .room import Room
|
||||
from .status import Status
|
||||
from .user import User
|
||||
from .work_log import WorkLog
|
||||
from .work_note import WorkNote
|
||||
|
||||
__all__ = [
|
||||
"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 sqlalchemy import Boolean, Unicode
|
||||
from sqlalchemy import Boolean, Integer, Unicode
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import expression as sql
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ class DeviceType(Base, CRUDMixin):
|
|||
__tablename__ = 'item'
|
||||
|
||||
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')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, case, cast, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, UnicodeText, case, cast, func
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||
from sqlalchemy.sql import expression as sql
|
||||
|
|
@ -17,9 +17,11 @@ class Inventory(Base, CRUDMixin):
|
|||
name: 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))
|
||||
notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
|
||||
notes: Mapped[Optional[str]] = mapped_column(UnicodeText)
|
||||
shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
|
||||
timestamp: Mapped[DateTime] = mapped_column(DateTime, default=func.now(), nullable=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_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_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__)
|
||||
|
||||
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"]
|
||||
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room", "status"]
|
||||
|
||||
def _fields_for_model(model: str):
|
||||
fields: list[str] = []
|
||||
|
|
@ -27,44 +27,37 @@ def _fields_for_model(model: str):
|
|||
"notes",
|
||||
"owner.id",
|
||||
"image.filename",
|
||||
"image.caption",
|
||||
]
|
||||
fields_spec = [
|
||||
{"name": "label", "type": "display", "label": "", "row": "label",
|
||||
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
|
||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
||||
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
||||
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
||||
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}},
|
||||
{"name": "model", "row": "device", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label": "Model #", "label_attrs": {"class": "form-label"}},
|
||||
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}},
|
||||
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
||||
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}},
|
||||
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
||||
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col form-floating"},
|
||||
"attrs": {"class": "form-control", "placeholder": "Device Name"}, "label_attrs": {"class": "ms-2"}},
|
||||
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col form-floating"},
|
||||
"attrs": {"class": "form-control", "placeholder": "Serial Number"}, "label_attrs": {"class": "ms-2"}},
|
||||
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col form-floating"},
|
||||
"attrs": {"class": "form-control", "placeholder": "Bar Code"}, "label_attrs": {"class": "ms-2"}},
|
||||
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col form-floating"},
|
||||
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "ms-2"}},
|
||||
{"name": "model", "row": "device", "wrap": {"class": "col form-floating"},
|
||||
"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 form-floating"},
|
||||
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "ms-2"}},
|
||||
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col form-floating"},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
|
||||
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}},
|
||||
"options_params": {"active__eq": True}},
|
||||
{"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}"},
|
||||
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col"},
|
||||
"type": "select", "options": [
|
||||
{"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": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col form-floating"},
|
||||
"label_attrs": {"class": "ms-2"}, "label_spec": "{description}"},
|
||||
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
|
||||
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
|
||||
"wrap": {"class": "h-100 w-100"}},
|
||||
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto", "data-model": "inventory"},
|
||||
"wrap": {"class": "d-inline-block position-relative image-wrapper", "style": "min-width: 200px; min-height: 200px;"}},
|
||||
{"name": "notes", "type": "template", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
|
||||
"template": "inventory_note.html"},
|
||||
{"name": "work_logs", "type": "template", "template_ctx": {}, "row": "notes", "wrap": {"class": "col"},
|
||||
|
|
@ -89,30 +82,44 @@ def _fields_for_model(model: str):
|
|||
"title",
|
||||
"active",
|
||||
"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 = [
|
||||
{"name": "label", "row": "label", "label": "", "type": "display",
|
||||
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
|
||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"},
|
||||
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
||||
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"},
|
||||
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
||||
{"name": "title", "label": "Title", "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 form-floating"}},
|
||||
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "ms-2"},
|
||||
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
|
||||
{"name": "title", "label": "Title", "label_attrs": {"class": "ms-2"},
|
||||
"attrs": {"placeholder": "President of the Universe", "class": "form-control"},
|
||||
"row": "name", "wrap": {"class": "col-3"}},
|
||||
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
|
||||
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
|
||||
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
||||
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
|
||||
"row": "name", "wrap": {"class": "col form-floating"}},
|
||||
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "ms-2 link-label"},
|
||||
"label_spec": "{label}", "row": "details", "wrap": {"class": "col form-floating"},
|
||||
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}},
|
||||
"options_params": {"active__eq": True, "staff__eq": True}},
|
||||
{"name": "location", "label": "Room", "label_attrs": {"class": "ms-2"},
|
||||
"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"},
|
||||
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
||||
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
|
||||
"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 = [
|
||||
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||
|
|
@ -120,6 +127,7 @@ def _fields_for_model(model: str):
|
|||
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
|
||||
{"name": "checkboxes", "order": 30, "parent": "details",
|
||||
"attrs": {"class": "col d-flex flex-column justify-content-end"}},
|
||||
{"name": "inventory", "order": 40, "attrs": {"class": "row"}},
|
||||
]
|
||||
|
||||
elif model == "worklog":
|
||||
|
|
@ -134,26 +142,28 @@ def _fields_for_model(model: str):
|
|||
"updates.id",
|
||||
"updates.content",
|
||||
"updates.timestamp",
|
||||
"updates.is_deleted",
|
||||
"updates.is_deleted"
|
||||
]
|
||||
fields_spec = [
|
||||
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
|
||||
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
|
||||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||
{"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact",
|
||||
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
||||
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}},
|
||||
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
|
||||
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
||||
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}},
|
||||
{"name": "contact", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Contact",
|
||||
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
|
||||
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}},
|
||||
"options_params": {"active__eq": True}},
|
||||
{"name": "work_item", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Work Item",
|
||||
"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",
|
||||
"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",
|
||||
"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"},
|
||||
"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"},
|
||||
]
|
||||
layout = [
|
||||
|
|
@ -173,18 +183,43 @@ def _fields_for_model(model: str):
|
|||
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
||||
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
||||
{"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"},
|
||||
"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}",
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details",
|
||||
"wrap": {"class": "col"}},
|
||||
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"}, "row": "details",
|
||||
"wrap": {"class": "col form-floating"}},
|
||||
]
|
||||
layout = [
|
||||
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||
{"name": "name", "order": 10, "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)
|
||||
|
||||
|
|
@ -315,6 +350,11 @@ def init_entry_routes(app):
|
|||
|
||||
payload = normalize_payload(request.get_json(force=True) or {}, cls)
|
||||
|
||||
# Strip caption for inventory so it doesn't hit Inventory(**payload)
|
||||
image_caption = None
|
||||
if model == "inventory":
|
||||
image_caption = payload.pop("caption", None)
|
||||
|
||||
# Child mutations and friendly-to-FK mapping
|
||||
updates = payload.pop("updates", []) or []
|
||||
payload.pop("delete_update_ids", None) # irrelevant on create
|
||||
|
|
@ -368,6 +408,8 @@ def init_entry_routes(app):
|
|||
cls = crudkit.crud.get_model(model)
|
||||
payload = normalize_payload(request.get_json(), cls)
|
||||
|
||||
image_caption = payload.pop("caption", None)
|
||||
|
||||
updates = payload.pop("updates", None) or []
|
||||
delete_ids = payload.pop("delete_update_ids", None) or []
|
||||
|
||||
|
|
@ -426,6 +468,13 @@ def init_entry_routes(app):
|
|||
|
||||
obj = service.update(id, data=payload, actor="update_entry", commit=False)
|
||||
|
||||
if model == "inventory" and image_caption is not None:
|
||||
image_id = payload.get("image_id") or getattr(obj, "image_id", None)
|
||||
if image_id:
|
||||
image_cls = crudkit.crud.get_model("image")
|
||||
image_svc = crudkit.crud.get_service(image_cls)
|
||||
image_svc.update(image_id, {"caption": image_caption})
|
||||
|
||||
if model == "worklog" and (updates or delete_ids):
|
||||
_apply_worklog_updates(obj, updates, delete_ids)
|
||||
|
||||
|
|
|
|||
89
inventory/routes/image.py
Normal file
89
inventory/routes/image.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from pathlib import Path
|
||||
from hashlib import md5
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask import current_app, request, abort, jsonify, url_for, Blueprint
|
||||
|
||||
import crudkit
|
||||
|
||||
bp_image = Blueprint('image', __name__, url_prefix='/api/image')
|
||||
|
||||
def init_image_routes(app):
|
||||
@bp_image.post('/upload')
|
||||
def upload_image():
|
||||
"""
|
||||
Accepts multipart/form-data:
|
||||
- image: file
|
||||
- model: optional model name (e.g "inventory")
|
||||
- caption: optional caption
|
||||
Saves to static/uploads/images/<model>/<hash>_filename
|
||||
Creates Image row via CRUD service and returns it as JSON.
|
||||
"""
|
||||
file = request.files.get("image")
|
||||
if not file or not file.filename:
|
||||
abort(400, "missing image file")
|
||||
|
||||
# Optional, useful to namespace by owner model
|
||||
model_name = (request.form.get("model") or "generic").lower()
|
||||
|
||||
# Normalize filename
|
||||
orig_name = secure_filename(file.filename)
|
||||
|
||||
# Read bytes once so we can hash + save
|
||||
raw = file.read()
|
||||
if not raw:
|
||||
abort(400, "empty file")
|
||||
|
||||
# Hash for stable-ish unique prefix
|
||||
h = md5(raw).hexdigest()[:16]
|
||||
stored_name = f"{h}_{orig_name}"
|
||||
|
||||
# Build path: static/uploads/images/<model_name>/<hash>_filename
|
||||
static_root = Path(current_app.root_path) / "static"
|
||||
rel_dir = Path("uploads") / "images" / model_name
|
||||
abs_dir = static_root / rel_dir
|
||||
abs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
abs_path = abs_dir / stored_name
|
||||
abs_path.write_bytes(raw)
|
||||
|
||||
# What goes in the DB: path relative to /static
|
||||
rel_path = str(rel_dir / stored_name).replace("\\", "/")
|
||||
|
||||
caption = request.form.get("caption", "") or ""
|
||||
image_id = request.form.get("image_id")
|
||||
|
||||
image_model = crudkit.crud.get_model('image')
|
||||
image_svc = crudkit.crud.get_service(image_model)
|
||||
|
||||
if image_id:
|
||||
# Reuse existing row instead of creating a new one
|
||||
image_id_int = int(image_id)
|
||||
# Make sure it exists
|
||||
existing = image_svc.get(image_id_int, {})
|
||||
if existing is not None:
|
||||
image = image_svc.update(image_id_int, {
|
||||
'filename': rel_path,
|
||||
'caption': caption,
|
||||
})
|
||||
else:
|
||||
# Fallback to create if somehow missing
|
||||
image = image_svc.create({
|
||||
'filename': rel_path,
|
||||
'caption': caption,
|
||||
})
|
||||
else:
|
||||
# First time: create new row
|
||||
image = image_svc.create({
|
||||
'filename': rel_path,
|
||||
'caption': caption
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': image.id,
|
||||
'filename': image.filename,
|
||||
'caption': image.caption,
|
||||
'url': url_for('static', filename=image.filename, _external=False)
|
||||
}), 201
|
||||
|
||||
app.register_blueprint(bp_image)
|
||||
|
|
@ -7,6 +7,7 @@ import crudkit
|
|||
|
||||
from crudkit.ui.fragments import render_table
|
||||
|
||||
from ..models.device_type import DeviceType
|
||||
from ..models.inventory import Inventory
|
||||
from ..models.work_log import WorkLog
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ bp_index = Blueprint("index", __name__)
|
|||
def init_index_routes(app):
|
||||
@bp_index.get("/")
|
||||
def index():
|
||||
# 1. work log stuff (leave it)
|
||||
work_log_service = crudkit.crud.get_service(WorkLog)
|
||||
work_logs = work_log_service.list({
|
||||
"complete__ne": 1,
|
||||
|
|
@ -32,10 +34,69 @@ def init_index_routes(app):
|
|||
{"field": "work_item.label", "label": "Work Item",
|
||||
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}
|
||||
]
|
||||
|
||||
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")
|
||||
def license():
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def init_listing_routes(app):
|
|||
limit_qs = request.args.get("limit")
|
||||
page = int(request.args.get("page", 1) or 1)
|
||||
per_page = int(per_page_qs) if (per_page_qs and per_page_qs.isdigit()) else (
|
||||
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 15
|
||||
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 18
|
||||
)
|
||||
sort = request.args.get("sort")
|
||||
fields_qs = request.args.get("fields")
|
||||
|
|
@ -36,7 +36,7 @@ def init_listing_routes(app):
|
|||
if model.lower() == 'inventory':
|
||||
spec = {"fields": [
|
||||
"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 = [
|
||||
{"field": "label"},
|
||||
|
|
@ -46,7 +46,7 @@ def init_listing_routes(app):
|
|||
{"field": "brand.name", "label": "Brand"},
|
||||
{"field": "model"},
|
||||
{"field": "device_type.description", "label": "Device Type"},
|
||||
{"field": "condition"},
|
||||
{"field": "condition.description", "label": "Condition"},
|
||||
{"field": "owner.label", "label": "Contact",
|
||||
"link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
|
||||
{"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
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from crudkit.ui.fragments import render_table
|
||||
|
||||
import crudkit
|
||||
|
||||
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):
|
||||
@bp_reports.get('/summary')
|
||||
def summary():
|
||||
inventory_model = crudkit.crud.get_model('inventory')
|
||||
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({
|
||||
"limit": 0,
|
||||
"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:
|
||||
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(
|
||||
index="device_type.description",
|
||||
columns="condition",
|
||||
columns=cat_col,
|
||||
values="id",
|
||||
aggfunc="count",
|
||||
fill_value=0,
|
||||
)
|
||||
|
||||
# Reorder/exclude like before
|
||||
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
|
||||
exclude = ["Removed", "Disposed"]
|
||||
cols = [c for c in order if c in pt.columns] + [c for c in pt.columns if c not in order and c not in exclude]
|
||||
pt = pt[cols]
|
||||
if "target" in needs.columns:
|
||||
needs["target"] = pd.to_numeric(needs["target"], errors="coerce").astype("Int64")
|
||||
needs = needs.fillna({"target": pd.NA})
|
||||
pt = pt.merge(needs, left_index=True, right_on="description")
|
||||
# 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
|
||||
pt = pt.loc[(pt != 0).any(axis=1)]
|
||||
# Keep a handle on the category columns produced by the pivot BEFORE merge
|
||||
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
|
||||
pt["Total"] = pt.sum(axis=1)
|
||||
total_row = pt.sum(axis=0).to_frame().T
|
||||
total_row.index = ["Total"]
|
||||
if not pt.empty and ordered:
|
||||
# Per-row totals (counts only)
|
||||
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)
|
||||
|
||||
# Names off
|
||||
# Strip pandas names
|
||||
pt.index.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")
|
||||
def q(params: dict | None):
|
||||
return f"{base_list_url}?{urlencode(params)}" if params else None
|
||||
|
||||
def q(h):
|
||||
return f"{base_list_url}?{urlencode(h)}" if h else None
|
||||
columns_for_render = list(pt.columns) if not pt.empty else []
|
||||
|
||||
# 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 = []
|
||||
for col in pt.columns.tolist():
|
||||
if col == "Total":
|
||||
col_headers.append({"label": col, "href": None})
|
||||
for col in columns_for_render:
|
||||
# Only make category columns clickable; planning/Total are informational
|
||||
if col == "Total" or col in planning_cols:
|
||||
col_headers.append({"label": label_for(col), "href": None})
|
||||
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 = []
|
||||
for idx in pt.index.tolist():
|
||||
# Row header link: only if not Total
|
||||
if idx == "Total":
|
||||
row_href = None
|
||||
else:
|
||||
row_href = q({"device_type.description": idx})
|
||||
index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique())
|
||||
for idx in index_for_render:
|
||||
is_total_row = (idx == "Total")
|
||||
row_href = None if is_total_row else q({"device_type.description": idx})
|
||||
|
||||
# Cells: combine filters, respecting Total row/col rules
|
||||
cells = []
|
||||
for col in pt.columns.tolist():
|
||||
val = int(pt.at[idx, col])
|
||||
for col in columns_for_render:
|
||||
# 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 = {}
|
||||
if idx != "Total":
|
||||
if not is_total_row:
|
||||
params["device_type.description"] = idx
|
||||
if col != "Total":
|
||||
params["condition"] = col
|
||||
href = q(params) if params else None # None for Total×Total
|
||||
cells.append({"value": f"{val:,}", "href": href})
|
||||
if col not in ("Total", *planning_cols):
|
||||
params[cat_col] = col
|
||||
href = q(params) if params else None
|
||||
cells.append({"value": s, "href": href})
|
||||
|
||||
table_rows.append({"label": idx, "href": row_href, "cells": cells})
|
||||
|
||||
return render_template(
|
||||
|
|
@ -92,4 +209,83 @@ def init_reports_routes(app):
|
|||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def init_search_routes(app):
|
|||
{"field": "location.label", "label": "Location"},
|
||||
]
|
||||
inventory_results = inventory_service.list({
|
||||
'notes|label|owner.label__icontains': q,
|
||||
'notes|label|model|serial|barcode|name|owner.label__icontains': q,
|
||||
'fields': [
|
||||
"label",
|
||||
"name",
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ def init_settings_routes(app):
|
|||
function_service = crudkit.crud.get_service(function_model)
|
||||
room_model = crudkit.crud.get_model('room')
|
||||
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})
|
||||
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})
|
||||
functions = function_service.list({"sort": "description", "limit": 0})
|
||||
rooms = room_service.list({
|
||||
|
|
@ -42,6 +44,16 @@ def init_settings_routes(app):
|
|||
],
|
||||
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)
|
||||
|
|
|
|||
12
inventory/routes/testing.py
Normal file
12
inventory/routes/testing.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from flask import Blueprint, render_template
|
||||
|
||||
import crudkit
|
||||
|
||||
bp_testing = Blueprint("testing", __name__)
|
||||
|
||||
def init_testing_routes(app):
|
||||
@bp_testing.get('/testing')
|
||||
def test_page():
|
||||
return render_template('testing.html')
|
||||
|
||||
app.register_blueprint(bp_testing)
|
||||
222
inventory/static/css/components/draw.css
Normal file
222
inventory/static/css/components/draw.css
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
:root { --tb-h: 34px; }
|
||||
|
||||
/* =========================================================
|
||||
GRID WIDGET (editor uses container queries, viewer does not)
|
||||
========================================================= */
|
||||
|
||||
/* -------------------------
|
||||
Shared basics (both modes)
|
||||
------------------------- */
|
||||
|
||||
/* drawing stack */
|
||||
.grid-widget [data-grid] {
|
||||
position: relative;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Overlay elements */
|
||||
.grid-widget [data-canvas],
|
||||
.grid-widget [data-dot],
|
||||
.grid-widget [data-coords] { position: absolute; }
|
||||
|
||||
.grid-widget [data-canvas]{
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-widget [data-dot]{
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-widget [data-coords]{
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Toolbar styling
|
||||
------------------------- */
|
||||
|
||||
.grid-widget [data-toolbar].toolbar{
|
||||
display: grid !important;
|
||||
grid-template-rows: auto auto;
|
||||
align-content: start;
|
||||
gap: 0.5rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-widget [data-toolbar] .toolbar-row{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.grid-widget [data-toolbar] .toolbar-row--primary,
|
||||
.grid-widget [data-toolbar] .toolbar-row--secondary{
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.grid-widget [data-toolbar] .toolbar-row--secondary{ opacity: 0.95; }
|
||||
|
||||
/* container query only matters in editor (set below) */
|
||||
@container (min-width: 750px){
|
||||
.grid-widget [data-toolbar].toolbar{
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.grid-widget [data-toolbar] .toolbar-row{ display: contents; }
|
||||
|
||||
.grid-widget [data-toolbar] .toolbar-row--primary,
|
||||
.grid-widget [data-toolbar] .toolbar-row--secondary{ overflow: visible; }
|
||||
}
|
||||
|
||||
.grid-widget [data-toolbar]::-webkit-scrollbar{ height: 8px; }
|
||||
|
||||
.grid-widget .toolbar-group{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.grid-widget .btn,
|
||||
.grid-widget .form-control,
|
||||
.grid-widget .badge{ height: var(--tb-h); }
|
||||
|
||||
.grid-widget [data-toolbar] .badge,
|
||||
.grid-widget [data-toolbar] .input-group-text{ white-space: nowrap; }
|
||||
|
||||
.grid-widget .toolbar .btn{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.grid-widget .toolbar .form-control-color{ width: var(--tb-h); padding: 0; }
|
||||
|
||||
.grid-widget .tb-btn{ flex-direction: column; gap: 2px; line-height: 1; }
|
||||
.grid-widget .tb-btn small{ font-size: 11px; opacity: 0.75; }
|
||||
|
||||
.grid-widget .dropdown-toggle::after{ display: none; }
|
||||
|
||||
.grid-widget .toolbar .btn-group .btn{ border-radius: 0; }
|
||||
.grid-widget .toolbar .btn-group .btn:first-child{
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
}
|
||||
.grid-widget .toolbar .btn-group .btn:last-child{
|
||||
border-top-right-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.grid-widget .btn-check:checked + .btn{
|
||||
background: rgba(0,0,0,0.08);
|
||||
border-color: rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
.grid-widget .dropdown-menu{
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.grid-widget .dropdown-menu .form-range{ width: 100%; margin: 0; }
|
||||
|
||||
/* =========================================================
|
||||
EDITOR MODE (needs container queries)
|
||||
========================================================= */
|
||||
|
||||
.grid-widget[data-mode="editor"]{
|
||||
container-type: inline-size; /* ONLY here */
|
||||
min-width: 375px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid-widget[data-mode="editor"] [data-grid-wrap]{
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 375px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-widget[data-mode="editor"] [data-grid]{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Editor: toolbar should match snapped grid width */
|
||||
.grid-widget[data-mode="editor"] [data-toolbar]{
|
||||
width: var(--grid-maxw, 100%);
|
||||
margin-inline: auto; /* center it to match the centered grid */
|
||||
max-width: 100%;
|
||||
align-self: center; /* don't stretch full parent width */
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
VIEWER MODE (must shrink-wrap like an <img>)
|
||||
========================================================= */
|
||||
|
||||
.grid-widget[data-mode="viewer"]{
|
||||
/* explicitly undo any containment */
|
||||
container-type: normal; /* <-- the money line */
|
||||
contain: none;
|
||||
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* wrap is the sized box (JS sets px) */
|
||||
.grid-widget[data-mode="viewer"] [data-grid-wrap]{
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
line-height: 0; /* remove inline baseline gap */
|
||||
}
|
||||
|
||||
/* grid must be in-flow and fill wrap */
|
||||
.grid-widget[data-mode="viewer"] [data-grid]{
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* viewer hides editor-only overlays */
|
||||
.grid-widget[data-mode="viewer"] [data-coords],
|
||||
.grid-widget[data-mode="viewer"] [data-dot]{ display: none !important; }
|
||||
12
inventory/static/css/components/dropdown.css
Normal file
12
inventory/static/css/components/dropdown.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.inventory-dropdown {
|
||||
border-color: rgb(222, 226, 230) !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.link-label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link-label a {
|
||||
pointer-events: auto;
|
||||
}
|
||||
10
inventory/static/css/components/image_display.css
Normal file
10
inventory/static/css/components/image_display.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.image-buttons {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.image-wrapper:hover .image-buttons {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
132
inventory/static/js/components/dropdown.js
Normal file
132
inventory/static/js/components/dropdown.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
const DropDown = globalThis.DropDown ?? (globalThis.DropDown = {});
|
||||
|
||||
DropDown.utilities = {
|
||||
filterList(id) {
|
||||
value = document.getElementById(`${id}-filter`).value.toLowerCase();
|
||||
list = document.querySelectorAll(`#${id}-dropdown li`);
|
||||
|
||||
list.forEach(item => {
|
||||
const txt = item.textContent.toLowerCase();
|
||||
if (txt.includes(value)) {
|
||||
item.style.display = 'list-item';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectItem(id, value) {
|
||||
const btn = document.getElementById(`${id}-button`);
|
||||
const txt = document.getElementById(`${id}-${value}`).textContent;
|
||||
const inp = document.getElementById(id);
|
||||
|
||||
btn.dataset.value = value;
|
||||
btn.value = txt;
|
||||
|
||||
inp.value = value;
|
||||
},
|
||||
};
|
||||
|
||||
(() => {
|
||||
const VISIBLE_ITEMS = 10;
|
||||
|
||||
function setMenuMaxHeight(buttonEl) {
|
||||
const menu = buttonEl?.nextElementSibling;
|
||||
if (!menu || !menu.classList.contains('dropdown-menu')) return;
|
||||
|
||||
const input = menu.querySelector('input.form-control');
|
||||
const firstItem = menu.querySelector('.dropdown-item');
|
||||
if (!firstItem) return;
|
||||
|
||||
// Measure even if the menu is closed
|
||||
const computed = getComputedStyle(menu);
|
||||
const wasHidden = computed.display === 'none' || computed.visibility === 'hidden';
|
||||
if (wasHidden) {
|
||||
menu.style.visibility = 'hidden';
|
||||
menu.style.display = 'block';
|
||||
}
|
||||
|
||||
const inputH = input ? input.getBoundingClientRect().height : 0;
|
||||
const itemH = firstItem.getBoundingClientRect().height || 0;
|
||||
const itemCount = Math.min(
|
||||
VISIBLE_ITEMS,
|
||||
menu.querySelectorAll('.dropdown-item').length
|
||||
);
|
||||
|
||||
const target = Math.ceil(inputH + itemH * itemCount);
|
||||
menu.style.maxHeight = `${target + 10}px`;
|
||||
menu.style.overflowY = 'auto';
|
||||
|
||||
if (wasHidden) {
|
||||
menu.style.display = '';
|
||||
menu.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
function onShow(e) {
|
||||
// Bootstrap delegated events: currentTarget is document, useless here.
|
||||
const source = e.target;
|
||||
|
||||
// Sanity check: make sure this is an Element before using .closest
|
||||
if (!(source instanceof Element)) {
|
||||
console.warn('Event target is not an Element:', source);
|
||||
return;
|
||||
}
|
||||
|
||||
// Whatever you were doing before
|
||||
setMenuMaxHeight(source);
|
||||
|
||||
// Walk up to the element with data-field
|
||||
const fieldElement = source.closest('[data-field]');
|
||||
if (!fieldElement) {
|
||||
console.warn('No [data-field] ancestor found for', source);
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName = fieldElement.dataset.field;
|
||||
if (!fieldName) {
|
||||
console.warn('Element has no data-field value:', fieldElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.getElementById(`${fieldName}-filter`);
|
||||
if (!input) {
|
||||
console.warn(`No element found with id "${fieldName}-filter"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Let Bootstrap finish its show animation / DOM fiddling
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
if (typeof input.select === 'function') {
|
||||
input.select();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
document.querySelectorAll('.dropdown-toggle[data-bs-toggle="dropdown"]').forEach(btn => {
|
||||
const menu = btn.nextElementSibling;
|
||||
if (menu && menu.classList.contains('dropdown-menu') && menu.classList.contains('show')) {
|
||||
setMenuMaxHeight(btn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init(root = document) {
|
||||
// Delegate so dynamically-added dropdowns work too
|
||||
root.addEventListener('shown.bs.dropdown', onShow);
|
||||
window.addEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// Expose for manyal calls or tests
|
||||
DropDown.utilities.setMenuMaxHeight = setMenuMaxHeight;
|
||||
DropDown.init = init;
|
||||
|
||||
// Auto-init
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => init());
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
438
inventory/static/js/components/grid/encode-decode.js
Normal file
438
inventory/static/js/components/grid/encode-decode.js
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { SHAPE_DEFAULTS } from "./widget-core.js";
|
||||
|
||||
function shortenKeys(shapes) {
|
||||
const keyMap = {
|
||||
type: 't',
|
||||
points: 'p',
|
||||
color: 'cl', // avoid collision with x2
|
||||
strokeWidth: 'sw',
|
||||
strokeOpacity: 'so',
|
||||
fillOpacity: 'fo',
|
||||
fill: 'f',
|
||||
|
||||
x: 'x',
|
||||
y: 'y',
|
||||
w: 'w',
|
||||
h: 'h',
|
||||
|
||||
x1: 'a',
|
||||
y1: 'b',
|
||||
x2: 'c',
|
||||
y2: 'd'
|
||||
};
|
||||
|
||||
return shapes.map((shape) => {
|
||||
const out = {};
|
||||
for (const key of Object.keys(shape)) {
|
||||
const newKey = keyMap[key] || key;
|
||||
out[newKey] = shape[key];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function shortenShapes(shapes) {
|
||||
const shapeMap = { path: 'p', line: 'l', rect: 'r', ellipse: 'e', stateChange: 's' };
|
||||
return shapes.map(shape => ({
|
||||
...shape,
|
||||
type: shapeMap[shape.type] || shape.type
|
||||
}));
|
||||
}
|
||||
|
||||
function collapseStateChanges(shapes) {
|
||||
const out = [];
|
||||
let pending = null;
|
||||
|
||||
const flush = () => {
|
||||
if (pending) out.push(pending);
|
||||
pending = null;
|
||||
};
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === "stateChange") {
|
||||
if (!pending) pending = { ...shape };
|
||||
else {
|
||||
for (const [k, v] of Object.entries(shape)) {
|
||||
if (k !== "type") pending[k] = v;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
flush();
|
||||
out.push(shape);
|
||||
}
|
||||
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function stateCode(shapes, SHAPE_DEFAULTS) {
|
||||
const state = {
|
||||
...SHAPE_DEFAULTS,
|
||||
color: "#000000",
|
||||
fill: false,
|
||||
fillOpacity: 1
|
||||
};
|
||||
|
||||
const styleKeys = Object.keys(state);
|
||||
const out = [];
|
||||
|
||||
for (const shape of shapes) {
|
||||
const s = { ...shape };
|
||||
const stateChange = {};
|
||||
|
||||
for (const key of styleKeys) {
|
||||
if (!(key in s)) continue;
|
||||
|
||||
if (s[key] !== state[key]) {
|
||||
stateChange[key] = s[key];
|
||||
state[key] = s[key];
|
||||
}
|
||||
|
||||
delete s[key];
|
||||
}
|
||||
|
||||
if (Object.keys(stateChange).length > 0) {
|
||||
out.push({ type: "stateChange", ...stateChange });
|
||||
}
|
||||
|
||||
out.push(s);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeDeltas(shapes) {
|
||||
const q = 100;
|
||||
|
||||
const out = [];
|
||||
let prevKind = null;
|
||||
|
||||
let prevBR = null;
|
||||
|
||||
let prevLineEnd = null;
|
||||
|
||||
const MAX_DOC_COORD = 1_000_000;
|
||||
const MAX_INT = MAX_DOC_COORD * q;
|
||||
|
||||
const clampInt = (v) => {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v > MAX_INT) return MAX_INT;
|
||||
if (v < -MAX_INT) return -MAX_INT;
|
||||
return v;
|
||||
};
|
||||
|
||||
const toInt = (n) => clampInt(Math.round(Number(n) * q));
|
||||
|
||||
const resetRun = () => {
|
||||
prevKind = null;
|
||||
prevBR = null;
|
||||
prevLineEnd = null;
|
||||
};
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === "stateChange") {
|
||||
out.push(shape);
|
||||
resetRun();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === "path") {
|
||||
const s = { ...shape };
|
||||
if (!Array.isArray(s.points) || s.points.length === 0) {
|
||||
out.push(s);
|
||||
resetRun();
|
||||
continue;
|
||||
}
|
||||
|
||||
const pts = [toInt(s.points[0].x), toInt(s.points[0].y)];
|
||||
let prev = s.points[0];
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
const cur = s.points[i];
|
||||
pts.push(toInt(cur.x - prev.x), toInt(cur.y - prev.y));
|
||||
prev = cur;
|
||||
}
|
||||
s.points = pts;
|
||||
out.push(s);
|
||||
resetRun();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === "line") {
|
||||
const s = { ...shape };
|
||||
|
||||
const x1 = toInt(s.x1), y1 = toInt(s.y1);
|
||||
const x2 = toInt(s.x2), y2 = toInt(s.y2);
|
||||
|
||||
let arr;
|
||||
if (prevKind !== "line" || !prevLineEnd) {
|
||||
arr = [x1, y1, x2 - x1, y2 - y1];
|
||||
} else {
|
||||
arr = [x1 - prevLineEnd.x2, y1 - prevLineEnd.y2, x2 - x1, y2 - y1];
|
||||
}
|
||||
|
||||
prevKind = "line";
|
||||
prevLineEnd = { x2, y2 };
|
||||
|
||||
delete s.x1; delete s.y1; delete s.x2; delete s.y2;
|
||||
s.points = arr;
|
||||
out.push(s);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === "rect" || shape.type === "ellipse") {
|
||||
const s = { ...shape };
|
||||
|
||||
const x = toInt(s.x), y = toInt(s.y);
|
||||
const w = toInt(s.w), h = toInt(s.h);
|
||||
|
||||
let arr;
|
||||
if (prevKind !== s.type || !prevBR) {
|
||||
arr = [x, y, w, h];
|
||||
} else {
|
||||
arr = [x - prevBR.x, y - prevBR.y, w, h];
|
||||
}
|
||||
|
||||
prevKind = s.type;
|
||||
prevBR = { x: x + w, y: y + h };
|
||||
|
||||
delete s.x; delete s.y; delete s.w; delete s.h;
|
||||
s.points = arr;
|
||||
out.push(s);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(shape);
|
||||
resetRun();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeRuns(shapes) {
|
||||
const out = [];
|
||||
let run = null;
|
||||
|
||||
const flush = () => {
|
||||
if (!run) return;
|
||||
out.push(run);
|
||||
run = null;
|
||||
};
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === 'path' || shape.type === 'stateChange') {
|
||||
flush();
|
||||
out.push(shape);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
run = { ...shape, points: [...shape.points] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === run.type) {
|
||||
run.points.push(...shape.points);
|
||||
} else {
|
||||
flush();
|
||||
run = { ...shape, points: [...shape.points] };
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeStates(shapes) {
|
||||
return shapes.map(shape => {
|
||||
if (shape.type !== 'stateChange') return shape;
|
||||
const re = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
let newShape = {};
|
||||
Object.keys(shape).forEach(key => {
|
||||
if (key === 'strokeOpacity' || key === 'strokeWidth' || key === 'fillOpacity') {
|
||||
const v = Number(shape[key]);
|
||||
if (Number.isFinite(v))
|
||||
newShape[key] = Math.round(v * 100);
|
||||
} else if (key === 'color') {
|
||||
newShape[key] = re.test(shape[key]) ? shape[key] : '#000000';
|
||||
} else if (key === 'fill') {
|
||||
newShape[key] = !!shape[key];
|
||||
}
|
||||
});
|
||||
return { ...shape, ...newShape };
|
||||
});
|
||||
}
|
||||
|
||||
export function encode({ cellSize, shapes, stripCaches, SHAPE_DEFAULTS }) {
|
||||
if (!SHAPE_DEFAULTS) SHAPE_DEFAULTS = { strokeWidth: 0.12, strokeOpacity: 1, fillOpacity: 1 };
|
||||
|
||||
const cs = Number(cellSize);
|
||||
const safeCellSize = Number.isFinite(cs) && cs >= 1 ? cs : 25;
|
||||
|
||||
const safeShapes = Array.isArray(shapes) ? shapes : [];
|
||||
const stripped = (typeof stripCaches === "function") ? stripCaches(safeShapes) : safeShapes;
|
||||
|
||||
const payload = {
|
||||
v: 1,
|
||||
cs: safeCellSize,
|
||||
q: 100,
|
||||
d: {
|
||||
cl: "#000000",
|
||||
f: false,
|
||||
sw: 12,
|
||||
so: 100,
|
||||
fo: 100
|
||||
},
|
||||
s: shortenKeys(
|
||||
shortenShapes(
|
||||
encodeStates(
|
||||
encodeRuns(
|
||||
computeDeltas(
|
||||
collapseStateChanges(
|
||||
stateCode(stripped, SHAPE_DEFAULTS)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
function decodePath(arr, q) {
|
||||
let x = arr[0], y = arr[1];
|
||||
const pts = [{ x: x / q, y: y / q }];
|
||||
for (let i = 2; i < arr.length; i += 2) {
|
||||
x += arr[i];
|
||||
y += arr[i + 1];
|
||||
pts.push({ x: x / q, y: y / q });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
export function decode(doc) {
|
||||
const q = Number(doc?.q) || 100;
|
||||
const cs = Number(doc?.cs) || 25;
|
||||
|
||||
const defaults = doc?.d || {};
|
||||
const state = {
|
||||
color: defaults.cl ?? "#000000",
|
||||
fill: !!defaults.f,
|
||||
strokeWidth: (Number(defaults.sw) ?? 12) / 100,
|
||||
strokeOpacity: (Number(defaults.so) ?? 100) / 100,
|
||||
fillOpacity: (Number(defaults.fo) ?? 100) / 100
|
||||
};
|
||||
|
||||
const outShapes = [];
|
||||
|
||||
const num01 = (v, fallback) => {};
|
||||
|
||||
const applyStateChange = (op) => {
|
||||
if ("cl" in op) state.color = op.cl;
|
||||
if ("f" in op) state.fill = !!op.f;
|
||||
if ("sw" in op) state.strokeWidth = num01(op.sw, state.strokeWidth * 100) / 100;
|
||||
if ("so" in op) state.strokeOpacity = num01(op.so, state.strokeOpacity * 100) / 100;
|
||||
if ("fo" in op) state.fillOpacity = num01(op.fo, state.fillOpacity * 100) / 100;
|
||||
};
|
||||
|
||||
const ops = Array.isArray(doc?.s) ? doc.s : [];
|
||||
for (const op of ops) {
|
||||
if (!op || typeof op !== "object") continue;
|
||||
|
||||
const t = op.t;
|
||||
if (t === "s") {
|
||||
applyStateChange(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
const arr = op.p;
|
||||
if (!Array.isArray(arr) || arr.length === 0) continue;
|
||||
|
||||
if (t === "p") {
|
||||
if (arr.length < 2 || (arr.length % 2) !== 0) continue;
|
||||
|
||||
outShapes.push({
|
||||
type: "path",
|
||||
points: decodePath(arr, q),
|
||||
color: state.color,
|
||||
strokeWidth: state.strokeWidth,
|
||||
strokeOpacity: state.strokeOpacity
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((arr.length % 4) !== 0) continue;
|
||||
|
||||
if (t === "l") {
|
||||
let prevX2 = null, prevY2 = null;
|
||||
|
||||
for (let i = 0; i < arr.length; i += 4) {
|
||||
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
|
||||
|
||||
let x1, y1;
|
||||
if (i === 0) {
|
||||
x1 = a; y1 = b;
|
||||
} else {
|
||||
x1 = prevX2 + a;
|
||||
y1 = prevY2 + b;
|
||||
}
|
||||
|
||||
const x2 = x1 + c;
|
||||
const y2 = y1 + d;
|
||||
|
||||
outShapes.push({
|
||||
type: "line",
|
||||
x1: x1 / q, y1: y1 / q, x2: x2 / q, y2: y2 / q,
|
||||
color: state.color,
|
||||
strokeWidth: state.strokeWidth,
|
||||
strokeOpacity: state.strokeOpacity
|
||||
});
|
||||
|
||||
prevX2 = x2; prevY2 = y2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t === "r" || t === "e") {
|
||||
let prevBRx = null, prevBRy = null;
|
||||
|
||||
for (let i = 0; i < arr.length; i += 4) {
|
||||
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
|
||||
|
||||
let x, y;
|
||||
if (i === 0) {
|
||||
x = a; y = b;
|
||||
} else {
|
||||
x = prevBRx + a;
|
||||
y = prevBRy + b;
|
||||
}
|
||||
|
||||
const w = c, h = d;
|
||||
|
||||
outShapes.push({
|
||||
type: (t === "r") ? "rect" : "ellipse",
|
||||
x: x / q, y: y / q, w: w / q, h: h / q,
|
||||
color: state.color,
|
||||
fill: state.fill,
|
||||
fillOpacity: state.fillOpacity,
|
||||
strokeWidth: state.strokeWidth,
|
||||
strokeOpacity: state.strokeOpacity
|
||||
});
|
||||
|
||||
prevBRx = x + w;
|
||||
prevBRy = y + h;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: Number(doc?.v) || 1,
|
||||
cellSize: cs,
|
||||
shapes: outShapes
|
||||
};
|
||||
}
|
||||
117
inventory/static/js/components/grid/geometry.js
Normal file
117
inventory/static/js/components/grid/geometry.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
export function dist2(a, b) {
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
export function pointToSegmentDist2(p, a, b) {
|
||||
const vx = b.x - a.x, vy = b.y - a.y;
|
||||
const wx = p.x - a.x, wy = p.y - a.y;
|
||||
|
||||
const c1 = vx * wx + vy * wy;
|
||||
if (c1 <= 0) return dist2(p, a);
|
||||
|
||||
const c2 = vx * vx + vy * vy;
|
||||
if (c2 <= c1) return dist2(p, b);
|
||||
|
||||
const t = c1 / c2;
|
||||
const proj = { x: a.x + t * vx, y: a.y + t * vy };
|
||||
return dist2(p, proj);
|
||||
}
|
||||
|
||||
function hitShape(p, s, tol) {
|
||||
if (s.type === 'line') {
|
||||
const a = { x: s.x1, y: s.y1 };
|
||||
const b = { x: s.x2, y: s.y2 };
|
||||
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
|
||||
const t = tol + sw;
|
||||
return pointToSegmentDist2(p, a, b) <= (t * t);
|
||||
}
|
||||
|
||||
if (s.type === 'path') {
|
||||
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
|
||||
if (!pts || pts.length < 2) return false;
|
||||
|
||||
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
|
||||
const t = tol + sw;
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
if (pointToSegmentDist2(p, pts[i], pts[i + 1]) <= (t * t)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s.type === 'rect') {
|
||||
return hitRect(p, s, tol);
|
||||
}
|
||||
|
||||
if (s.type === 'ellipse') {
|
||||
return hitEllipse(p, s, tol);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hitRect(p, r, tol) {
|
||||
const x1 = r.x, y1 = r.y, x2 = r.x + r.w, y2 = r.y + r.h;
|
||||
const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);
|
||||
const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);
|
||||
|
||||
const inside = (p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY);
|
||||
|
||||
if (r.fill) {
|
||||
return (p.x >= minX - tol && p.x <= maxX + tol && p.y >= minY - tol && p.y <= maxY + tol);
|
||||
}
|
||||
|
||||
if (!inside) {
|
||||
if (p.x < minX - tol || p.x > maxX + tol || p.y < minY - tol || p.y > maxY + tol) return false;
|
||||
}
|
||||
|
||||
const nearLeft = Math.abs(p.x - minX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
|
||||
const nearRight = Math.abs(p.x - maxX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
|
||||
const nearTop = Math.abs(p.y - minY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
|
||||
const nearBottom = Math.abs(p.y - maxY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
|
||||
|
||||
return nearLeft || nearRight || nearTop || nearBottom;
|
||||
}
|
||||
|
||||
function hitEllipse(p, e, tol) {
|
||||
const cx = e.x + e.w / 2;
|
||||
const cy = e.y + e.h / 2;
|
||||
const rx = Math.abs(e.w / 2);
|
||||
const ry = Math.abs(e.h / 2);
|
||||
if (rx <= 0 || ry <= 0) return false;
|
||||
|
||||
const nx = (p.x - cx) / rx;
|
||||
const ny = (p.y - cy) / ry;
|
||||
const d = nx * nx + ny * ny;
|
||||
|
||||
if (e.fill) {
|
||||
const rx2 = (rx + tol);
|
||||
const ry2 = (ry + tol);
|
||||
const nnx = (p.x - cx) / rx2;
|
||||
const nny = (p.y - cy) / ry2;
|
||||
return (nnx * nnx + nny * nny) <= 1;
|
||||
}
|
||||
|
||||
const minR = Math.max(1e-6, Math.min(rx, ry));
|
||||
const band = tol / minR;
|
||||
return Math.abs(d - 1) <= Math.max(0.02, band);
|
||||
}
|
||||
|
||||
export function pickShapeAt(docPt, shapes, cellSize, opts = {}) {
|
||||
const pxTol = opts.pxTol ?? 6;
|
||||
const cs = Number(cellSize);
|
||||
const safeCellSize = (Number.isFinite(cs) && cs > 0) ? cs : 25;
|
||||
|
||||
const tol = pxTol / safeCellSize;
|
||||
|
||||
for (let i = shapes.length - 1; i >= 0; i--) {
|
||||
const s = shapes[i];
|
||||
if (!s) continue;
|
||||
|
||||
if (hitShape(docPt, s, tol)) {
|
||||
return { index: i, shape: s };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
23
inventory/static/js/components/grid/global-bindings.js
Normal file
23
inventory/static/js/components/grid/global-bindings.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
(function bindGridGlobalOnce() {
|
||||
if (window.__gridGlobalBound) return;
|
||||
window.__gridGlobalBound = true;
|
||||
|
||||
window.activeGridWidget = null;
|
||||
|
||||
// Keydown (undo/redo, escape)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const w = window.activeGridWidget;
|
||||
if (!w || typeof w.handleKeyDown !== 'function') return;
|
||||
w.handleKeyDown(e);
|
||||
});
|
||||
|
||||
// Pointer finalize (for drawing finishing outside the element)
|
||||
const forwardPointer = (e) => {
|
||||
const w = window.__gridPointerOwner || window.activeGridWidget;
|
||||
if (!w || typeof w.handleGlobalPointerUp !== 'function') return;
|
||||
w.handleGlobalPointerUp(e);
|
||||
};
|
||||
|
||||
window.addEventListener('pointerup', forwardPointer, { capture: true });
|
||||
window.addEventListener('pointercancel', forwardPointer, { capture: true });
|
||||
})();
|
||||
46
inventory/static/js/components/grid/index.js
Normal file
46
inventory/static/js/components/grid/index.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import './global-bindings.js';
|
||||
import { initGridWidget } from './widget-init.js';
|
||||
|
||||
const GRID_BOOT = window.__gridBootMap || (window.__gridBootMap = new WeakMap());
|
||||
|
||||
(function autoBootGridWidgets() {
|
||||
function bootRoot(root) {
|
||||
if (GRID_BOOT.has(root)) return;
|
||||
GRID_BOOT.set(root, true);
|
||||
|
||||
const mode = root.dataset.mode || 'editor';
|
||||
const storageKey = root.dataset.storageKey || root.dataset.key || 'gridDoc';
|
||||
|
||||
const api = initGridWidget(root, { mode, storageKey });
|
||||
root.__gridApi = api;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-grid-widget]').forEach(bootRoot);
|
||||
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.removedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
|
||||
const roots = [];
|
||||
if (node.matches?.('[data-grid-widget]')) roots.push(node);
|
||||
node.querySelectorAll?.('[data-grid-widget]').forEach(r => roots.push(r));
|
||||
|
||||
for (const r of roots) {
|
||||
r.__gridApi?.destroy?.();
|
||||
r.__gridApi = null;
|
||||
GRID_BOOT.delete(r);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of m.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
|
||||
if (node.matches?.('[data-grid-widget]')) bootRoot(node);
|
||||
node.querySelectorAll?.('[data-grid-widget]').forEach(bootRoot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
42
inventory/static/js/components/grid/simplify.js
Normal file
42
inventory/static/js/components/grid/simplify.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { pointToSegmentDist2 } from './geometry.js';
|
||||
|
||||
export function simplifyRDP(points, epsilon) {
|
||||
if (!Array.isArray(points) || points.length < 3) return points || [];
|
||||
const e = Number(epsilon);
|
||||
const eps2 = Number.isFinite(e) ? e * e : 0;
|
||||
|
||||
function rdp(first, last, out) {
|
||||
let maxD2 = 0;
|
||||
let idx = -1;
|
||||
|
||||
const a = points[first];
|
||||
const b = points[last];
|
||||
|
||||
for (let i = first + 1; i < last; ++i) {
|
||||
const d2 = pointToSegmentDist2(points[i], a, b);
|
||||
if (d2 > maxD2) {
|
||||
maxD2 = d2;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxD2 > eps2 && idx !== -1) {
|
||||
rdp(first, idx, out);
|
||||
out.pop();
|
||||
rdp(idx, last, out);
|
||||
} else {
|
||||
out.push(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
const out = [];
|
||||
rdp(0, points.length - 1, out);
|
||||
|
||||
const deduped = [out[0]];
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
const cur = out[i];
|
||||
if (prev.x !== cur.x || prev.y !== cur.y) deduped.push(cur);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
90
inventory/static/js/components/grid/spline.js
Normal file
90
inventory/static/js/components/grid/spline.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
export function catmullRomResample(points, {
|
||||
alpha = 0.5,
|
||||
samplesPerSeg = 8,
|
||||
maxSamplesPerSeg = 32,
|
||||
minSamplesPerSeg = 4,
|
||||
closed = false,
|
||||
maxOutputPoints = 5000
|
||||
} = {}) {
|
||||
if (!Array.isArray(points) || points.length < 2) return points || [];
|
||||
|
||||
const dist = (a, b) => {
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
return Math.hypot(dx, dy);
|
||||
};
|
||||
|
||||
const tj = (ti, pi, pj) => ti + Math.pow(dist(pi, pj), alpha);
|
||||
|
||||
const lerp2 = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
|
||||
function evalSegment(p0, p1, p2, p3, t) {
|
||||
let t0 = 0;
|
||||
let t1 = tj(t0, p0, p1);
|
||||
let t2 = tj(t1, p1, p2);
|
||||
let t3 = tj(t2, p2, p3);
|
||||
|
||||
const eps = 1e-6;
|
||||
if (t1 - t0 < eps) t1 = t0 + eps;
|
||||
if (t2 - t1 < eps) t2 = t1 + eps;
|
||||
if (t3 - t2 < eps) t3 = t2 + eps;
|
||||
|
||||
const u = t1 + (t2 - t1) * t;
|
||||
|
||||
const A1 = lerp2(p0, p1, (u - t0) / (t1 - t0));
|
||||
const A2 = lerp2(p1, p2, (u - t1) / (t2 - t1));
|
||||
const A3 = lerp2(p2, p3, (u - t2) / (t3 - t2));
|
||||
|
||||
const B1 = lerp2(A1, A2, (u - t0) / (t2 - t0));
|
||||
const B2 = lerp2(A2, A3, (u - t1) / (t3 - t1));
|
||||
|
||||
const C = lerp2(B1, B2, (u - t1) / (t2 - t1));
|
||||
return C;
|
||||
}
|
||||
|
||||
const src = (points || []).filter(p =>
|
||||
p && Number.isFinite(p.x) && Number.isFinite(p.y)
|
||||
);
|
||||
if (src.length < 2) return src;
|
||||
|
||||
const n = src.length;
|
||||
|
||||
const get = (i) => {
|
||||
if (closed) {
|
||||
const k = (i % n + n) % n;
|
||||
return src[k];
|
||||
}
|
||||
if (i < 0) return src[0];
|
||||
if (i >= n) return src[n - 1];
|
||||
return src[i];
|
||||
};
|
||||
|
||||
const out = [];
|
||||
const pushPoint = (p) => {
|
||||
if (out.length >= maxOutputPoints) return false;
|
||||
const prev = out[out.length - 1];
|
||||
if (!prev || prev.x !== p.x || prev.y !== p.y) out.push(p);
|
||||
return true;
|
||||
};
|
||||
|
||||
pushPoint({ x: src[0].x, y: src[0].y });
|
||||
|
||||
const segCount = closed ? n : (n - 1);
|
||||
for (let i = 0; i < segCount; i++) {
|
||||
const p0 = get(i - 1);
|
||||
const p1 = get(i);
|
||||
const p2 = get(i + 1);
|
||||
const p3 = get(i + 2);
|
||||
|
||||
const segLen = dist(p1, p2);
|
||||
const adaptive = Math.round(samplesPerSeg * Math.max(1, segLen * 0.75));
|
||||
const steps = Math.max(minSamplesPerSeg, Math.min(maxSamplesPerSeg, adaptive));
|
||||
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const p = evalSegment(p0, p1, p2, p3, t);
|
||||
if (!pushPoint(p)) return out;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
472
inventory/static/js/components/grid/widget-core.js
Normal file
472
inventory/static/js/components/grid/widget-core.js
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
import { catmullRomResample } from './spline.js';
|
||||
|
||||
export const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
|
||||
export const SHAPE_DEFAULTS = {
|
||||
strokeWidth: 0.12,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1
|
||||
};
|
||||
|
||||
export function createWidgetCore(env) {
|
||||
let {
|
||||
root, mode, storageKey,
|
||||
gridEl, canvasEl,
|
||||
viewerOffset = { x: 0, y: 0 },
|
||||
} = env;
|
||||
|
||||
let doc = env.doc || structuredClone(DEFAULT_DOC);
|
||||
let cellSize = Number(env.cellSize) || 25;
|
||||
let shapes = Array.isArray(env.shapes) ? env.shapes : [];
|
||||
let selectedShape = env.selectedShape || null;
|
||||
|
||||
let ctx = null;
|
||||
let dpr = 1;
|
||||
|
||||
function clamp01(n, fallback = 1) {
|
||||
const x = Number(n);
|
||||
return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback;
|
||||
}
|
||||
|
||||
function isFiniteNum(n) { return Number.isFinite(Number(n)); }
|
||||
|
||||
// Document and shape lifecycle
|
||||
|
||||
function loadDoc() {
|
||||
try {
|
||||
const raw = env.loadRaw
|
||||
? env.loadRaw()
|
||||
: localStorage.getItem(storageKey);
|
||||
|
||||
return raw ? JSON.parse(raw) : structuredClone(DEFAULT_DOC);
|
||||
} catch {
|
||||
return structuredClone(DEFAULT_DOC);
|
||||
}
|
||||
}
|
||||
|
||||
function saveDoc(nextDoc = doc) {
|
||||
const safeDoc = {
|
||||
...nextDoc,
|
||||
shapes: stripCaches(Array.isArray(nextDoc.shapes) ? nextDoc.shapes : [])
|
||||
};
|
||||
doc = safeDoc;
|
||||
|
||||
const raw = JSON.stringify(safeDoc);
|
||||
|
||||
try {
|
||||
if (env.saveRaw) env.saveRaw(raw);
|
||||
else localStorage.setItem(storageKey, raw);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function setDoc(nextDoc) {
|
||||
const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC;
|
||||
cellSize = Number(d.cellSize) || 25;
|
||||
shapes = rebuildPathCaches(
|
||||
sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : [])
|
||||
);
|
||||
doc = { version: Number(d.version) || 1, cellSize, shapes };
|
||||
|
||||
if (mode === 'editor') {
|
||||
saveDoc(doc);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => resizeAndSetupCanvas());
|
||||
}
|
||||
|
||||
function sanitizeShapes(list) {
|
||||
const allowed = new Set(['rect', 'ellipse', 'line', 'path']);
|
||||
|
||||
const normStroke = (v, fallback = 0.12) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.max(0, n);
|
||||
};
|
||||
|
||||
return list.flatMap((s) => {
|
||||
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
|
||||
|
||||
const color = typeof s.color === 'string' ? s.color : '#000000';
|
||||
const fillOpacity = clamp01(s.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
||||
const strokeOpacity = clamp01(s.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
|
||||
if (s.type === 'line') {
|
||||
if (!['x1', 'y1', 'x2', 'y2'].every(k => isFiniteNum(s[k]))) return [];
|
||||
return [{
|
||||
type: 'line',
|
||||
x1: +s.x1, y1: +s.y1, x2: +s.x2, y2: +s.y2,
|
||||
color,
|
||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
||||
strokeOpacity
|
||||
}];
|
||||
}
|
||||
|
||||
if (s.type === 'path') {
|
||||
if (!Array.isArray(s.points) || s.points.length < 2) return [];
|
||||
|
||||
const points = s.points.flatMap(p => {
|
||||
if (!p || !isFiniteNum(p.x) || !isFiniteNum(p.y)) return [];
|
||||
return [{ x: +p.x, y: +p.y }];
|
||||
});
|
||||
if (points.length < 2) return [];
|
||||
|
||||
return [{
|
||||
type: 'path',
|
||||
points,
|
||||
color,
|
||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
||||
strokeOpacity
|
||||
}];
|
||||
}
|
||||
|
||||
if (!['x', 'y', 'w', 'h'].every(k => isFiniteNum(s[k]))) return [];
|
||||
return [{
|
||||
type: s.type,
|
||||
x: +s.x, y: +s.y, w: +s.w, h: +s.h,
|
||||
color,
|
||||
fill: !!s.fill,
|
||||
fillOpacity,
|
||||
strokeOpacity,
|
||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function stripCaches(shapes) {
|
||||
return shapes.map(s => {
|
||||
if (s.type === 'path') {
|
||||
return {
|
||||
type: 'path',
|
||||
points: s.points,
|
||||
color: s.color,
|
||||
strokeWidth: s.strokeWidth,
|
||||
strokeOpacity: s.strokeOpacity
|
||||
};
|
||||
}
|
||||
if (s.type === 'line') {
|
||||
return {
|
||||
type: 'line',
|
||||
x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2,
|
||||
color: s.color,
|
||||
strokeWidth: s.strokeWidth,
|
||||
strokeOpacity: s.strokeOpacity
|
||||
};
|
||||
}
|
||||
if (s.type === 'rect' || s.type === 'ellipse') {
|
||||
return {
|
||||
type: s.type,
|
||||
x: s.x, y: s.y, w: s.w, h: s.h,
|
||||
color: s.color,
|
||||
fill: !!s.fill,
|
||||
fillOpacity: s.fillOpacity,
|
||||
strokeOpacity: s.strokeOpacity,
|
||||
strokeWidth: s.strokeWidth
|
||||
};
|
||||
}
|
||||
return s; // shouldn't happen
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildPathCaches(list) {
|
||||
const MIN_PTS_FOR_SMOOTH = 4;
|
||||
const MIN_LEN = 2;
|
||||
const MIN_TURN = 0.15;
|
||||
|
||||
return list.map(s => {
|
||||
if (s.type !== 'path') return s;
|
||||
|
||||
const pts = s.points;
|
||||
if (!Array.isArray(s.points) || pts.length < 2) return s;
|
||||
if (!pts.every(p => p && Number.isFinite(p.x) && Number.isFinite(p.y))) return s;
|
||||
|
||||
if (pathLength(pts) < MIN_LEN) return s;
|
||||
|
||||
if (pts.length < MIN_PTS_FOR_SMOOTH) return s;
|
||||
|
||||
if (MIN_TURN != null && totalTurning(pts) < MIN_TURN) return s;
|
||||
|
||||
const renderPoints = catmullRomResample(s.points, {
|
||||
alpha: 0.5,
|
||||
samplesPerSeg: 10,
|
||||
maxSamplesPerSeg: 40,
|
||||
minSamplesPerSeg: 6,
|
||||
closed: false,
|
||||
maxOutputPoints: 4000
|
||||
});
|
||||
|
||||
return {
|
||||
...s,
|
||||
...(renderPoints?.length >= 2 ? { renderPoints } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function totalTurning(points) {
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
|
||||
const v1x = p1.x - p0.x;
|
||||
const v1y = p1.y - p0.y;
|
||||
const v2x = p2.x - p1.x;
|
||||
const v2y = p2.y - p1.y;
|
||||
|
||||
const len1 = Math.hypot(v1x, v1y);
|
||||
const len2 = Math.hypot(v2x, v2y);
|
||||
|
||||
if (len1 === 0 || len2 === 0) continue;
|
||||
|
||||
const cross = Math.abs(v1x * v2y - v1y * v2x);
|
||||
|
||||
sum += cross / (len1 * len2);
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
function pathLength(pts) {
|
||||
let L = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dx = pts[i].x - pts[i - 1].x;
|
||||
const dy = pts[i].y - pts[i - 1].y;
|
||||
L += Math.hypot(dx, dy);
|
||||
}
|
||||
return L;
|
||||
}
|
||||
|
||||
function getShapesBounds(shapes) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
const expand = (x1, y1, x2, y2) => {
|
||||
minX = Math.min(minX, x1);
|
||||
minY = Math.min(minY, y1);
|
||||
maxX = Math.max(maxX, x2);
|
||||
maxY = Math.max(maxY, y2);
|
||||
};
|
||||
|
||||
for (const s of shapes || []) {
|
||||
if (!s) continue;
|
||||
|
||||
if (s.type === 'rect' || s.type === 'ellipse') {
|
||||
expand(s.x, s.y, s.x + s.w, s.y + s.h);
|
||||
} else if (s.type === 'line') {
|
||||
expand(
|
||||
Math.min(s.x1, s.x2), Math.min(s.y1, s.y2),
|
||||
Math.max(s.x1, s.x2), Math.max(s.y1, s.y2)
|
||||
);
|
||||
} else if (s.type === 'path') {
|
||||
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
|
||||
if (!pts?.length) continue;
|
||||
for (const p of pts) expand(p.x, p.y, p.x, p.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX)) return null;
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
// Canvas pipeline
|
||||
|
||||
function resizeAndSetupCanvas() {
|
||||
dpr = window.devicePixelRatio || 1;
|
||||
|
||||
const w = gridEl.clientWidth;
|
||||
const h = gridEl.clientHeight;
|
||||
|
||||
canvasEl.width = Math.round(w * dpr);
|
||||
canvasEl.height = Math.round(h * dpr);
|
||||
|
||||
canvasEl.style.width = `${w}px`;
|
||||
canvasEl.style.height = `${h}px`;
|
||||
|
||||
ctx = canvasEl.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
redrawAll();
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
|
||||
}
|
||||
|
||||
function drawShape(shape) {
|
||||
if (!ctx) return;
|
||||
const toPx = (v) => v * cellSize;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = shape.color || '#000000';
|
||||
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
||||
|
||||
if (shape.type === 'rect' || shape.type === 'ellipse') {
|
||||
const x = toPx(shape.x);
|
||||
const y = toPx(shape.y);
|
||||
const w = toPx(shape.w);
|
||||
const h = toPx(shape.h);
|
||||
|
||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
if (shape.type === 'rect') {
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
} else {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (shape.fill) {
|
||||
ctx.globalAlpha = clamp01(shape.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
||||
ctx.fillStyle = shape.color;
|
||||
if (shape.type === 'rect') {
|
||||
ctx.fillRect(x, y, w, h);
|
||||
} else {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
} else if (shape.type === 'line') {
|
||||
const x1 = toPx(shape.x1);
|
||||
const y1 = toPx(shape.y1);
|
||||
const x2 = toPx(shape.x2);
|
||||
const y2 = toPx(shape.y2);
|
||||
|
||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (shape.type === 'path') {
|
||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
|
||||
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
const pts = (shape.renderPoints && shape.renderPoints.length >= 2)
|
||||
? shape.renderPoints
|
||||
: shape.points;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toPx(pts[0].x), toPx(pts[0].y));
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(toPx(pts[i].x), toPx(pts[i].y));
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function redrawAll() {
|
||||
if (!ctx || !shapes) return;
|
||||
|
||||
clearCanvas();
|
||||
|
||||
ctx.save();
|
||||
if (mode !== 'editor') {
|
||||
ctx.translate(viewerOffset.x, viewerOffset.y);
|
||||
}
|
||||
shapes.forEach(drawShape);
|
||||
|
||||
if (mode === 'editor' && selectedShape) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([6, 4]);
|
||||
drawShape({
|
||||
...selectedShape,
|
||||
fill: false,
|
||||
strokeWidth: Math.max(selectedShape.strokeWidth ?? 0.12, 0.12) + (2 / cellSize),
|
||||
strokeOpacity: 1
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderAllWithPreview(previewShape = null, dashed = true) {
|
||||
if (!ctx) return;
|
||||
clearCanvas();
|
||||
shapes.forEach(drawShape);
|
||||
|
||||
if (!previewShape) return;
|
||||
|
||||
ctx.save();
|
||||
if (dashed) ctx.setLineDash([5, 3]);
|
||||
drawShape(previewShape);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Coordinate conversion
|
||||
|
||||
function pxToGrid(v) {
|
||||
return v / cellSize;
|
||||
}
|
||||
|
||||
function pxToDocPoint(clientX, clientY) {
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const x = Math.min(Math.max(clientX, rect.left), rect.right) - rect.left;
|
||||
const y = Math.min(Math.max(clientY, rect.top), rect.bottom) - rect.top;
|
||||
return { x: pxToGrid(x), y: pxToGrid(y) };
|
||||
}
|
||||
|
||||
// Tool state helpers
|
||||
|
||||
function getActiveTool() {
|
||||
const checked = root.querySelector('input[data-tool]:checked');
|
||||
return checked ? checked.value : 'pen';
|
||||
}
|
||||
|
||||
function setActiveTool(toolValue) {
|
||||
const el = root.querySelector(`input[data-tool][value="${CSS.escape(toolValue)}"]`);
|
||||
if (el) el.checked = true;
|
||||
}
|
||||
|
||||
function getActiveType() {
|
||||
const checked = root.querySelector('input[data-gridtype]:checked');
|
||||
return checked ? checked.value : 'noGrid';
|
||||
}
|
||||
|
||||
function setActiveType(typeValue) {
|
||||
const el = root.querySelector(`input[data-gridtype][value="${CSS.escape(typeValue)}"]`);
|
||||
if (el) el.checked = true;
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULT_DOC,
|
||||
SHAPE_DEFAULTS,
|
||||
|
||||
get doc() { return doc; },
|
||||
get cellSize() { return cellSize; },
|
||||
get shapes() { return shapes; },
|
||||
get ctx() { return ctx; },
|
||||
get selectedShape() { return selectedShape; },
|
||||
set selectedShape(v) { selectedShape = v; },
|
||||
set viewerOffset(v) { viewerOffset = v; },
|
||||
|
||||
loadDoc, saveDoc, setDoc,
|
||||
sanitizeShapes, stripCaches, rebuildPathCaches,
|
||||
getShapesBounds,
|
||||
|
||||
resizeAndSetupCanvas,
|
||||
redrawAll,
|
||||
renderAllWithPreview,
|
||||
pxToDocPoint,
|
||||
|
||||
getActiveTool, setActiveTool,
|
||||
getActiveType, setActiveType,
|
||||
|
||||
clamp01, pxToGrid, isFiniteNum,
|
||||
};
|
||||
}
|
||||
831
inventory/static/js/components/grid/widget-editor.js
Normal file
831
inventory/static/js/components/grid/widget-editor.js
Normal file
|
|
@ -0,0 +1,831 @@
|
|||
import { encode, decode } from './encode-decode.js';
|
||||
import { dist2, pickShapeAt } from './geometry.js';
|
||||
import { simplifyRDP } from './simplify.js';
|
||||
import { SHAPE_DEFAULTS } from './widget-core.js';
|
||||
|
||||
export function initWidgetEditor(core, env) {
|
||||
const { root, gridEl, gridWrapEl, toastMessage, storageKey } = env;
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
const clearEl = root.querySelector('[data-clear]');
|
||||
const colorEl = root.querySelector('[data-color]');
|
||||
const coordsEl = root.querySelector('[data-coords]');
|
||||
const dotEl = root.querySelector('[data-dot]');
|
||||
const dotSVGEl = root.querySelector('[data-dot-svg]');
|
||||
const exportEl = root.querySelector('[data-export]');
|
||||
const importButtonEl = root.querySelector('[data-import-button]');
|
||||
const importEl = root.querySelector('[data-import]');
|
||||
const cellSizeEl = root.querySelector('[data-cell-size]');
|
||||
const toolBarEl = root.querySelector('[data-toolbar]');
|
||||
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
|
||||
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
|
||||
const strokeWidthEl = root.querySelector('[data-stroke-width]');
|
||||
const cellSizeValEl = root.querySelector('[data-cell-size-val]');
|
||||
const fillValEl = root.querySelector('[data-fill-opacity-val]');
|
||||
const strokeValEl = root.querySelector('[data-stroke-opacity-val]');
|
||||
const widthValEl = root.querySelector('[data-stroke-width-val]');
|
||||
|
||||
function bindRangeWithLabel(inputEl, labelEl, format = (v) => v) {
|
||||
const sync = () => { labelEl.textContent = format(inputEl.value); };
|
||||
inputEl.addEventListener('input', sync);
|
||||
inputEl.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
if (cellSizeEl && cellSizeValEl) bindRangeWithLabel(cellSizeEl, cellSizeValEl, v => `${v}px`);
|
||||
if (fillOpacityEl && fillValEl) bindRangeWithLabel(fillOpacityEl, fillValEl, v => `${parseInt(Number(v) * 100)}%`);
|
||||
if (strokeOpacityEl && strokeValEl) bindRangeWithLabel(strokeOpacityEl, strokeValEl, v => `${parseInt(Number(v) * 100)}%`);
|
||||
if (strokeWidthEl && widthValEl) bindRangeWithLabel(strokeWidthEl, widthValEl, v => `${Math.round(Number(v) * Number(cellSizeEl.value || 0))}px`);
|
||||
|
||||
core.saveDoc({ ...core.doc, shapes: core.shapes });
|
||||
|
||||
const savedTool = localStorage.getItem(`${storageKey}:tool`);
|
||||
if (savedTool) core.setActiveTool(savedTool);
|
||||
|
||||
const savedType = localStorage.getItem(`${storageKey}:gridType`);
|
||||
if (savedType) core.setActiveType(savedType);
|
||||
|
||||
cellSizeEl.value = core.cellSize;
|
||||
let dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
|
||||
|
||||
let selectedColor;
|
||||
let currentFillOpacity = core.clamp01(fillOpacityEl?.value ?? 1, 1);
|
||||
let currentStrokeOpacity = core.clamp01(strokeOpacityEl?.value ?? 1, 1);
|
||||
let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12;
|
||||
let selectedIndex = -1;
|
||||
|
||||
selectedColor = colorEl?.value || '#000000';
|
||||
if (dotSVGEl) {
|
||||
const circle = dotSVGEl.querySelector('circle');
|
||||
circle?.setAttribute('fill', selectedColor);
|
||||
}
|
||||
|
||||
let currentShape = null;
|
||||
let suppressNextClick = false;
|
||||
|
||||
const history = [structuredClone(core.shapes)];
|
||||
let historyIndex = 0
|
||||
|
||||
let sizingRAF = 0;
|
||||
let lastApplied = { w: 0, h: 0 };
|
||||
|
||||
const ro = new ResizeObserver(scheduleSnappedCellSize);
|
||||
ro.observe(gridWrapEl);
|
||||
|
||||
setGrid();
|
||||
scheduleSnappedCellSize();
|
||||
|
||||
let activePointerId = null;
|
||||
|
||||
if (toolBarEl && window.bootstrap?.Dropdown) {
|
||||
toolBarEl.querySelectorAll('[data-bs-toggle="dropdown"]').forEach((toggle) => {
|
||||
window.bootstrap.Dropdown.getOrCreateInstance(toggle, {
|
||||
popperConfig(defaultConfig) {
|
||||
return {
|
||||
...defaultConfig,
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
...(defaultConfig.modifiers || []),
|
||||
{ name: 'preventOverflow', options: { boundary: 'viewport' } },
|
||||
{ name: 'flip', options: { boundary: 'viewport', padding: 8 } },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(() => requestAnimationFrame(scheduleSnappedCellSize));
|
||||
|
||||
const api = {
|
||||
handleKeyDown(e) {
|
||||
const key = e.key.toLowerCase();
|
||||
const t = e.target;
|
||||
|
||||
const isTextField = t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable);
|
||||
|
||||
if (isTextField) {
|
||||
const isUndo = (e.ctrlKey || e.metaKey) && key === 'z';
|
||||
const isRedo = (e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey));
|
||||
|
||||
if (!isUndo && !isRedo) return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) redo();
|
||||
else undo();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && key === 'y') {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'escape' && currentShape) {
|
||||
e.preventDefault();
|
||||
currentShape = null;
|
||||
core.redrawAll();
|
||||
}
|
||||
},
|
||||
|
||||
handleGlobalPointerUp(e) {
|
||||
finishPointer(e);
|
||||
},
|
||||
|
||||
cancelStroke() { cancelStroke(); }
|
||||
};
|
||||
|
||||
function destroy() {
|
||||
if (window.activeGridWidget === api) window.activeGridWidget = null;
|
||||
|
||||
currentShape = null;
|
||||
activePointerId = null;
|
||||
|
||||
try {
|
||||
if (window.__gridPointerId != null && gridEl.hasPointerCapture?.(window.__gridPointerId)) {
|
||||
gridEl.releasePointerCapture(window.__gridPointerId);
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if (window.__gridPointerOwner === api) {
|
||||
window.__gridPointerOwner = null;
|
||||
window.__gridPointerId = null;
|
||||
}
|
||||
|
||||
ro.disconnect();
|
||||
}
|
||||
|
||||
|
||||
api.destroy = destroy;
|
||||
|
||||
root.addEventListener('focusin', () => { window.activeGridWidget = api; });
|
||||
|
||||
root.addEventListener('pointerdown', () => {
|
||||
window.activeGridWidget = api;
|
||||
}, { capture: true });
|
||||
|
||||
function setGrid() {
|
||||
const type = core.getActiveType();
|
||||
|
||||
gridEl.style.backgroundImage = "";
|
||||
gridEl.style.backgroundSize = "";
|
||||
gridEl.style.backgroundPosition = "";
|
||||
gridEl.style.boxShadow = "none";
|
||||
dotEl.classList.add('d-none');
|
||||
|
||||
// Minor dots
|
||||
const dotPx = Math.max(1, Math.round(core.cellSize * 0.08));
|
||||
const minorColor = '#ddd';
|
||||
|
||||
// Major dots (every 5 cells)
|
||||
const majorStep = core.cellSize * 5;
|
||||
const majorDotPx = Math.max(dotPx + 1, Math.round(core.cellSize * 0.12));
|
||||
const majorColor = '#c4c4c4';
|
||||
|
||||
const minorLayer = `radial-gradient(circle, ${minorColor} ${dotPx}px, transparent ${dotPx}px)`;
|
||||
const majorLayer = `radial-gradient(circle, ${majorColor} ${majorDotPx}px, transparent ${majorDotPx}px)`;
|
||||
|
||||
if (type === 'fullGrid') {
|
||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||
gridEl.style.backgroundSize = `${majorStep}px ${majorStep}px, ${core.cellSize}px ${core.cellSize}px`;
|
||||
gridEl.style.backgroundPosition =
|
||||
`${majorStep / 2}px ${majorStep / 2}px, ${core.cellSize / 2}px ${core.cellSize / 2}px`;
|
||||
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
||||
|
||||
} else if (type === 'verticalGrid') {
|
||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||
gridEl.style.backgroundSize = `${majorStep}px 100%, ${core.cellSize}px 100%`;
|
||||
gridEl.style.backgroundPosition =
|
||||
`${majorStep / 2}px 0px, ${core.cellSize / 2}px 0px`;
|
||||
gridEl.style.boxShadow = "inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
|
||||
|
||||
} else if (type === 'horizontalGrid') {
|
||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||
gridEl.style.backgroundSize = `100% ${majorStep}px, 100% ${core.cellSize}px`;
|
||||
gridEl.style.backgroundPosition =
|
||||
`0px ${majorStep / 2}px, 0px ${core.cellSize / 2}px`;
|
||||
gridEl.style.boxShadow = "inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
|
||||
|
||||
} else { // noGrid
|
||||
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideRect(clientX, clientY, rect) {
|
||||
return clientX >= rect.left && clientX <= rect.right &&
|
||||
clientY >= rect.top && clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
function finishPointer(e) {
|
||||
if (window.__gridPointerOwner !== api) return;
|
||||
if (!currentShape) return;
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
|
||||
onPointerUp(e);
|
||||
activePointerId = null;
|
||||
|
||||
window.__gridPointerOwner = null;
|
||||
window.__gridPointerId = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function penAddPoint(shape, clientX, clientY, minStep = 0.02, maxDtMs = 16) {
|
||||
const p = core.pxToDocPoint(clientX, clientY);
|
||||
|
||||
if (!Array.isArray(shape.points)) shape.points = [];
|
||||
if (shape._lastAddTime == null) shape._lastAddTime = performance.now();
|
||||
|
||||
const pts = shape.points;
|
||||
const last = pts[pts.length - 1];
|
||||
|
||||
const now = performance.now();
|
||||
const dt = now - shape._lastAddTime;
|
||||
|
||||
if (!last) {
|
||||
pts.push(p);
|
||||
shape._lastAddTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = p.x - last.x;
|
||||
const dy = p.y - last.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
if (d2 >= minStep * minStep || dt >= maxDtMs) {
|
||||
pts.push(p);
|
||||
shape._lastAddTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (historyIndex <= 0) return;
|
||||
historyIndex--;
|
||||
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
|
||||
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (historyIndex >= history.length - 1) return;
|
||||
historyIndex++;
|
||||
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
|
||||
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function commit(nextShapes) {
|
||||
history.splice(historyIndex + 1);
|
||||
|
||||
history.push(structuredClone(nextShapes));
|
||||
historyIndex++;
|
||||
|
||||
if (history.length > MAX_HISTORY) {
|
||||
const overflow = history.length - MAX_HISTORY;
|
||||
history.splice(0, overflow);
|
||||
historyIndex -= overflow;
|
||||
if (historyIndex < 0) historyIndex = 0;
|
||||
}
|
||||
|
||||
const rebuilt = core.rebuildPathCaches(nextShapes);
|
||||
|
||||
core.setDoc({ ...core.doc, shapes: rebuilt, cellSize: core.cellSize });
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function snapDown(n, step) {
|
||||
return Math.floor(n / step) * step;
|
||||
}
|
||||
|
||||
function applySnappedCellSize() {
|
||||
sizingRAF = 0;
|
||||
|
||||
const grid = core.cellSize;
|
||||
if (!Number.isFinite(grid) || grid < 1) return;
|
||||
|
||||
const w = gridWrapEl.clientWidth;
|
||||
const h = gridWrapEl.clientHeight;
|
||||
|
||||
const snappedW = snapDown(w, grid);
|
||||
const snappedH = snapDown(h, grid);
|
||||
|
||||
// Only touch width-related CSS if width changed
|
||||
const wChanged = snappedW !== lastApplied.w;
|
||||
const hChanged = snappedH !== lastApplied.h;
|
||||
if (!wChanged && !hChanged) return;
|
||||
|
||||
lastApplied = { w: snappedW, h: snappedH };
|
||||
|
||||
// critical: don't let observer see our own updates as layout input
|
||||
ro.disconnect();
|
||||
|
||||
gridEl.style.width = `${snappedW}px`;
|
||||
gridEl.style.height = `${snappedH}px`;
|
||||
|
||||
if (wChanged) {
|
||||
root.style.setProperty('--grid-maxw', `${snappedW}px`);
|
||||
}
|
||||
|
||||
ro.observe(gridWrapEl);
|
||||
|
||||
core.resizeAndSetupCanvas();
|
||||
}
|
||||
|
||||
|
||||
function scheduleSnappedCellSize() {
|
||||
if (sizingRAF) return;
|
||||
sizingRAF = requestAnimationFrame(applySnappedCellSize);
|
||||
}
|
||||
|
||||
function applyCellSize(newSize) {
|
||||
const n = Number(newSize);
|
||||
if (!Number.isFinite(n) || n < 1) return;
|
||||
|
||||
core.setDoc({ ...core.doc, cellSize: n, shapes: core.shapes });
|
||||
dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
|
||||
dotSVGEl?.setAttribute('width', dotSize);
|
||||
dotSVGEl?.setAttribute('height', dotSize);
|
||||
setGrid();
|
||||
scheduleSnappedCellSize();
|
||||
}
|
||||
|
||||
function snapToGrid(x, y) {
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const clampedX = Math.min(Math.max(x, rect.left), rect.right);
|
||||
const clampedY = Math.min(Math.max(y, rect.top), rect.bottom);
|
||||
|
||||
const localX = clampedX - rect.left;
|
||||
const localY = clampedY - rect.top;
|
||||
|
||||
const grid = core.cellSize;
|
||||
const maxIx = Math.floor(rect.width / grid);
|
||||
const maxIy = Math.floor(rect.height / grid);
|
||||
|
||||
const ix = Math.min(Math.max(Math.round(localX / grid), 0), maxIx);
|
||||
const iy = Math.min(Math.max(Math.round(localY / grid), 0), maxIy);
|
||||
|
||||
const type = core.getActiveType();
|
||||
|
||||
let snapX = localX;
|
||||
let snapY = localY;
|
||||
|
||||
if (type === 'fullGrid' || type === 'verticalGrid') {
|
||||
snapX = Math.min(ix * grid, rect.width);
|
||||
}
|
||||
|
||||
if (type === 'fullGrid' || type === 'horizontalGrid') {
|
||||
snapY = Math.min(iy * grid, rect.height);
|
||||
}
|
||||
|
||||
return {
|
||||
ix,
|
||||
iy,
|
||||
x: snapX,
|
||||
y: snapY,
|
||||
localX,
|
||||
localY
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRect(shape) {
|
||||
const x1 = core.pxToGrid(shape.x1);
|
||||
const y1 = core.pxToGrid(shape.y1);
|
||||
const x2 = core.pxToGrid(shape.x2);
|
||||
const y2 = core.pxToGrid(shape.y2);
|
||||
|
||||
return {
|
||||
type: 'rect',
|
||||
x: Math.min(x1, x2),
|
||||
y: Math.min(y1, y2),
|
||||
w: Math.abs(x2 - x1),
|
||||
h: Math.abs(y2 - y1),
|
||||
color: shape.color,
|
||||
fill: shape.fill,
|
||||
fillOpacity: core.clamp01(shape.fillOpacity, 1),
|
||||
strokeOpacity: core.clamp01(shape.strokeOpacity, 1),
|
||||
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEllipse(shape) {
|
||||
const r = normalizeRect(shape);
|
||||
return { ...r, type: 'ellipse' };
|
||||
}
|
||||
|
||||
function normalizeLine(shape) {
|
||||
return {
|
||||
type: 'line',
|
||||
x1: core.pxToGrid(shape.x1),
|
||||
y1: core.pxToGrid(shape.y1),
|
||||
x2: core.pxToGrid(shape.x2),
|
||||
y2: core.pxToGrid(shape.y2),
|
||||
color: shape.color,
|
||||
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12,
|
||||
strokeOpacity: core.clamp01(shape.strokeOpacity)
|
||||
};
|
||||
}
|
||||
|
||||
function cancelStroke(e) {
|
||||
const owns = (window.__gridPointerOwner === api) &&
|
||||
(e ? window.__gridPointerId === e.pointerId : true);
|
||||
|
||||
if (!owns) return;
|
||||
|
||||
currentShape = null;
|
||||
activePointerId = null;
|
||||
|
||||
window.__gridPointerOwner = null;
|
||||
window.__gridPointerId = null;
|
||||
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!currentShape) return;
|
||||
|
||||
// Only finalize if this pointer is the captured one (or we failed to capture, sigh)
|
||||
if (gridEl.hasPointerCapture?.(e.pointerId)) {
|
||||
gridEl.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
|
||||
|
||||
currentShape.x2 = snapX;
|
||||
currentShape.y2 = snapY;
|
||||
|
||||
let finalShape = null;
|
||||
|
||||
if (currentShape.tool === 'pen') {
|
||||
const pts = currentShape.points;
|
||||
|
||||
if (pts.length >= 2) {
|
||||
const coarse = [pts[0]];
|
||||
const minStepPx = 0.75;
|
||||
const minStep = minStepPx / core.cellSize;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
if (dist2(pts[i], coarse[coarse.length - 1]) >= minStep * minStep) {
|
||||
coarse.push(pts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (coarse.length >= 2) {
|
||||
const epsilon = Math.max(0.01, (currentShape.strokeWidth ?? 0.12) * 0.75);
|
||||
|
||||
const simplified = simplifyRDP(coarse, epsilon);
|
||||
|
||||
if (simplified.length >= 2) {
|
||||
finalShape = {
|
||||
type: 'path',
|
||||
points: simplified,
|
||||
color: currentShape.color || '#000000',
|
||||
strokeWidth: core.isFiniteNum(currentShape.strokeWidth) ? Math.max(0, +currentShape.strokeWidth) : SHAPE_DEFAULTS.strokeWidth,
|
||||
strokeOpacity: core.clamp01(currentShape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (currentShape.tool === 'line') {
|
||||
const line = normalizeLine(currentShape);
|
||||
if (line.x1 !== line.x2 || line.y1 !== line.y2) finalShape = line;
|
||||
|
||||
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
|
||||
const rect = normalizeRect(currentShape);
|
||||
if (rect.w > 0 && rect.h > 0) finalShape = rect;
|
||||
|
||||
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
|
||||
const ellipse = normalizeEllipse(currentShape);
|
||||
if (ellipse.w > 0 && ellipse.h > 0) finalShape = ellipse;
|
||||
}
|
||||
|
||||
if (finalShape) {
|
||||
if (finalShape && ('_lastAddTime' in finalShape)) delete finalShape._lastAddTime;
|
||||
commit([...core.shapes, finalShape]);
|
||||
|
||||
|
||||
suppressNextClick = true;
|
||||
setTimeout(() => { suppressNextClick = false; }, 0);
|
||||
}
|
||||
|
||||
currentShape = null;
|
||||
core.renderAllWithPreview(null);
|
||||
}
|
||||
|
||||
gridEl.addEventListener('pointerup', finishPointer);
|
||||
|
||||
function setSelection(hit) {
|
||||
if (!hit) {
|
||||
selectedIndex = -1;
|
||||
core.selectedShape = null;
|
||||
core.redrawAll();
|
||||
return;
|
||||
}
|
||||
selectedIndex = hit.index;
|
||||
core.selectedShape = hit.shape;
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
gridEl.addEventListener('click', (e) => {
|
||||
if (suppressNextClick) {
|
||||
suppressNextClick = false;
|
||||
return;
|
||||
}
|
||||
if (currentShape) return;
|
||||
if (e.target.closest('[data-toolbar]')) return;
|
||||
|
||||
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
|
||||
setSelection(hit);
|
||||
|
||||
if (hit) root.dispatchEvent(new CustomEvent('shape:click', { detail: hit }));
|
||||
});
|
||||
|
||||
gridEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
if (currentShape) return;
|
||||
|
||||
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
|
||||
setSelection(hit);
|
||||
|
||||
root.dispatchEvent(new CustomEvent('shape:contextmenu', {
|
||||
detail: { hit, clientX: e.clientX, clientY: e.clientY }
|
||||
}));
|
||||
});
|
||||
|
||||
gridEl.addEventListener('dblclick', (e) => {
|
||||
if (currentShape) return;
|
||||
if (e.target.closest('[data-toolbar]')) return;
|
||||
|
||||
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
|
||||
setSelection(hit);
|
||||
|
||||
if (hit) root.dispatchEvent(new CustomEvent('shape:dblclick', { detail: hit }));
|
||||
});
|
||||
|
||||
root.querySelectorAll('input[data-tool]').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) {
|
||||
localStorage.setItem(`${storageKey}:tool`, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('input[data-gridtype]').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) {
|
||||
localStorage.setItem(`${storageKey}:gridType`, input.value);
|
||||
}
|
||||
setGrid();
|
||||
core.redrawAll();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value));
|
||||
cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value));
|
||||
|
||||
importButtonEl.addEventListener('click', () => importEl.click());
|
||||
|
||||
importEl.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = decode(JSON.parse(reader.result));
|
||||
|
||||
if (Number.isFinite(Number(data.cellSize)) && Number(data.cellSize) >= 1) {
|
||||
cellSizeEl.value = data.cellSize;
|
||||
applyCellSize(data.cellSize);
|
||||
}
|
||||
|
||||
const loadedShapes = Array.isArray(data?.shapes) ? data.shapes : [];
|
||||
const rebuilt = core.rebuildPathCaches(core.sanitizeShapes(loadedShapes));
|
||||
core.setDoc({ version: Number(data?.version) || 1, cellSize: Number(data?.cellSize) || core.cellSize, shapes: rebuilt });
|
||||
|
||||
history.length = 0;
|
||||
history.push(structuredClone(core.shapes));
|
||||
historyIndex = 0;
|
||||
|
||||
core.redrawAll();
|
||||
} catch {
|
||||
toastMessage('Failed to load data from JSON file.', 'danger');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
exportEl.addEventListener('click', () => {
|
||||
const payload = encode({ cellSize: core.cellSize, shapes: core.shapes, stripCaches: core.stripCaches, SHAPE_DEFAULTS });
|
||||
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'grid-shapes.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
clearEl.addEventListener('click', () => {
|
||||
cellSizeEl.value = 25;
|
||||
core.setDoc({ ...core.doc, cellSize: 25, shapes: [] });
|
||||
|
||||
history.length = 0;
|
||||
history.push([]);
|
||||
historyIndex = 0;
|
||||
|
||||
core.redrawAll();
|
||||
});
|
||||
|
||||
colorEl.addEventListener('input', () => {
|
||||
selectedColor = colorEl.value || '#000000';
|
||||
const circle = dotSVGEl.querySelector('circle');
|
||||
if (circle) {
|
||||
circle.setAttribute('fill', selectedColor);
|
||||
}
|
||||
});
|
||||
|
||||
fillOpacityEl?.addEventListener('input', () => {
|
||||
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
fillOpacityEl?.addEventListener('change', () => {
|
||||
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
strokeOpacityEl?.addEventListener('input', () => {
|
||||
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
strokeOpacityEl?.addEventListener('change', () => {
|
||||
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
strokeWidthEl?.addEventListener('input', () => {
|
||||
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
|
||||
});
|
||||
|
||||
strokeWidthEl?.addEventListener('change', () => {
|
||||
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
|
||||
});
|
||||
|
||||
gridEl.addEventListener('pointercancel', (e) => cancelStroke(e));
|
||||
gridEl.addEventListener('lostpointercapture', (e) => cancelStroke(e));
|
||||
|
||||
gridEl.addEventListener('pointermove', (e) => {
|
||||
if (!core.ctx) return;
|
||||
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const inside = isInsideRect(e.clientX, e.clientY, rect);
|
||||
const drawing = !!currentShape;
|
||||
|
||||
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
|
||||
const tool = core.getActiveTool();
|
||||
|
||||
if (!drawing && !inside) {
|
||||
coordsEl.classList.add('d-none');
|
||||
dotEl.classList.add('d-none');
|
||||
} else {
|
||||
coordsEl.classList.remove('d-none');
|
||||
|
||||
if (core.getActiveType() !== 'noGrid' && tool !== 'pen') {
|
||||
dotEl.classList.remove('d-none');
|
||||
|
||||
const wrapRect = gridWrapEl.getBoundingClientRect();
|
||||
const offsetX = rect.left - wrapRect.left;
|
||||
const offsetY = rect.top - wrapRect.top;
|
||||
|
||||
dotEl.style.left = `${offsetX + snapX}px`;
|
||||
dotEl.style.top = `${offsetY + snapY}px`;
|
||||
} else {
|
||||
dotEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (core.getActiveType() == 'noGrid') {
|
||||
coordsEl.innerText = `(px x=${Math.round(localX)} y=${Math.round(localY)})`;
|
||||
} else {
|
||||
coordsEl.innerText = `(x=${ix} (${snapX}px) y=${iy} (${snapY}px))`;
|
||||
}
|
||||
|
||||
if (!currentShape) return;
|
||||
|
||||
// PEN: mutate points and preview the same shape object
|
||||
if (currentShape.tool === 'pen') {
|
||||
const minStepPx = 0.75;
|
||||
const minStep = minStepPx / core.cellSize;
|
||||
penAddPoint(currentShape, e.clientX, e.clientY, minStep, 16);
|
||||
|
||||
// realtime instrumentation
|
||||
coordsEl.innerText += ` | pts=${currentShape.points?.length ?? 0}`;
|
||||
|
||||
core.renderAllWithPreview(currentShape, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Other tools: build a normalized preview shape
|
||||
let preview = null;
|
||||
|
||||
if (currentShape.tool === 'line') {
|
||||
preview = normalizeLine({
|
||||
x1: currentShape.x1,
|
||||
y1: currentShape.y1,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: currentShape.color,
|
||||
strokeWidth: currentShape.strokeWidth,
|
||||
strokeOpacity: currentShape.strokeOpacity
|
||||
});
|
||||
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
|
||||
preview = normalizeRect({ ...currentShape, x2: snapX, y2: snapY });
|
||||
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
|
||||
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
|
||||
}
|
||||
|
||||
core.renderAllWithPreview(preview, currentShape.tool !== 'pen');
|
||||
});
|
||||
|
||||
gridEl.addEventListener('pointerleave', (e) => {
|
||||
coordsEl.classList.add('d-none');
|
||||
dotEl.classList.add('d-none');
|
||||
});
|
||||
|
||||
gridEl.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
if (e.target.closest('[data-toolbar]')) return;
|
||||
|
||||
e.preventDefault();
|
||||
activePointerId = e.pointerId;
|
||||
|
||||
window.__gridPointerOwner = api;
|
||||
window.__gridPointerId = e.pointerId;
|
||||
|
||||
try {
|
||||
gridEl.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore: some browsers / scenarios won't allow capture
|
||||
}
|
||||
|
||||
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
|
||||
const tool = core.getActiveTool();
|
||||
|
||||
if (tool === 'line') {
|
||||
currentShape = {
|
||||
tool,
|
||||
type: 'line',
|
||||
x1: snapX,
|
||||
y1: snapY,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: selectedColor,
|
||||
strokeWidth: currentStrokeWidth,
|
||||
strokeOpacity: currentStrokeOpacity
|
||||
};
|
||||
} else if (tool === 'outline' || tool === 'filled') {
|
||||
currentShape = {
|
||||
tool,
|
||||
x1: snapX,
|
||||
y1: snapY,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: selectedColor,
|
||||
fill: (tool === 'filled'),
|
||||
fillOpacity: currentFillOpacity,
|
||||
strokeOpacity: currentStrokeOpacity,
|
||||
strokeWidth: currentStrokeWidth
|
||||
};
|
||||
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
|
||||
currentShape = {
|
||||
tool,
|
||||
x1: snapX,
|
||||
y1: snapY,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: selectedColor,
|
||||
fill: (tool === 'filledEllipse'),
|
||||
fillOpacity: currentFillOpacity,
|
||||
strokeOpacity: currentStrokeOpacity,
|
||||
strokeWidth: currentStrokeWidth
|
||||
};
|
||||
} else if (tool === 'pen') {
|
||||
const p = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
currentShape = {
|
||||
tool,
|
||||
type: 'path',
|
||||
points: [p],
|
||||
color: selectedColor,
|
||||
strokeWidth: currentStrokeWidth,
|
||||
strokeOpacity: currentStrokeOpacity,
|
||||
_lastAddTime: performance.now()
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
136
inventory/static/js/components/grid/widget-init.js
Normal file
136
inventory/static/js/components/grid/widget-init.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { encode, decode } from './encode-decode.js';
|
||||
import { createWidgetCore, DEFAULT_DOC } from "./widget-core.js";
|
||||
import { initWidgetEditor } from "./widget-editor.js";
|
||||
import { initWidgetViewer } from "./widget-viewer.js";
|
||||
|
||||
function readEmbeddedDoc(root, toastMessage) {
|
||||
const el = root.querySelector('[data-grid-doc]');
|
||||
if (!el) return null;
|
||||
|
||||
const raw = (el.textContent || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return decode(parsed);
|
||||
} catch (err) {
|
||||
toastMessage?.(`Failed to parse embedded grid JSON: ${err?.message || err}`, 'danger');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function initGridWidget(root, opts = {}) {
|
||||
const mode = opts.mode || 'editor';
|
||||
const storageKey = opts.storageKey ?? 'gridDoc';
|
||||
|
||||
const canvasEl = root.querySelector('[data-canvas]');
|
||||
const gridEl = root.querySelector('[data-grid]');
|
||||
const gridWrapEl = root.querySelector('[data-grid-wrap]');
|
||||
|
||||
if (!canvasEl || !gridEl || !gridWrapEl) {
|
||||
throw new Error("Grid widget: missing required viewer elements.");
|
||||
}
|
||||
|
||||
const toastMessage = opts.toastMessage || (() => { });
|
||||
|
||||
let initialDoc = opts.doc ?? null;
|
||||
|
||||
if (!initialDoc && mode !== 'editor') {
|
||||
initialDoc = readEmbeddedDoc(root, toastMessage);
|
||||
}
|
||||
|
||||
const core = createWidgetCore({
|
||||
root,
|
||||
mode,
|
||||
storageKey,
|
||||
gridEl,
|
||||
canvasEl,
|
||||
viewerOffset: opts.viewerOffset || { x: 0, y: 0 },
|
||||
doc: initialDoc,
|
||||
cellSize: opts.cellSize,
|
||||
shapes: opts.shapes,
|
||||
|
||||
loadRaw() {
|
||||
if (mode !== 'editor') return null;
|
||||
return localStorage.getItem(storageKey);
|
||||
},
|
||||
saveRaw(_rawInternalDoc) {
|
||||
if (mode !== 'editor') return;
|
||||
|
||||
const payload = encode({
|
||||
cellSize: core.cellSize,
|
||||
shapes: core.shapes,
|
||||
stripCaches: core.stripCaches,
|
||||
SHAPE_DEFAULTS: core.SHAPE_DEFAULTS
|
||||
});
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
}
|
||||
});
|
||||
|
||||
const env = { root, gridEl, gridWrapEl, toastMessage, storageKey };
|
||||
|
||||
if (mode === 'editor') {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
try {
|
||||
const decoded = decode(JSON.parse(raw));
|
||||
core.setDoc(decoded);
|
||||
} catch {
|
||||
core.setDoc(DEFAULT_DOC);
|
||||
}
|
||||
} else {
|
||||
const raw = root.dataset.doc;
|
||||
if (raw) {
|
||||
try {
|
||||
const decoded = decode(JSON.parse(raw));
|
||||
core.setDoc(decoded);
|
||||
} catch {
|
||||
core.setDoc(DEFAULT_DOC);
|
||||
}
|
||||
} else {
|
||||
core.setDoc(DEFAULT_DOC);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const embedded = initialDoc ?? readEmbeddedDoc(root, toastMessage);
|
||||
if (embedded) core.setDoc(embedded);
|
||||
}
|
||||
|
||||
let editorApi = null;
|
||||
if (mode === 'editor') {
|
||||
editorApi = initWidgetEditor(core, env);
|
||||
}
|
||||
|
||||
let viewerApi = null;
|
||||
if (mode !== 'editor') {
|
||||
viewerApi = initWidgetViewer(core, { core, gridEl, gridWrapEl });
|
||||
}
|
||||
|
||||
const api = {
|
||||
core,
|
||||
mode,
|
||||
|
||||
redraw() { core.redrawAll(); },
|
||||
destroy() { editorApi?.destroy?.(); },
|
||||
|
||||
get doc() { return core.doc; },
|
||||
get shapes() { return core.shapes; },
|
||||
get cellSize() { return core.cellSize; },
|
||||
};
|
||||
|
||||
if (editorApi) {
|
||||
api.handleKeyDown = editorApi.handleKeyDown;
|
||||
api.handleGlobalPointerUp = editorApi.handleGlobalPointerUp;
|
||||
api.cancelStroke = editorApi.cancelStroke;
|
||||
}
|
||||
|
||||
if (viewerApi) {
|
||||
api.setDoc = viewerApi.setDoc;
|
||||
api.redraw = viewerApi.redraw;
|
||||
api.destroy = viewerApi.destroy;
|
||||
api.decode = viewerApi.decode;
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
66
inventory/static/js/components/grid/widget-viewer.js
Normal file
66
inventory/static/js/components/grid/widget-viewer.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { decode } from "./encode-decode.js";
|
||||
|
||||
export function initWidgetViewer(core, env) {
|
||||
const { mode, gridEl, gridWrapEl } = env;
|
||||
|
||||
if (mode === 'editor') return null;
|
||||
|
||||
let resizeRAF = 0;
|
||||
|
||||
function applyViewerBoundsSizing() {
|
||||
const b = core.getShapesBounds(core.shapes);
|
||||
const padCells = 0.5;
|
||||
|
||||
const wCells = b ? (b.maxX - b.minX + padCells * 2) : 10;
|
||||
const hCells = b ? (b.maxY - b.minY + padCells * 2) : 10;
|
||||
|
||||
const wPx = Math.max(1, Math.ceil(wCells * core.cellSize));
|
||||
const hPx = Math.max(1, Math.ceil(hCells * core.cellSize));
|
||||
|
||||
gridEl.style.width = `${wPx}px`;
|
||||
gridEl.style.height = `${hPx}px`;
|
||||
gridWrapEl.style.width = `${wPx}px`;
|
||||
gridWrapEl.style.height = `${hPx}px`;
|
||||
|
||||
if (b) {
|
||||
core.viewerOffset = {
|
||||
x: (-b.minX + padCells) * core.cellSize,
|
||||
y: (-b.minY + padCells) * core.cellSize
|
||||
};
|
||||
} else {
|
||||
core.viewerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (resizeRAF) return;
|
||||
resizeRAF = requestAnimationFrame(() => {
|
||||
resizeRAF = 0;
|
||||
applyViewerBoundsSizing();
|
||||
core.resizeAndSetupCanvas();
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(scheduleResize);
|
||||
ro.observe(gridWrapEl);
|
||||
|
||||
window.addEventListener('resize', scheduleResize, { passive: true });
|
||||
|
||||
requestAnimationFrame(scheduleResize);
|
||||
|
||||
function setDoc(nextDoc) {
|
||||
core.setDoc(nextDoc);
|
||||
applyViewerBoundsSizing();
|
||||
core.resizeAndSetupCanvas();
|
||||
}
|
||||
|
||||
return {
|
||||
setDoc,
|
||||
redraw: () => core.redrawAll(),
|
||||
destroy() {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', scheduleResize);
|
||||
},
|
||||
decode
|
||||
};
|
||||
}
|
||||
105
inventory/static/js/components/image_display.js
Normal file
105
inventory/static/js/components/image_display.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
const ImageDisplay = globalThis.ImageDisplay ?? (globalThis.ImageDisplay = {});
|
||||
|
||||
ImageDisplay.utilities = {
|
||||
fileInput: document.getElementById('image'),
|
||||
image: document.getElementById('imageDisplay'),
|
||||
captionInput: document.getElementById('caption'),
|
||||
removeButton: document.getElementById('remove-inventory-image'),
|
||||
imageIdInput: document.getElementById('image_id'),
|
||||
|
||||
// set when user selects a new file
|
||||
_dirty: false,
|
||||
_removed: false,
|
||||
|
||||
onAddButtonClick() {
|
||||
this.fileInput.click();
|
||||
},
|
||||
|
||||
onRemoveButtonClick() {
|
||||
// Clear preview back to placeholder
|
||||
this.image.src = this.image.dataset.placeholder || this.image.src;
|
||||
this.fileInput.value = '';
|
||||
this._dirty = false;
|
||||
this._removed = true;
|
||||
this.imageIdInput.value = '';
|
||||
this.removeButton.classList.add('d-none');
|
||||
},
|
||||
|
||||
onFileChange() {
|
||||
const [file] = this.fileInput.files;
|
||||
|
||||
if (!file) {
|
||||
toastMessage('No file selected!', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image")) {
|
||||
toastMessage('Unsupported file type!', 'danger')
|
||||
this.fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
this.image.src = url;
|
||||
|
||||
if (this.removeButton) {
|
||||
this.removeButton.classList.remove('d-none');
|
||||
}
|
||||
|
||||
this._dirty = true;
|
||||
this._removed = false;
|
||||
},
|
||||
|
||||
async uploadIfChanged() {
|
||||
// If no changes to image, do nothing
|
||||
if (!this._dirty && !this._removed) return null;
|
||||
|
||||
// Removed but not replaced: tell backend to clear image_id
|
||||
if (this._removed) {
|
||||
return { remove: true };
|
||||
}
|
||||
|
||||
const [file] = this.fileInput.files;
|
||||
if (!file) return null;
|
||||
|
||||
if(!window.IMAGE_UPLOAD_URL) {
|
||||
toastMessage('IMAGE_UPLOAD_URL not set', 'danger');
|
||||
return null;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
if (this.captionInput) {
|
||||
fd.append('caption', this.captionInput.value || '');
|
||||
}
|
||||
if (window.IMAGE_OWNER_MODEL) {
|
||||
fd.append('model', window.IMAGE_OWNER_MODEL);
|
||||
}
|
||||
if (this.imageIdInput && this.imageIdInput.value) {
|
||||
fd.append('image_id', this.imageIdInput.value);
|
||||
}
|
||||
|
||||
const res = await fetch(window.IMAGE_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.status !== 'success') {
|
||||
toastMessage(data.error || 'Image upload failed.', 'danger');
|
||||
throw new Error(data.error || 'Image upload failed.');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.imageIdInput.value = data.id;
|
||||
this._dirty = false;
|
||||
this._removed = false;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
filename: data.filename,
|
||||
url: data.url,
|
||||
};
|
||||
},
|
||||
};
|
||||
36
inventory/static/js/components/markdown.js
Normal file
36
inventory/static/js/components/markdown.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const MarkDown = {
|
||||
parseOptions: { gfm: true, breaks: false },
|
||||
sanitizeOptions: { ADD_ATTR: ['target', 'rel'] },
|
||||
|
||||
toHTML(md) {
|
||||
const raw = marked.parse(md || "", this.parseOptions);
|
||||
return DOMPurify.sanitize(raw, this.sanitizeOptions);
|
||||
},
|
||||
|
||||
enhance(root) {
|
||||
if (!root) return;
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||
}
|
||||
for (const t of root.querySelectorAll('table')) {
|
||||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||
}
|
||||
for (const q of root.querySelectorAll('blockquote')) {
|
||||
q.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
|
||||
}
|
||||
for (const l of root.querySelectorAll('ul')) {
|
||||
l.classList.add('list-group');
|
||||
}
|
||||
for (const l of root.querySelectorAll('li')) {
|
||||
l.classList.add('list-group-item');
|
||||
}
|
||||
},
|
||||
|
||||
renderInto(el, md) {
|
||||
if (!el) return;
|
||||
el.innerHTML = this.toHTML(md);
|
||||
this.enhance(el);
|
||||
}
|
||||
};
|
||||
7
inventory/static/js/utils/json.js
Normal file
7
inventory/static/js/utils/json.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
function readJSONScript(id, fallback = "") {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return fallback;
|
||||
const txt = el.textContent?.trim();
|
||||
if (!txt) return fallback;
|
||||
try { return JSON.parse(txt); } catch { return fallback; }
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('reports.problems') }}">Problems</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
266
inventory/templates/components/draw.html
Normal file
266
inventory/templates/components/draw.html
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
{% macro drawWidget(uid) %}
|
||||
<div class="grid-widget" data-grid-widget data-mode="editor" data-storage-key="gridDoc:{{ uid }}">
|
||||
<div data-toolbar
|
||||
class="btn-toolbar bg-light border border-bottom-0 rounded-bottom-0 border-secondary-subtle rounded p-1 align-items-center flex-nowrap overflow-auto toolbar">
|
||||
<div class="toolbar-row toolbar-row--primary">
|
||||
<div class="toolbar-group">
|
||||
<div class="btn-group">
|
||||
<input type="radio" class="btn-check" value="pen" name="tool-{{ uid }}" id="tool-pen-{{ uid }}"
|
||||
data-tool checked>
|
||||
<label for="tool-pen-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-pen" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001m-.644.766a.5.5 0 0 0-.707 0L1.95 11.756l-.764 3.057 3.057-.764L14.44 3.854a.5.5 0 0 0 0-.708z" />
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" value="line" name="tool-{{ uid }}" id="tool-line-{{ uid }}"
|
||||
data-tool>
|
||||
<label for="tool-line-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
aria-hidden="true">
|
||||
<path d="M4 12 L12 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<circle cx="4" cy="12" r="1.5" fill="currentColor" />
|
||||
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" value="outline" name="tool-{{ uid }}"
|
||||
id="tool-outline-{{ uid }}" data-tool>
|
||||
<label for="tool-outline-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-square" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" />
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" value="filled" name="tool-{{ uid }}"
|
||||
id="tool-filled-{{ uid }}" data-tool>
|
||||
<label for="tool-filled-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-square-fill" viewBox="0 0 16 16">
|
||||
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" value="outlineEllipse" name="tool-{{ uid }}"
|
||||
id="tool-outline-ellipse-{{ uid }}" data-tool>
|
||||
<label for="tool-outline-ellipse-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-circle" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" value="filledEllipse" name="tool-{{ uid }}"
|
||||
id="tool-filled-ellipse-{{ uid }}" data-tool>
|
||||
<label for="tool-filled-ellipse-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-circle-fill" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="8" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<input type="color" class="form-control form-control-sm form-control-color" data-color>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<div class="btn-group">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
|
||||
data-export>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-download" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
|
||||
<path
|
||||
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z" />
|
||||
</svg>
|
||||
</button>
|
||||
<input type="file" data-import accept="application/json" class="d-none">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
|
||||
data-import-button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-upload" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
|
||||
<path
|
||||
d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center"
|
||||
data-clear>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-x-lg" 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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-row toolbar-row--secondary">
|
||||
<div class="toolbar-group">
|
||||
<div class="btn-group">
|
||||
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="noGrid"
|
||||
id="type-no-grid-{{ uid }}" data-gridtype checked>
|
||||
<label for="type-no-grid-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-border" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M0 0h.969v.5H1v.469H.969V1H.5V.969H0zm2.844 1h-.938V0h.938zm1.875 0H3.78V0h.938v1zm1.875 0h-.938V0h.938zm.937 0V.969H7.5V.5h.031V0h.938v.5H8.5v.469h-.031V1zm2.813 0h-.938V0h.938zm1.875 0h-.938V0h.938zm1.875 0h-.938V0h.938zM15.5 1h-.469V.969H15V.5h.031V0H16v.969h-.5zM1 1.906v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM1 3.78v.938H0V3.78zm6.5.938V3.78h1v.938zm7.5 0V3.78h1v.938zM1 5.656v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM.969 8.5H.5v-.031H0V7.53h.5V7.5h.469v.031H1v.938H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm1.875-.031V8.5H7.53v-.031H7.5V7.53h.031V7.5h.938v.031H8.5v.938zm1.875.031h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.406 0h-.469v-.031H15V7.53h.031V7.5h.469v.031h.5v.938h-.5zM0 10.344v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 12.22v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 14.094v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM.969 16H0v-.969h.5V15h.469v.031H1v.469H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm.937 0v-.5H7.5v-.469h.031V15h.938v.031H8.5v.469h-.031v.5zm2.813 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm.937 0v-.5H15v-.469h.031V15h.469v.031h.5V16z" />
|
||||
</svg>
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="horizontalGrid"
|
||||
id="type-horizontal-{{ uid }}" data-gridtype>
|
||||
<label for="type-horizontal-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-border-center" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM7.531.969V1h.938V.969H8.5V.5h-.031V0H7.53v.5H7.5v.469zM9.406 1h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM1 4.719V3.78H0v.938h1zm6.5-.938v.938h1V3.78h-1zm7.5 0v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM0 8.5v-1h16v1zm0 .906v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm1.875-.5v.5h.938v-.5H8.5v-.469h-.031V15H7.53v.031H7.5v.469zm1.875.5h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
|
||||
</svg>
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="verticalGrid"
|
||||
id="type-vertical-{{ uid }}" data-gridtype>
|
||||
<label for="type-vertical-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-border-middle" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM8.5 16h-1V0h1zm.906-15h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm14-.938v.938h1v-.938zM1 4.719V3.78H0v.938h1zm14-.938v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm14-.938v.938h1v-.938zM.5 8.5h.469v-.031H1V7.53H.969V7.5H.5v.031H0v.938h.5zm1.406 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.469v-.031h.5V7.53h-.5V7.5h-.469v.031H15v.938h.031zM0 9.406v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
|
||||
</svg>
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="fullGrid"
|
||||
id="type-full-{{ uid }}" data-gridtype>
|
||||
<label for="type-full-{{ uid }}"
|
||||
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-border-all" viewBox="0 0 16 16">
|
||||
<path d="M0 0h16v16H0zm1 1v6.5h6.5V1zm7.5 0v6.5H15V1zM15 8.5H8.5V15H15zM7.5 15V8.5H1V15z" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-grid-3x3-gap" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M4 2v2H2V2zm1 12v-2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m5 10v-2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1M9 2v2H7V2zm5 0v2h-2V2zM4 7v2H2V7zm5 0v2H7V7zm5 0h-2v2h2zM4 12v2H2v-2zm5 0v2H7v-2zm5 0v2h-2v-2zM12 1a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm-1 6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm1 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
<small data-cell-size-val>25px</small>
|
||||
</button>
|
||||
<ul class="dropdown-menu p-2">
|
||||
<li>
|
||||
<div class="small text-secondary mb-1">Cell Size</div>
|
||||
<input type="range" min="1" max="100" step="1" value="25" data-cell-size
|
||||
class="form-range w-100">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-droplet-fill" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M8 16a6 6 0 0 0 6-6c0-1.655-1.122-2.904-2.432-4.362C10.254 4.176 8.75 2.503 8 0c0 0-6 5.686-6 10a6 6 0 0 0 6 6M6.646 4.646l.708.708c-.29.29-1.128 1.311-1.907 2.87l-.894-.448c.82-1.641 1.717-2.753 2.093-3.13" />
|
||||
</svg>
|
||||
<small data-fill-opacity-val>100%</small>
|
||||
</button>
|
||||
<ul class="dropdown-menu p-2">
|
||||
<li>
|
||||
<div class="small text-secondary mb-1">Fill Opacity</div>
|
||||
<input type="range" min="0" max="1" step="0.01" value="1" data-fill-opacity
|
||||
class="form-range w-100">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-dash-square-dotted" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M2.5 0q-.25 0-.487.048l.194.98A1.5 1.5 0 0 1 2.5 1h.458V0zm2.292 0h-.917v1h.917zm1.833 0h-.917v1h.917zm1.833 0h-.916v1h.916zm1.834 0h-.917v1h.917zm1.833 0h-.917v1h.917zM13.5 0h-.458v1h.458q.151 0 .293.029l.194-.981A2.5 2.5 0 0 0 13.5 0m2.079 1.11a2.5 2.5 0 0 0-.69-.689l-.556.831q.248.167.415.415l.83-.556zM1.11.421a2.5 2.5 0 0 0-.689.69l.831.556c.11-.164.251-.305.415-.415zM16 2.5q0-.25-.048-.487l-.98.194q.027.141.028.293v.458h1zM.048 2.013A2.5 2.5 0 0 0 0 2.5v.458h1V2.5q0-.151.029-.293zM0 3.875v.917h1v-.917zm16 .917v-.917h-1v.917zM0 5.708v.917h1v-.917zm16 .917v-.917h-1v.917zM0 7.542v.916h1v-.916zm15 .916h1v-.916h-1zM0 9.375v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .916v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .917v.458q0 .25.048.487l.98-.194A1.5 1.5 0 0 1 1 13.5v-.458zm16 .458v-.458h-1v.458q0 .151-.029.293l.981.194Q16 13.75 16 13.5M.421 14.89c.183.272.417.506.69.689l.556-.831a1.5 1.5 0 0 1-.415-.415zm14.469.689c.272-.183.506-.417.689-.69l-.831-.556c-.11.164-.251.305-.415.415l.556.83zm-12.877.373Q2.25 16 2.5 16h.458v-1H2.5q-.151 0-.293-.029zM13.5 16q.25 0 .487-.048l-.194-.98A1.5 1.5 0 0 1 13.5 15h-.458v1zm-9.625 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zm1.834 0h.916v-1h-.916zm1.833 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
|
||||
</svg>
|
||||
<small data-stroke-opacity-val>100%</small>
|
||||
</button>
|
||||
<ul class="dropdown-menu p-2">
|
||||
<li>
|
||||
<div class="small text-secondary mb-1">Stroke Opacity</div>
|
||||
<input type="range" min="0" max="1" step="0.01" value="1" data-stroke-opacity
|
||||
class="form-range w-100">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
|
||||
fill="currentColor" class="me-1" aria-hidden="true">
|
||||
<rect x="2" y="3" width="12" height="1" rx=".5"></rect>
|
||||
<rect x="2" y="7" width="12" height="2" rx="1"></rect>
|
||||
<rect x="2" y="12" width="12" height="3" rx="1.5"></rect>
|
||||
</svg>
|
||||
<small data-stroke-width-val>12%</small>
|
||||
</button>
|
||||
<ul class="dropdown-menu p-2">
|
||||
<li>
|
||||
<div class="small text-secondary mb-1">Stroke Width</div>
|
||||
<input type="range" min="0" max="1" step="0.01" value="0.12" data-stroke-width
|
||||
class="form-range w-100">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-wrap" data-grid-wrap>
|
||||
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
|
||||
<circle cx="16" cy="16" r="4" fill="black" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="position-relative overflow-hidden grid" data-grid>
|
||||
<div class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
|
||||
data-coords></div>
|
||||
<canvas class="position-absolute w-100 h-100" data-canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro viewWidget(uid, json) %}
|
||||
<span class="grid-widget" data-grid-widget data-mode="viewer" data-storage-key="gridDoc:{{ uid }}">
|
||||
<span class="grid-wrap" data-grid-wrap>
|
||||
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
|
||||
<circle cx="16" cy="16" r="4" fill="black" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="position-relative overflow-hidden grid" data-grid>
|
||||
<span
|
||||
class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
|
||||
data-coords></span>
|
||||
<canvas class="position-absolute" data-canvas></canvas>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<script type="application/json" data-grid-doc>
|
||||
{{ json | safe }}
|
||||
</script>
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,25 +1,9 @@
|
|||
{# show label unless hidden/custom #}
|
||||
{% if field_type != 'hidden' and field_label %}
|
||||
<label for="{{ field_name }}"
|
||||
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
{% if link_href %}
|
||||
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
|
||||
{% endif %}
|
||||
{{ field_label }}
|
||||
{% if link_href %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
|
||||
|
||||
{% if field_type == 'select' %}
|
||||
<select name="{{ field_name }}" id="{{ field_name }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}
|
||||
{%- if not options %} disabled{% endif %}>
|
||||
{#
|
||||
<select name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
|
||||
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %} {%- if not options %} disabled{% endif %}>
|
||||
{% if options %}
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in options %}
|
||||
|
|
@ -31,53 +15,89 @@
|
|||
<option value="">-- No selection available --</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
#}
|
||||
{% if options %}
|
||||
{% if value %}
|
||||
{% 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 %}
|
||||
{% set sel_label = "-- Select --" %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% set sel_label = "-- Select --" %}
|
||||
{% endif %}
|
||||
<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 }}" data-field="{{ field_name }}" 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"
|
||||
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
|
||||
{% for opt in options %}
|
||||
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
|
||||
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')"
|
||||
id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No
|
||||
selection available --</button>
|
||||
{% endif %}
|
||||
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else '' }}">
|
||||
|
||||
{% elif field_type == 'textarea' %}
|
||||
<textarea name="{{ field_name }}" id="{{ field_name }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
|
||||
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
|
||||
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
|
||||
|
||||
{% elif field_type == 'checkbox' %}
|
||||
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1"
|
||||
{% if value %}checked{% endif %}
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" {% if value %}checked{% endif %} {% if
|
||||
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
|
||||
%}>
|
||||
|
||||
{% elif field_type == 'hidden' %}
|
||||
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}">
|
||||
|
||||
{% elif field_type == 'display' %}
|
||||
<div {% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
|
||||
<div {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor
|
||||
%}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
|
||||
|
||||
{% elif field_type == "date" %}
|
||||
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
|
||||
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
|
||||
{% elif field_type == "time" %}
|
||||
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
|
||||
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
|
||||
{% elif field_type == "datetime" %}
|
||||
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if
|
||||
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
|
||||
%}>
|
||||
|
||||
{% else %}
|
||||
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
|
||||
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
{% if help %}
|
||||
<div class="form-text">{{ help }}</div>
|
||||
{% endif %}
|
||||
{% 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,5 +1,6 @@
|
|||
<!-- TABLE {{ kwargs['object_class'] if kwargs else '(NO MODEL ASSOCIATED)' }} -->
|
||||
<div class="table-responsive" style="max-height: 80vh;">
|
||||
<table class="table 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>
|
||||
<tr>
|
||||
{% for col in columns %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block styleincludes %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/dropdown.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/image_display.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container mt-5">
|
||||
{{ form | safe }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptincludes %}
|
||||
<script src="{{ url_for('static', filename='js/components/image_display.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/components/dropdown.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,101 @@
|
|||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-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="submit" class="btn btn-outline-primary" id="submit">Save</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>
|
||||
|
||||
<script>
|
||||
window.newDrafts = window.newDrafts || [];
|
||||
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) {
|
||||
const fd = new FormData(form);
|
||||
|
|
@ -36,45 +126,38 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function collectExistingUpdateIds() {
|
||||
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
|
||||
.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:
|
||||
// URLs for create/update
|
||||
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;
|
||||
|
||||
formEl.addEventListener("submit", async e => {
|
||||
formEl.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submit');
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const json = formToJson(formEl);
|
||||
|
||||
if (model === 'inventory') {
|
||||
// the file input 'image' must NOT go into the JSON at all
|
||||
delete json.image;
|
||||
}
|
||||
|
||||
// Handle image for inventory
|
||||
if (model === 'inventory' && globalThis.ImageDisplay?.utilities) {
|
||||
const imgResult = await ImageDisplay.utilities.uploadIfChanged();
|
||||
|
||||
if (imgResult?.remove) {
|
||||
json.image_id = null;
|
||||
} else if (imgResult && imgResult.id) {
|
||||
json.image_id = imgResult.id; // ✅ this, and ONLY this
|
||||
}
|
||||
}
|
||||
|
||||
if (model === 'inventory' && typeof getMarkdown === 'function') {
|
||||
const md = getMarkdown();
|
||||
json.notes = (typeof md === 'string') ? getMarkdown().trim() : '';
|
||||
json.notes = (typeof md === 'string') ? md.trim() : '';
|
||||
} else if (model === 'worklog') {
|
||||
json.updates = collectEditedUpdates();
|
||||
json.delete_update_ids = collectDeletedIds();
|
||||
|
|
@ -84,38 +167,43 @@
|
|||
|
||||
const url = hasId ? updateUrl : createUrl;
|
||||
|
||||
try {
|
||||
console.log('Submitting JSON:', json);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(json),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
const reply = await res.json();
|
||||
|
||||
if (reply.status === 'success') {
|
||||
if (!hasId && reply.id) {
|
||||
window.queueToast('Created successfully.', 'success');
|
||||
window.newDrafts = [];
|
||||
window.deletedIds = [];
|
||||
window.location.assign(`/entry/${model}/${reply.id}`);
|
||||
|
||||
if (!hasId && reply.id) {
|
||||
queueToast('Created successfully.', 'success');
|
||||
location.assign(`/entry/${model}/${reply.id}`);
|
||||
return;
|
||||
} else {
|
||||
window.queueToast('Updated successfully.', 'success');
|
||||
}
|
||||
|
||||
queueToast('Updated successfully.', 'success');
|
||||
|
||||
if (model === 'worklog') {
|
||||
for (const id of collectDeletedIds()) {
|
||||
const li = document.getElementById(`note-${id}`);
|
||||
if (li) li.remove();
|
||||
document.getElementById(`note-${id}`)?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
window.newDrafts = [];
|
||||
window.deletedIds = [];
|
||||
window.location.replace(window.location.href);
|
||||
return;
|
||||
}
|
||||
location.replace(location.href);
|
||||
} else {
|
||||
toastMessage(reply.message || 'Server reported failure.', 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
toastMessage(`Network error: ${String(err)}`, 'danger');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</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 %}
|
||||
|
|
@ -1,6 +1,43 @@
|
|||
{% set model = field['attrs']['data-model'] %}
|
||||
{% set image_id = field['template_ctx']['values'].get('image_id') %}
|
||||
|
||||
{% set buttons %}
|
||||
<div class="btn-group position-absolute end-0 top-0 mt-2 me-2 border image-buttons">
|
||||
<button type="button" class="btn btn-light" id="add-inventory-image"
|
||||
onclick="ImageDisplay.utilities.onAddButtonClick();"><svg xmlns="http://www.w3.org/2000/svg" width="16"
|
||||
height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2" />
|
||||
</svg></button>
|
||||
<button type="button" class="btn btn-danger{% if not value %} d-none{% endif %}" id="remove-inventory-image"
|
||||
onclick="ImageDisplay.utilities.onRemoveButtonClick();">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash"
|
||||
viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z" />
|
||||
<path
|
||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ buttons }}
|
||||
{% if value %}
|
||||
<img src="{{ url_for('static', filename=field['value_label']) }}" alt="{{ value }}" {% if field['attrs'] %}{% for k,v in
|
||||
field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}
|
||||
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100">
|
||||
<img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100"
|
||||
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
|
||||
{% endif %}
|
||||
<input type="text" class="form-control" id="caption" name="caption"
|
||||
value="{{ field['template_ctx']['values']['image.caption'] if value else '' }}">
|
||||
<input type="hidden" id="image_id" name="image_id" value="{{ image_id if image_id is not none else '' }}">
|
||||
<input type="file" class="d-none" name="image" id="image" accept="image/*"
|
||||
onchange="ImageDisplay.utilities.onFileChange();">
|
||||
|
||||
<script>
|
||||
// URL for image upload
|
||||
window.IMAGE_UPLOAD_URL = {{ url_for('image.upload_image') | tojson }};
|
||||
window.IMAGE_OWNER_MODEL = {{ model | tojson }};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,44 @@
|
|||
<p class="lead text-center">Find out about all of your assets.</p>
|
||||
|
||||
<div class="row mx-5">
|
||||
<div class="col pivot-cell ms-5">
|
||||
<div class="col">
|
||||
<p class="display-6 text-center">Active Worklogs</p>
|
||||
{{ logs | safe }}
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
|
@ -28,40 +28,30 @@
|
|||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderView(getMarkdown());
|
||||
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
|
||||
});
|
||||
|
||||
function getMarkdown() {
|
||||
const el = document.getElementById('noteContent');
|
||||
return el ? (JSON.parse(el.textContent) || "") : "";
|
||||
}
|
||||
// used by entry_buttons submit
|
||||
window.getMarkdown = function () {
|
||||
return readJSONScript('noteContent', "");
|
||||
};
|
||||
|
||||
function setMarkdown(md) {
|
||||
const el = document.getElementById('noteContent');
|
||||
if (el) el.textContent = JSON.stringify(md ?? "");
|
||||
}
|
||||
|
||||
function renderView(md) {
|
||||
const container = document.getElementById('editContainer');
|
||||
if (!container) return;
|
||||
const html = marked.parse(md || "", {gfm: true});
|
||||
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] });
|
||||
for (const a of container.querySelectorAll('a[href]')) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||
}
|
||||
for (const t of container.querySelectorAll('table')) {
|
||||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||
}
|
||||
}
|
||||
|
||||
function changeMode() {
|
||||
const container = document.getElementById('editContainer');
|
||||
const toggle = document.getElementById('editSwitch');
|
||||
if (!toggle.checked) return renderView(getMarkdown());
|
||||
if (!toggle.checked) {
|
||||
MarkDown.renderInto(container, getMarkdown());
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getMarkdown();
|
||||
container.innerHTML = `
|
||||
|
|
@ -82,13 +72,13 @@
|
|||
const ta = document.getElementById('editor');
|
||||
const value = ta ? ta.value : "";
|
||||
setMarkdown(value);
|
||||
renderView(value);
|
||||
MarkDown.renderInto(document.getElementById('editContainer'), value);
|
||||
document.getElementById('editSwitch').checked = false;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
document.getElementById('editSwitch').checked = false;
|
||||
renderView(getMarkdown());
|
||||
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
|
|
@ -96,13 +86,13 @@
|
|||
const preview = document.getElementById('preview');
|
||||
preview.classList.toggle('d-none');
|
||||
if (!preview.classList.contains('d-none')) {
|
||||
const html = marked.parse(ta ? ta.value : "");
|
||||
preview.innerHTML = DOMPurify.sanitize(html);
|
||||
MarkDown.renderInto(preview, ta ? ta.value : "");
|
||||
}
|
||||
}
|
||||
|
||||
function autoGrow(ta) {
|
||||
if (!ta) return;
|
||||
if (CSS?.supports?.('field-sizing: content')) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = ta.scrollHeight + 'px';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ Inventory Manager - {{ model|title }} Listing
|
|||
|
||||
{% block main %}
|
||||
<div class="mx-5">
|
||||
<h1 class="display-4 text-center mt-2">{{ model|title }} Listing</h1>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary mb-3"
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="btn-group h-50 align-self-end">
|
||||
<button type="button" class="btn btn-primary mb-2"
|
||||
onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button>
|
||||
</div>
|
||||
<h1 class="display-6 text-center">{{ model|title }} Listing</h1>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{{ table | safe }}
|
||||
|
||||
|
|
|
|||
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 %}
|
||||
|
|
@ -5,17 +5,76 @@
|
|||
<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 %}
|
||||
|
||||
{% block main %}
|
||||
<form id="settings_form" method="post">
|
||||
<div class="container">
|
||||
<ul class="nav nav-pills nav-fill">
|
||||
<li class="nav-item">
|
||||
<button type="button" class="nav-link active" id="device-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
|
||||
<button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#device-tab-pane">Devices</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button type="button" class="nav-link" id="location-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
|
||||
<button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#location-tab-pane">Locations</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -24,12 +83,51 @@
|
|||
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="brand" class="form-label">Brand</label>
|
||||
<label for="brand" class="form-label">Brands</label>
|
||||
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="devicetype" class="form-label">Device Type</label>
|
||||
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
|
||||
<label for="devicetype" class="form-label">Device Types</label>
|
||||
{# { 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>
|
||||
|
|
@ -37,11 +135,11 @@
|
|||
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="area" class="form-label">Area</label>
|
||||
<label for="area" class="form-label">Areas</label>
|
||||
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="roomfunction" class="form-label">Description</label>
|
||||
<label for="roomfunction" class="form-label">Descriptions</label>
|
||||
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,7 +147,8 @@
|
|||
<div class="row mt-3">
|
||||
<label for="rooms" class="form-label">
|
||||
Rooms
|
||||
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
<a href="{{ url_for('entry.entry_new', model='room') }}"
|
||||
class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
</label>
|
||||
<div class="col">
|
||||
{{ rooms | safe }}
|
||||
|
|
@ -65,3 +164,264 @@
|
|||
{% block scriptincludes %}
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
|
||||
<div class="table-responsive mx-5">
|
||||
<table class="table table-sm table-striped table-hover table-bordered align-middle">
|
||||
<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 mb-0">
|
||||
<thead>
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
<th class="text-end">{{ col.label }}</th>
|
||||
<th class="text-end position-sticky top-0 bg-body border">{{ col.label }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in table_rows %}
|
||||
<tr>
|
||||
<th class="text-nowrap">
|
||||
{% set need_more = (row['cells'][-2]['value'] | int > 0) %}
|
||||
<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 %}
|
||||
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
|
||||
{% else %}
|
||||
|
|
@ -27,9 +31,13 @@
|
|||
</th>
|
||||
{% for cell in row.cells %}
|
||||
{% if cell.href %}
|
||||
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
|
||||
<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 %}
|
||||
<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 %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
|
|
|
|||
42
inventory/templates/testing.html
Normal file
42
inventory/templates/testing.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% import 'components/draw.html' as draw %}
|
||||
|
||||
{% block styleincludes %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/draw.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% set jsonImage %}
|
||||
{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","cl":"#ffff00","f":true},{"t":"e","p":[0,0,2500,2500]},{"t":"s","cl":"#ffffff"},{"t":"e","p":[500,600,600,600,300,-600,600,600]},{"t":"s","cl":"#000000"},{"t":"e","p":[700,800,200,200,700,-200,200,200,-800,300,500,1000]},{"t":"s","f":false},{"t":"e","p":[500,600,600,600,300,-600,600,600,-2000,-1200,2500,2500]},{"t":"l","p":[400,700,500,-300,1200,300,-500,-300]}]}
|
||||
{% endset %}
|
||||
{% set jsonImage2 %}
|
||||
{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","cl":"#ffe59e","f":true},{"t":"e","p":[0,0,2500,2500]},{"t":"s","cl":"#fdc8fe"},{"t":"e","p":[100,100,2300,2300]},{"t":"s","cl":"#fbe6a1"},{"t":"e","p":[600,600,1300,1300]},{"t":"s","cl":"#ffffff"},{"t":"e","p":[700,700,1100,1100]},{"t":"s","cl":"#000000","f":false},{"t":"e","p":[0,0,2500,2500,-2400,-2400,2300,2300,-1800,-1800,1300,1300,-1200,-1200,1100,1100]},{"t":"s","sw":37,"cl":"#ff0000"},{"t":"l","p":[600,500,100,-100,500,0,100,0,500,200,100,100,200,200,100,-100,-600,-400,0,-100,-800,300,-100,100,-400,800,0,-100,100,-400,-100,-100,200,1000,100,0,-100,-600,-100,-100,500,1000,100,-100,-200,-200,0,100,500,200,100,-100,200,-200,100,100,-500,0,-100,100,800,-400,100,100,0,-400,100,100,0,-300,100,0,-200,-100,0,-100,0,-400,0,100,-400,-200,100,0,-600,-200,100,-100,-300,100,0,100,-300,500,-100,-100,0,900,100,-100,1300,400,100,-100,200,-200,0,-100,100,-200,0,-100]},{"t":"s","cl":"#00ff00"},{"t":"l","p":[400,700,100,0,500,-200,0,-100,400,-200,0,100,100,200,100,100,200,-200,100,100,300,600,100,0,-400,-300,100,100,100,400,100,0,100,200,-100,0,-200,100,100,100,0,200,100,-100,-400,100,0,-100,-100,300,100,0,-200,200,0,-100,-100,-200,0,100,-200,200,100,0,-300,0,0,-100,200,-100,100,-100,-400,0,100,0,-300,100,100,0,-200,-300,0,100,-200,-100,100,0,-200,-100,0,-100,100,-100,0,-100,-300,-100,100,0,0,-200,100,0,100,-200,0,100,100,-400,100,0]},{"t":"s","cl":"#0000ff"},{"t":"l","p":[800,400,0,100,300,0,100,0,200,-100,100,-100,200,0,0,100,300,100,100,100,0,200,0,-100,0,300,100,0,-200,300,0,-100,100,200,100,0,-300,100,0,100,0,200,0,100,-200,-100,0,100,200,200,-100,-100,0,200,-100,0,-100,-100,0,-100,-100,200,-100,0,-200,100,0,-100,-300,100,100,-100,-100,-300,0,100,-300,100,100,0,-200,-100,100,0,-200,-100,-100,-100,0,-200,100,-100,0,-200,-100,-100,100,-400,-100,0,200,300,100,-100,100,-200,-100,-100,300,-100,100,0,-500,-100,-100,100,1300,100,100,100,-1200,900,100,0]}]}
|
||||
{% endset %}
|
||||
<div class="row">
|
||||
<div class="col" style="height: 80vh">
|
||||
{{ draw.drawWidget('test1') }}
|
||||
</div>
|
||||
<!-- div class="col">
|
||||
{{ draw.drawWidget('test4') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{{ draw.drawWidget('test5') }}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ draw.drawWidget('test6') }}
|
||||
</div -->
|
||||
<div class="col" style="height: 80vh;">
|
||||
I am testing a thing.
|
||||
{{ draw.viewWidget('test2', jsonImage) }}
|
||||
{{ draw.viewWidget('test3', jsonImage2) }}
|
||||
The thing has been tested.
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptincludes %}
|
||||
<script type="module" src="{{ url_for('static', filename='js/components/grid/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{% set items = (field.template_ctx.instance.updates or []) %}
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Add update</label>
|
||||
<div class="mt-3 form-floating">
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
<ul class="list-group mt-3">
|
||||
{% for n in items %}
|
||||
<li class="list-group-item">
|
||||
<li class="list-group-item" id="note-{{ n.id }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
|
||||
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
|
||||
|
|
@ -63,44 +63,15 @@
|
|||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
|
||||
<script>
|
||||
// State (kept global for compatibility with your form serialization)
|
||||
window.newDrafts = window.newDrafts || [];
|
||||
window.deletedIds = window.deletedIds || [];
|
||||
|
||||
// ---------- DRY UTILITIES ----------
|
||||
function renderMarkdown(md) {
|
||||
// One place to parse + sanitize
|
||||
const raw = marked.parse(md || "");
|
||||
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
|
||||
}
|
||||
|
||||
function enhanceLinks(root) {
|
||||
if (!root) return;
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceTables(root) {
|
||||
if (!root) return;
|
||||
for (const t of root.querySelectorAll('table')) {
|
||||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHTML(el, md) {
|
||||
if (!el) return;
|
||||
el.innerHTML = renderMarkdown(md);
|
||||
enhanceLinks(el);
|
||||
enhanceTables(el);
|
||||
}
|
||||
|
||||
function getMarkdown(id) {
|
||||
const el = document.getElementById(`md-${id}`);
|
||||
return el ? JSON.parse(el.textContent || '""') : "";
|
||||
return readJSONScript(`md-${id}`, "");
|
||||
}
|
||||
|
||||
function setMarkdown(id, md) {
|
||||
|
|
@ -114,14 +85,14 @@
|
|||
|
||||
function autoGrow(ta) {
|
||||
if (!ta) return;
|
||||
if (CSS?.supports?.('field-sizing: content')) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = (ta.scrollHeight + 5) + 'px';
|
||||
}
|
||||
|
||||
// ---------- RENDERERS ----------
|
||||
function renderExistingView(id) {
|
||||
const container = document.getElementById(`editContainer${id}`);
|
||||
renderHTML(container, getMarkdown(id));
|
||||
MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
|
||||
}
|
||||
|
||||
function renderEditor(id) {
|
||||
|
|
@ -159,8 +130,7 @@
|
|||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'w-100 markdown-body';
|
||||
left.innerHTML = renderMarkdown(md || '');
|
||||
enhanceLinks(left);
|
||||
MarkDown.renderInto(left, md || '');
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'ms-3 d-flex flex-column align-items-end';
|
||||
|
|
@ -263,7 +233,7 @@
|
|||
if (!preview) return;
|
||||
preview.classList.toggle('d-none');
|
||||
if (!preview.classList.contains('d-none')) {
|
||||
preview.innerHTML = renderMarkdown(ta ? ta.value : "");
|
||||
MarkDown.renderInto(preview, ta ? ta.value : "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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