forked from FrederikBaerentsen/BrickTracker
feat(sets): per-bag inventory with progress tracking and bag-aware audit
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
from typing import Any
|
||||
|
||||
from .exceptions import ErrorException
|
||||
from .sql import BrickSQL
|
||||
|
||||
# Per-bag part state (checked/missing) for sets with a sidecar bag inventory.
|
||||
# The bag composition itself is never stored; only the user's progress is
|
||||
# (keyed by set record id, bag number and the part identity tuple).
|
||||
# Plain functions on purpose: a BrickRecord model is overkill for a
|
||||
# two-column state table.
|
||||
|
||||
|
||||
# Update one state field ('checked' or 'missing') from a changer.js payload.
|
||||
# Returns the stored value.
|
||||
def update_state(
|
||||
set_id: str,
|
||||
bag: str,
|
||||
part: str,
|
||||
color: int,
|
||||
spare: int,
|
||||
state: str,
|
||||
json: Any | None,
|
||||
/,
|
||||
) -> Any:
|
||||
value = json.get('value', '') if json else ''
|
||||
|
||||
if state == 'checked':
|
||||
value = bool(value)
|
||||
elif state == 'missing':
|
||||
# Same rules as BrickPart.update_problem: positive integer, '' -> 0
|
||||
try:
|
||||
if value == '':
|
||||
value = 0
|
||||
|
||||
value = int(value)
|
||||
|
||||
if value < 0:
|
||||
value = 0
|
||||
except Exception:
|
||||
raise ErrorException('"{value}" is not a valid integer'.format(
|
||||
value=value
|
||||
))
|
||||
else:
|
||||
raise ErrorException('"{state}" is not a valid bag part state'.format(
|
||||
state=state
|
||||
))
|
||||
|
||||
BrickSQL().execute_and_commit(
|
||||
'bag_part/update/{state}'.format(state=state),
|
||||
parameters={
|
||||
'id': set_id,
|
||||
'bag': bag,
|
||||
'part': part,
|
||||
'color': color,
|
||||
'spare': spare,
|
||||
state: value,
|
||||
},
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# All stored state for one set:
|
||||
# {(bag, part, color, spare): {'checked': bool, 'missing': int}}
|
||||
def list_state(
|
||||
set_id: str,
|
||||
/,
|
||||
) -> dict[tuple[str, str, int, int], dict[str, Any]]:
|
||||
rows = BrickSQL().fetchall(
|
||||
'bag_part/list',
|
||||
parameters={'id': set_id},
|
||||
)
|
||||
|
||||
return {
|
||||
(row['bag'], row['part'], row['color'], row['spare']): {
|
||||
'checked': bool(row['checked']),
|
||||
'missing': row['missing'],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
@@ -363,6 +363,12 @@ class BrickSidecar(object):
|
||||
def get_images(ref: str, /) -> dict[str, Any] | None:
|
||||
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
|
||||
|
||||
# GET /sets/{ref}/bags -> {'set': ..., 'bag_count': n, 'bags': [...]},
|
||||
# or None (the sidecar 404s when the set has no bag inventory).
|
||||
@staticmethod
|
||||
def get_bags(ref: str, /) -> dict[str, Any] | None:
|
||||
return BrickSidecar._get_json('/sets/{ref}/bags'.format(ref=ref))
|
||||
|
||||
# Build a BrickTracker-served proxy URL for an <img src>. The browser hits
|
||||
# BrickTracker (same origin), which fetches the image from the sidecar
|
||||
# server-side. This works even when the sidecar is only reachable on the
|
||||
|
||||
+146
-1
@@ -4,9 +4,10 @@ from datetime import datetime, timezone
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app
|
||||
from flask import current_app, url_for
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
from .bag_part import list_state as list_bag_part_state
|
||||
from .sidecar import BrickSidecar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,6 +66,8 @@ def summarize(
|
||||
# Brickset "Notes" blurb (web-scraped; contains light HTML). Sanitised
|
||||
# like the description so it renders safely.
|
||||
'notes': _description(data.get('notes')),
|
||||
# Whether the sidecar holds a per-bag part inventory for this set.
|
||||
'has_bags': bool(data.get('has_bags')),
|
||||
}
|
||||
|
||||
# Retired status from the exit date. exit_date is the full formatted date
|
||||
@@ -131,6 +134,148 @@ def summarize(
|
||||
return summary
|
||||
|
||||
|
||||
# Join the sidecar's per-bag inventory onto the set's part rows. Returns
|
||||
# (bags, breakdown):
|
||||
# - bags: template-ready [{'number', 'is_extra', 'parts': [...]}] in sidecar
|
||||
# order, each part carrying its per-bag quantity, display data, the stored
|
||||
# per-bag checked/missing state with its changer prefixes/urls, and the DOM
|
||||
# id of the main row's missing input (the sum-group key for the client-side
|
||||
# missing sync).
|
||||
# - breakdown: part row html_id() -> [[bag number, quantity], ...] pairs, for
|
||||
# the bag column in the normal audit modal.
|
||||
# Degrades to (None, {}) whenever the sidecar is off or has no bag data; the
|
||||
# bag composition is joined at render time and never persisted (only the
|
||||
# per-bag progress lives in bricktracker_bag_parts).
|
||||
def bag_inventory(
|
||||
brickset: Any,
|
||||
parts: Any,
|
||||
/,
|
||||
) -> tuple[list[dict[str, Any]] | None, dict[str, list[list[Any]]]]:
|
||||
if not BrickSidecar.enabled():
|
||||
return None, {}
|
||||
|
||||
payload = BrickSidecar.get_bags(brickset.fields.set)
|
||||
if not payload or not payload.get('bags'):
|
||||
return None, {}
|
||||
|
||||
# One BrickTracker row per (part, color, spare) for the whole set; bag
|
||||
# entries reference it many-to-one via (part_ex_id, color_id).
|
||||
index: dict[tuple[str, int, int], Any] = {
|
||||
(str(row.fields.part), int(row.fields.color), int(row.fields.spare)): row # noqa: E501
|
||||
for row in parts
|
||||
}
|
||||
|
||||
# Stored per-bag progress, keyed (bag, part, color, spare)
|
||||
state = list_bag_part_state(brickset.fields.id)
|
||||
|
||||
# The changer prefixes keep the "-missing-"/"-checked-" fragments so the
|
||||
# bulk operations and audit selectors match, and a per-bag "bag{i}"
|
||||
# namespace so ids stay unique across bags.
|
||||
def changer(index: int, kind: str, part: str, color: int, spare: int, bag: str) -> dict[str, Any]: # noqa: E501
|
||||
return {
|
||||
'prefix_{kind}'.format(kind=kind): 'bag{index}-part-{kind}-{part}-{color}-{spare}'.format( # noqa: E501
|
||||
index=index, kind=kind, part=part, color=color, spare=spare,
|
||||
),
|
||||
'url_{kind}'.format(kind=kind): url_for(
|
||||
'set.bag_part_state',
|
||||
id=brickset.fields.id,
|
||||
bag=bag,
|
||||
part=part,
|
||||
color=color,
|
||||
spare=spare,
|
||||
state=kind,
|
||||
),
|
||||
}
|
||||
|
||||
bags: list[dict[str, Any]] = []
|
||||
breakdown: dict[str, list[list[Any]]] = {}
|
||||
|
||||
for i, bag in enumerate(payload['bags'], start=1):
|
||||
number = str(bag.get('displayNumbers') or bag.get('bagNumber') or '?')
|
||||
# "Extra" bags sometimes hold the spares, so try spare rows first
|
||||
# there (with a fallback either way, see spare_order below)
|
||||
is_extra = number.lower() == 'extra'
|
||||
spare_order = (1, 0) if is_extra else (0, 1)
|
||||
|
||||
bag_parts: list[dict[str, Any]] = []
|
||||
for part in bag.get('parts') or []:
|
||||
part_ref = str(part.get('part_ex_id') or part.get('bl_part_id') or '') # noqa: E501
|
||||
try:
|
||||
color = int(part.get('color_id'))
|
||||
except (TypeError, ValueError):
|
||||
color = -1
|
||||
quantity = part.get('quantity') or 0
|
||||
|
||||
row = None
|
||||
for spare in spare_order:
|
||||
row = index.get((part_ref, color, spare))
|
||||
if row is not None:
|
||||
break
|
||||
|
||||
if row is not None:
|
||||
part_state = state.get(
|
||||
(number, row.fields.part, row.fields.color, row.fields.spare), # noqa: E501
|
||||
{},
|
||||
)
|
||||
entry: dict[str, Any] = {
|
||||
'quantity': quantity,
|
||||
'name': row.fields.name,
|
||||
'color': row.fields.color,
|
||||
'color_name': row.fields.color_name,
|
||||
'color_rgb': row.fields.color_rgb,
|
||||
'spare': bool(row.fields.spare),
|
||||
'image_url': row.url_for_image(),
|
||||
'url': row.url(),
|
||||
# Final DOM id of the main row's missing input
|
||||
# (macro/form.html renders "{prefix}-{id}").
|
||||
'missing_input_id': '{prefix}-{id}'.format(
|
||||
prefix=row.html_id('missing'),
|
||||
id=row.fields.id,
|
||||
),
|
||||
'checked': part_state.get('checked', False),
|
||||
# None renders an empty input, like the main table
|
||||
'missing': part_state.get('missing') or None,
|
||||
}
|
||||
entry.update(changer(i, 'missing', row.fields.part, row.fields.color, row.fields.spare, number)) # noqa: E501
|
||||
entry.update(changer(i, 'checked', row.fields.part, row.fields.color, row.fields.spare, number)) # noqa: E501
|
||||
bag_parts.append(entry)
|
||||
breakdown.setdefault(row.html_id(), []).append(
|
||||
[number, quantity],
|
||||
)
|
||||
else:
|
||||
# No matching row (e.g. a part the import assigned to a
|
||||
# minifigure): display-only data from the sidecar, but the
|
||||
# per-bag progress is still stored (keyed by the sidecar
|
||||
# part ref, spare=0). A negative color means the sidecar
|
||||
# gave no usable identity, so the row gets no inputs.
|
||||
part_state = state.get((number, part_ref, color, 0), {})
|
||||
entry = {
|
||||
'quantity': quantity,
|
||||
'name': part_ref or str(part.get('partId') or '?'),
|
||||
'color': None,
|
||||
'color_name': part.get('colorName'),
|
||||
'color_rgb': str(part.get('colorHex') or '').lstrip('#') or None, # noqa: E501
|
||||
'spare': False,
|
||||
'image_url': None,
|
||||
'url': None,
|
||||
'missing_input_id': None,
|
||||
'checked': part_state.get('checked', False),
|
||||
'missing': part_state.get('missing') or None,
|
||||
}
|
||||
if part_ref and color >= 0:
|
||||
entry.update(changer(i, 'missing', part_ref, color, 0, number)) # noqa: E501
|
||||
entry.update(changer(i, 'checked', part_ref, color, 0, number)) # noqa: E501
|
||||
bag_parts.append(entry)
|
||||
|
||||
bags.append({
|
||||
'number': number,
|
||||
'is_extra': is_extra,
|
||||
'parts': bag_parts,
|
||||
})
|
||||
|
||||
return bags, breakdown
|
||||
|
||||
|
||||
# --- Helpers ------------------------------------------------------------
|
||||
|
||||
def _clean_str(value: Any) -> str | None:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
SELECT "bag", "part", "color", "spare", "checked", "missing"
|
||||
FROM "bricktracker_bag_parts"
|
||||
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM :id
|
||||
@@ -0,0 +1,4 @@
|
||||
INSERT INTO "bricktracker_bag_parts" ("id", "bag", "part", "color", "spare", "checked")
|
||||
VALUES (:id, :bag, :part, :color, :spare, :checked)
|
||||
ON CONFLICT("id", "bag", "part", "color", "spare")
|
||||
DO UPDATE SET "checked" = excluded."checked"
|
||||
@@ -0,0 +1,4 @@
|
||||
INSERT INTO "bricktracker_bag_parts" ("id", "bag", "part", "color", "spare", "missing")
|
||||
VALUES (:id, :bag, :part, :color, :spare, :missing)
|
||||
ON CONFLICT("id", "bag", "part", "color", "spare")
|
||||
DO UPDATE SET "missing" = excluded."missing"
|
||||
@@ -0,0 +1,21 @@
|
||||
-- description: Add per-bag part state (checked/missing) for sidecar bag inventories
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Per-bag progress for sets with a sidecar bag inventory. The bag composition
|
||||
-- itself is never persisted (joined from the sidecar at render time); this
|
||||
-- only stores what the user ticked/typed per bag. The main parts row's
|
||||
-- missing count is kept as the sum of these per-bag values client-side.
|
||||
CREATE TABLE "bricktracker_bag_parts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bag" TEXT NOT NULL,
|
||||
"part" TEXT NOT NULL,
|
||||
"color" INTEGER NOT NULL,
|
||||
"spare" INTEGER NOT NULL DEFAULT 0,
|
||||
"checked" INTEGER NOT NULL DEFAULT 0,
|
||||
"missing" INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY("id", "bag", "part", "color", "spare"),
|
||||
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -10,6 +10,9 @@ WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
|
||||
DELETE FROM "bricktracker_minifigures"
|
||||
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
|
||||
|
||||
DELETE FROM "bricktracker_bag_parts"
|
||||
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
|
||||
|
||||
DELETE FROM "bricktracker_set_tags"
|
||||
WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[str] = '1.5.0'
|
||||
__database_version__: Final[int] = 29
|
||||
__database_version__: Final[int] = 30
|
||||
|
||||
@@ -14,6 +14,7 @@ from flask_login import login_required
|
||||
from werkzeug.wrappers.response import Response
|
||||
|
||||
from .exceptions import exception_handler
|
||||
from ..bag_part import update_state as update_bag_part_state
|
||||
from ..exceptions import ErrorException
|
||||
from ..minifigure import BrickMinifigure
|
||||
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
|
||||
@@ -22,6 +23,7 @@ from ..rebrickable_image import RebrickableImage
|
||||
from ..rebrickable_set import RebrickableSet
|
||||
from ..set import BrickSet
|
||||
from ..sidecar import BrickSidecar
|
||||
from ..sidecar_set import bag_inventory as sidecar_bag_inventory
|
||||
from ..sidecar_set import summarize as sidecar_summarize
|
||||
from ..set_custom_field_list import BrickSetCustomFieldList
|
||||
from ..set_list import BrickSetList, set_metadata_lists
|
||||
@@ -390,6 +392,15 @@ def details(*, id: str) -> str:
|
||||
fetch_price=current_app.config.get('SIDECAR_AUTO_FETCH_PRICE', False),
|
||||
)
|
||||
|
||||
# Per-bag inventory (BrickData): joined onto the part rows at render
|
||||
# time, never persisted. Any failure degrades to "no bag UI".
|
||||
sidecar_bags, bag_breakdown = None, {}
|
||||
if sidecar_summary and sidecar_summary.get('has_bags'):
|
||||
sidecar_bags, bag_breakdown = sidecar_bag_inventory(
|
||||
item,
|
||||
item.parts(),
|
||||
)
|
||||
|
||||
# Check if there are multiple instances of this set
|
||||
all_instances = BrickSetList()
|
||||
# Load all sets with metadata context for tags, owners, etc.
|
||||
@@ -415,6 +426,8 @@ def details(*, id: str) -> str:
|
||||
open_instructions=request.args.get('open_instructions'),
|
||||
brickset_statuses=BrickSetStatusList.list(all=True),
|
||||
sidecar_summary=sidecar_summary,
|
||||
sidecar_bags=sidecar_bags,
|
||||
bag_breakdown=bag_breakdown,
|
||||
**set_metadata_lists(as_class=True)
|
||||
)
|
||||
else:
|
||||
@@ -425,6 +438,8 @@ def details(*, id: str) -> str:
|
||||
open_instructions=request.args.get('open_instructions'),
|
||||
brickset_statuses=BrickSetStatusList.list(all=True),
|
||||
sidecar_summary=sidecar_summary,
|
||||
sidecar_bags=sidecar_bags,
|
||||
bag_breakdown=bag_breakdown,
|
||||
**set_metadata_lists(as_class=True)
|
||||
)
|
||||
|
||||
@@ -519,6 +534,47 @@ def checked_part(
|
||||
return jsonify({'checked': checked})
|
||||
|
||||
|
||||
# Update per-bag part state (sidecar bag inventory walkthrough)
|
||||
@set_page.route('/<id>/bags/<bag>/parts/<part>/<int:color>/<int:spare>/<state>', methods=['POST']) # noqa: E501
|
||||
@login_required
|
||||
@exception_handler(__file__, json=True)
|
||||
def bag_part_state(
|
||||
*,
|
||||
id: str,
|
||||
bag: str,
|
||||
part: str,
|
||||
color: int,
|
||||
spare: int,
|
||||
state: str,
|
||||
) -> Response:
|
||||
# Validates the set exists (raises otherwise)
|
||||
brickset = BrickSet().select_specific(id)
|
||||
|
||||
value = update_bag_part_state(
|
||||
brickset.fields.id,
|
||||
bag,
|
||||
part,
|
||||
color,
|
||||
spare,
|
||||
state,
|
||||
request.json,
|
||||
)
|
||||
|
||||
# Info
|
||||
logger.info('Set {set} ({id}): updated bag {bag} part ({part} color: {color}, spare: {spare}) {state} to {value}'.format( # noqa: E501
|
||||
set=brickset.fields.set,
|
||||
id=brickset.fields.id,
|
||||
bag=bag,
|
||||
part=part,
|
||||
color=color,
|
||||
spare=spare,
|
||||
state=state,
|
||||
value=value,
|
||||
))
|
||||
|
||||
return jsonify({state: value})
|
||||
|
||||
|
||||
# Refresh a set
|
||||
@set_page.route('/refresh/<set>/', methods=['GET'])
|
||||
@set_page.route('/<id>/refresh', methods=['GET'])
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Keeps the main Parts table's missing count equal to the sum of the per-bag
|
||||
// missing inputs for that part (a part can span several bags). Bag rows carry
|
||||
// data-target-missing = the main row's input id; writing the main input and
|
||||
// dispatching "change" lets the existing BrickChanger persist it.
|
||||
//
|
||||
// The sum always wins: a manual edit of the main field is overwritten by the
|
||||
// next bag edit for that part. Parts without bag inputs stay fully manual.
|
||||
(() => {
|
||||
const sync = (target) => {
|
||||
const main = document.getElementById(target);
|
||||
if (!main || main.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
document.querySelectorAll(`table[data-bag-table] tr[data-target-missing="${CSS.escape(target)}"] input[id*="-missing-"]`).forEach(input => {
|
||||
sum += parseInt(input.value, 10) || 0;
|
||||
});
|
||||
|
||||
// Empty string clears the field (0 missing), matching the "clear all
|
||||
// missing" convention in parts-bulk-operations.js.
|
||||
const value = sum > 0 ? String(sum) : '';
|
||||
if (main.value !== value) {
|
||||
main.value = value;
|
||||
main.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const handle = (e) => {
|
||||
// Trusted "input" events fire per keystroke; the sum only needs to
|
||||
// follow once the value is committed ("change"). The untrusted case
|
||||
// is the BrickChanger clear button, which POSTs directly and only
|
||||
// dispatches a programmatic "input" event.
|
||||
if (e.type === 'input' && e.isTrusted) {
|
||||
return;
|
||||
}
|
||||
const input = e.target;
|
||||
if (!input.matches || !input.matches('input[id*="-missing-"]')) {
|
||||
return;
|
||||
}
|
||||
const row = input.closest('tr[data-target-missing]');
|
||||
if (row && input.closest('table[data-bag-table]')) {
|
||||
sync(row.dataset.targetMissing);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('change', handle);
|
||||
document.addEventListener('input', handle);
|
||||
});
|
||||
})();
|
||||
@@ -17,6 +17,12 @@ class PartsAuditMode {
|
||||
this.index = 0;
|
||||
this.mode = 'missing'; // 'missing' or 'found'
|
||||
|
||||
// Bag tables (set/bags.html) carry their own per-bag inputs, so the
|
||||
// audit works exactly like on the parts table; the flag only tweaks
|
||||
// the modal wording (spares note, unmatched-row message).
|
||||
const accordion = document.getElementById(accordionId);
|
||||
this.bagMode = !!(accordion && accordion.querySelector('table[data-bag-table]'));
|
||||
|
||||
this.setupModal();
|
||||
this.cacheElements();
|
||||
this.setupEventListeners();
|
||||
@@ -50,6 +56,14 @@ class PartsAuditMode {
|
||||
<div class="mb-1">Quantity <span id="audit-qty">0</span></div>
|
||||
<div class="mb-3"><span id="audit-name" class="fs-6"></span> <span id="audit-color" class="text-secondary ms-1"></span></div>
|
||||
|
||||
<div id="audit-bags" class="mb-1 d-none">
|
||||
<table class="table table-sm table-bordered w-auto mx-auto mb-1">
|
||||
<thead><tr><th>Bag</th><th>Qty</th></tr></thead>
|
||||
<tbody id="audit-bags-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="audit-spares-note" class="text-secondary small mb-2 d-none">Spare parts are not included in bag quantities.</div>
|
||||
|
||||
<div id="audit-input-area">
|
||||
<div class="btn-group mb-2" role="group" aria-label="Audit mode">
|
||||
<button type="button" class="btn btn-sm" id="audit-mode-missing">Missing</button>
|
||||
@@ -94,6 +108,9 @@ class PartsAuditMode {
|
||||
name: document.getElementById('audit-name'),
|
||||
color: document.getElementById('audit-color'),
|
||||
qty: document.getElementById('audit-qty'),
|
||||
bags: document.getElementById('audit-bags'),
|
||||
bagsBody: document.getElementById('audit-bags-body'),
|
||||
sparesNote: document.getElementById('audit-spares-note'),
|
||||
inputArea: document.getElementById('audit-input-area'),
|
||||
noInput: document.getElementById('audit-no-input'),
|
||||
modeMissing: document.getElementById('audit-mode-missing'),
|
||||
@@ -236,13 +253,20 @@ class PartsAuditMode {
|
||||
return;
|
||||
}
|
||||
|
||||
// Image: prefer the full-size lightbox target, fall back to the thumbnail.
|
||||
// Image: prefer the full-size lightbox target, fall back to the
|
||||
// thumbnail. Unmatched bag parts have none; hide the broken box.
|
||||
const link = row.querySelector('a[data-lightbox]');
|
||||
const img = row.querySelector('td img');
|
||||
this.el.image.src = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
|
||||
const imageSrc = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
|
||||
this.el.image.src = imageSrc;
|
||||
this.el.image.classList.toggle('d-none', !imageSrc);
|
||||
|
||||
// Unmatched bag parts render as plain text without a link.
|
||||
const nameLink = row.querySelector('[data-col="name"] a');
|
||||
this.el.name.textContent = nameLink ? nameLink.textContent.trim() : '';
|
||||
const nameCell = row.querySelector('[data-col="name"]');
|
||||
this.el.name.textContent = nameLink
|
||||
? nameLink.textContent.trim()
|
||||
: (nameCell ? nameCell.textContent.trim() : '');
|
||||
|
||||
const colorCell = row.querySelector('[data-col="color"]');
|
||||
this.el.color.innerHTML = colorCell ? colorCell.innerHTML : '';
|
||||
@@ -250,6 +274,36 @@ class PartsAuditMode {
|
||||
const needed = this.neededQty(row);
|
||||
this.el.qty.textContent = needed;
|
||||
|
||||
// Per-part bag breakdown (set pages with sidecar bag data). Bag-audit
|
||||
// rows never carry data-bags, so this stays hidden there.
|
||||
let bagData = null;
|
||||
if (row.dataset.bags) {
|
||||
try {
|
||||
bagData = JSON.parse(row.dataset.bags);
|
||||
} catch (e) {
|
||||
bagData = null;
|
||||
}
|
||||
}
|
||||
if (bagData && bagData.length) {
|
||||
this.el.bagsBody.replaceChildren(...bagData.map(([bag, qty]) => {
|
||||
const tr = document.createElement('tr');
|
||||
const bagCell = document.createElement('td');
|
||||
bagCell.textContent = bag;
|
||||
const qtyCell = document.createElement('td');
|
||||
qtyCell.textContent = qty;
|
||||
tr.append(bagCell, qtyCell);
|
||||
return tr;
|
||||
}));
|
||||
this.el.bags.classList.remove('d-none');
|
||||
} else {
|
||||
this.el.bags.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Bag inventories never list spare parts, so say so whenever bag
|
||||
// quantities are on screen.
|
||||
const showSparesNote = (bagData && bagData.length > 0) || this.bagMode;
|
||||
this.el.sparesNote.classList.toggle('d-none', !showSparesNote);
|
||||
|
||||
// Toggle the input area depending on whether this table tracks missing.
|
||||
const missing = this.missingInput(row);
|
||||
if (missing) {
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/bag-missing-sync.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all, checked=not all and not read_only, hamburger_menu=not all and not read_only, accordion_id=accordion_id|default(''), filters=filters|default(false)) }}
|
||||
<tbody>
|
||||
{% for item in table_collection %}
|
||||
<tr>
|
||||
{# tojson escapes ' but not ", so the attribute must be single-quoted #}
|
||||
<tr {% if bag_breakdown is defined and bag_breakdown and item.html_id() in bag_breakdown %}data-bags='{{ bag_breakdown[item.html_id()] | tojson }}'{% endif %}>
|
||||
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
|
||||
<td data-sort="{{ item.fields.name }}" data-col="name">
|
||||
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{% import 'macro/accordion.html' as accordion %}
|
||||
{% import 'macro/form.html' as form %}
|
||||
{% import 'macro/table.html' as table %}
|
||||
|
||||
{{ accordion.header('Bags', 'bags-inventory', 'set-details', quantity=sidecar_bags | length, icon='archive-line', class='p-0') }}
|
||||
<div class="accordion accordion-flush" id="bags-list">
|
||||
{% for bag in sidecar_bags %}
|
||||
{% set bag_title = bag.number if bag.is_extra else 'Bag ' ~ bag.number %}
|
||||
{% set bag_accordion_id = 'bag-' ~ loop.index %}
|
||||
{{ accordion.header(bag_title, bag_accordion_id, 'bags-list', quantity=bag.parts | length, icon='archive-line', class='p-0') }}
|
||||
<div class="p-2 border-bottom">
|
||||
<small class="text-secondary">Spare parts are not included in bag inventories.</small>
|
||||
</div>
|
||||
<div class="table-responsive-sm">
|
||||
<table class="table table-striped align-middle sortable mb-0" data-parts-filterable="true" data-bag-table="true">
|
||||
{{ table.header(color=true, quantity=true, missing=true, damaged=false, checked=true, hamburger_menu=g.login.is_authenticated(), accordion_id=bag_accordion_id, filters=true) }}
|
||||
<tbody>
|
||||
{% for p in bag.parts %}
|
||||
<tr {% if p.missing_input_id %}data-target-missing="{{ p.missing_input_id }}"{% endif %}>
|
||||
{% if p.image_url %}
|
||||
{{ table.image(p.image_url, caption=p.name, alt=p.name) }}
|
||||
{% else %}
|
||||
<td class="py-0"></td>
|
||||
{% endif %}
|
||||
<td data-sort="{{ p.name }}" data-col="name">
|
||||
{% if p.url %}<a class="text-reset" href="{{ p.url }}">{{ p.name }}</a>{% else %}{{ p.name }}{% endif %}
|
||||
{% if p.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
|
||||
</td>
|
||||
<td data-sort="{{ p.color_name }}" data-col="color">
|
||||
{% if p.color_rgb %}<span class="color-rgb color-rgb-table {% if p.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if p.color != 9999 %}style="background-color: #{{ p.color_rgb }};"{% endif %}></span>{% endif %}
|
||||
<span class="align-middle">{{ p.color_name }}</span>
|
||||
</td>
|
||||
<td data-col="quantity">{{ p.quantity }}</td>
|
||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<td data-sort="{{ p.missing or 0 }}" data-col="missing" class="table-td-input">
|
||||
{% if p.url_missing %}
|
||||
{{ form.input('Missing', item.fields.id, p.prefix_missing, p.url_missing, p.missing) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_CHECKED_PARTS'] %}
|
||||
<td data-sort="{{ p.checked | int }}" data-col="checked" class="table-td-input">
|
||||
{% if p.url_checked %}
|
||||
<center>{{ form.checkbox('', item.fields.id, p.prefix_checked, p.url_checked, p.checked) }}</center>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if g.login.is_authenticated() and (not config['HIDE_TABLE_MISSING_PARTS'] or not config['HIDE_TABLE_CHECKED_PARTS'] or (not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'])) %}
|
||||
{# Filler cell under the hamburger column so row/header cell counts match #}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
@@ -1,4 +1,6 @@
|
||||
{% import 'macro/accordion.html' as accordion %}
|
||||
{# "with context" so includes inside the macros (part/table.html) can see
|
||||
render kwargs like bag_breakdown #}
|
||||
{% import 'macro/accordion.html' as accordion with context %}
|
||||
{% import 'macro/badge.html' as badge %}
|
||||
{% import 'macro/card.html' as card %}
|
||||
{% import 'macro/form.html' as form %}
|
||||
@@ -147,6 +149,9 @@
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated(), filters=true)}}
|
||||
{% if sidecar_bags is defined and sidecar_bags %}
|
||||
{% include 'set/bags.html' %}
|
||||
{% endif %}
|
||||
{% for minifigure in item.minifigures() %}
|
||||
{{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url(), hamburger_menu=g.login.is_authenticated())}}
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user