Compare commits
2 commits
dc3482f887
...
4ef4d5e23f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef4d5e23f | ||
|
|
8481a40553 |
10 changed files with 391 additions and 91 deletions
|
|
@ -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
|
||||
|
|
@ -42,6 +43,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)}")
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ def _fields_for_model(model: str):
|
|||
"title",
|
||||
"active",
|
||||
"staff",
|
||||
"supervisor.id"
|
||||
"supervisor.id",
|
||||
"inventory.label",
|
||||
"inventory.brand.name",
|
||||
"inventory.model",
|
||||
"inventory.device_type.description"
|
||||
]
|
||||
fields_spec = [
|
||||
{"name": "label", "row": "label", "label": "", "type": "display",
|
||||
|
|
@ -106,6 +110,7 @@ def _fields_for_model(model: str):
|
|||
"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"},
|
||||
]
|
||||
layout = [
|
||||
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||
|
|
@ -113,6 +118,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},
|
||||
]
|
||||
|
||||
elif model == "worklog":
|
||||
|
|
@ -127,7 +133,7 @@ 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}",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
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
|
||||
|
|
@ -9,83 +11,196 @@ 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.description", "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.description",
|
||||
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(
|
||||
|
|
@ -94,7 +209,6 @@ def init_reports_routes(app):
|
|||
table_rows=table_rows,
|
||||
)
|
||||
|
||||
|
||||
@bp_reports.get("/problems")
|
||||
def problems():
|
||||
inventory_model = crudkit.crud.get_model('inventory')
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ def init_settings_routes(app):
|
|||
],
|
||||
})
|
||||
statuses = render_table(statuses, opts={"object_class": 'status'})
|
||||
print([t.as_dict() for t in device_types])
|
||||
|
||||
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses)
|
||||
|
||||
|
|
|
|||
8
inventory/templates/errors/500.html
Normal file
8
inventory/templates/errors/500.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Internal Server Error{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="display-1 text-center">Internal Server Error</h1>
|
||||
<div class="alert alert-danger text-center">This service has encountered an error. This error has been logged. Please try again later.</div>
|
||||
{% endblock %}
|
||||
8
inventory/templates/errors/default.html
Normal file
8
inventory/templates/errors/default.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ code }} {{ name }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="display-1 text-center">{{ code }} - {{ name }}</h1>
|
||||
<div class="alert alert-danger text-center">{{ description }}</div>
|
||||
{% endblock %}
|
||||
|
|
@ -5,9 +5,44 @@
|
|||
<p class="lead text-center">Find out about all of your assets.</p>
|
||||
|
||||
<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 %}
|
||||
|
|
@ -1,24 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block style %}
|
||||
thead.sticky-top th {
|
||||
z-index: 2;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
<tr class="position-sticky top-0 bg-body border">
|
||||
<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 +37,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>
|
||||
|
|
|
|||
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">
|
||||
<table class="table table-sm table-bordered table-striped table-hover">
|
||||
{% if inv %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Brand</th>
|
||||
<th>Model</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in inv %}
|
||||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue