Compare commits

..

14 Commits

Author SHA1 Message Date
FrederikBaerentsen e94b8d2de5 fix(set): re-apply filters when clearing a field via the eraser button 2026-06-14 09:30:36 +02:00
FrederikBaerentsen 4074aa7c4c feat(set): add per-column header filters to the parts table 2026-06-14 08:35:58 +02:00
FrederikBaerentsen f262411dc4 fix(purchase): treat 0 as valid price throughout and fix minifig import data types 2026-04-30 20:12:14 +02:00
FrederikBaerentsen 711833e5de fix(statistics): show price values with 2 decimal places instead of rounding to whole numbers (see #146) 2026-04-18 14:51:37 +02:00
FrederikBaerentsen 12dead4ded fix(admin): changed browser popup to bootstrap modal 2026-04-18 14:38:48 +02:00
FrederikBaerentsen 0e3ba26010 Merge branch 'bugfix/issue-151' into release/1.4.1 2026-04-18 13:56:53 +02:00
FrederikBaerentsen 6177187103 Merge branch 'bugfix/issue-150' into release/1.4.1 2026-04-18 13:55:49 +02:00
FrederikBaerentsen 5c0daed160 Merge branch 'bugfix/issue-149c' into release/1.4.1 2026-04-18 13:53:55 +02:00
FrederikBaerentsen 66bbed3597 Merge branch 'bugfix/issue-149b' into release/1.4.1 2026-04-18 13:51:13 +02:00
FrederikBaerentsen 665441c5ac Updated changelog 2026-04-18 10:27:35 +02:00
FrederikBaerentsen d751a3d0af fix(add): replace two-socket approach with single BrickSetSocket for minifigure error where duplicate sets were added (see #150) 2026-04-18 10:16:27 +02:00
FrederikBaerentsen 1b077e86b1 fix(import): handle empty image URLs from Rebrickable for minifigures and parts (see #149) 2026-04-17 19:14:52 +02:00
FrederikBaerentsen ef6bdc823d fix(admin): cast BK_INSTRUCTIONS_ALLOWED_EXTENSIONS as a list not a string (see #149) 2026-04-17 18:07:34 +02:00
FrederikBaerentsen 0567d9817f fix(admin): restore actual defaults on "Reset to Defaults" instead of blanking fields. (see #149) 2026-04-17 17:23:47 +02:00
31 changed files with 480 additions and 151 deletions
+27 -1
View File
@@ -2,12 +2,38 @@
## 1.4.1
### Enhancements
- **Added per-column header filters to the Parts table on the set details page**: The main Parts table now has an always-visible filter row under the column headers
- Name: case-insensitive substring search
- Color: dropdown populated from the colors present in the current results
- Missing / Damaged: All / With / Without dropdowns
- Checked: All / Checked / Unchecked
- Filters combine (AND) and re-evaluate live as missing/damaged values or checkboxes change. Bulk actions (mark all missing, check all, etc.) only affect the rows visible under the active filter. Scoped to the main Parts table only; per-minifigure tables stay sort-only
- **"Reset to Defaults" confirmation now uses a Bootstrap modal instead of a browser dialog**: Replaced the native browser `confirm()` popup with a consistent Bootstrap modal matching the style of BrickTracker
### Bug Fixes
- **Fixed prices on the Statistics page being rounded to whole numbers** (Issue #146): All price values now display with two decimal places (`%.2f`) instead of being rounded to whole numbers (`%.0f`)
- **Fixed "Reset to Defaults" blanking all settings instead of restoring them** (Issue #149a, branch `bugfix/issue-149a`): "Reset to Defaults" was clearing all fields to empty/false instead of populating them with their actual default values
- `resetToDefaults()` now reads from `window.DEFAULT_CONFIG` and restores each field to its proper default, matching the same logic used on initial page load
- **Fixed `BK_INSTRUCTIONS_ALLOWED_EXTENSIONS` being treated as a string instead of a list** (Issue #149b, branch `bugfix/issue-149b`): When this setting was saved via the admin panel, it was stored and cast as a plain string rather than a list, causing it to be iterated character by character (e.g. `['.', 'p', 'd', 'f']` instead of `['.pdf']`)
- Added `allowed_extensions` to the list-type keyword detection in `_cast_value()`, matching the existing pattern used for `badge_order` settings
- **Fixed crash when importing sets containing minifigures or parts with no image on Rebrickable** (Issue #149c, branch `bugfix/issue-149c`): Adding or refreshing a set would fail entirely if any minifigure or part had no image URL, with error `Invalid URL '': No scheme supplied`
- Rebrickable returns an empty string (not `None`) for missing images; normalize empty strings to `None` at the point of ingestion in `rebrickable_minifigure.py` and `individual_minifigure.py`, matching the existing pattern in `rebrickable_set.py`
- Updated `rebrickable_image.py` to treat empty strings the same as `None` throughout, falling back to the configured nil placeholder image
- Note: the originally reported sets could no longer reproduce the crash (images may have since been added on Rebrickable), so this fix is based on assumptions only
- **Fixed previously added set being re-added when adding an individual minifigure** (Issue #150, branch `bugfix/issue-150`): After adding a set, entering a `fig-` ID and confirming would add the previous set again instead of the minifigure, if user did not reload inbetween.
- `add.js` was creating a second `BrickMinifigureSocket` with its own listeners on the same button and input as `BrickSetSocket`, causing duplicate socket events and cross-socket state confusion
- **Fixed purchase date, price, and notes not being saved when adding an individual minifigure** (Issue #151, branch `bugfix/issue-151`): Filling in purchase date, price, or notes before clicking Add had no effect, only purchase location was saved
- `BrickMinifigureSocket` was missing references to `#add-purchase-date`, `#add-purchase-price`, and `#add-description`, so those fields were never read or included in the socket emit
- The backend already supported all three fields. This was just a frontend error
- **Fixed deleting a wish with an owner assigned** (Issue #152): Resolved foreign key constraint error when removing a set from the wishlist that had an owner assigned
- **Fixed purchase date and price not being converted when adding an individual minifigure** (Issue #151 follow-up): Purchase date was stored as a raw `YYYY/MM/DD` string and price as a raw string instead of a Unix epoch float and float respectively, causing them to be silently dropped from statistics aggregations
- `IndividualMinifigure.download()` now mirrors the conversion logic already present in `BrickSet.download()`: date parsed via `datetime.strptime` to timestamp, price cast to `float`
- **Fixed a price of 0 being treated as no price** (Issue #153): Setting a purchase price of `0` on sets, individual minifigures, or parts was indistinguishable from having no price set at all
- Replaced truthiness checks (`if price`, `price or ''`) with explicit `None` checks throughout badge display, management input fields, and inline price update endpoints
- **Fixed deleting a wish with an owner assigned** (Issue #152, branch `bugfix/issue-152`): Resolved foreign key constraint error when removing a set from the wishlist that had an owner assigned
- Wish owners are now deleted before the wish itself, respecting the FK constraint
## 1.4
+1 -1
View File
@@ -192,7 +192,7 @@ class ConfigManager:
def _cast_value(self, var_name: str, value: Any) -> Any:
"""Cast value to appropriate type based on variable name"""
# List variables (admin sections, badge order) - Check this FIRST before boolean check
if any(keyword in var_name.lower() for keyword in ['sections', 'badge_order']):
if any(keyword in var_name.lower() for keyword in ['sections', 'badge_order', 'allowed_extensions']):
if isinstance(value, str):
return [section.strip() for section in value.split(',') if section.strip()]
elif isinstance(value, list):
+21 -3
View File
@@ -1,5 +1,6 @@
import logging
import traceback
from datetime import datetime
from typing import Any, Self, TYPE_CHECKING
from uuid import uuid4
@@ -67,8 +68,25 @@ class IndividualMinifigure(RebrickableMinifigure):
self.fields.purchase_location = purchase_location.fields.id if purchase_location else None
# Save purchase date and price
self.fields.purchase_date = data.get('purchase_date', None)
self.fields.purchase_price = data.get('purchase_price', None)
purchase_date = data.get('purchase_date', None)
if purchase_date == '':
purchase_date = None
if purchase_date is not None:
try:
purchase_date = datetime.strptime(purchase_date, '%Y/%m/%d').timestamp()
except Exception:
purchase_date = None
self.fields.purchase_date = purchase_date
purchase_price = data.get('purchase_price', None)
if purchase_price == '':
purchase_price = None
if purchase_price is not None:
try:
purchase_price = float(purchase_price)
except Exception:
purchase_price = None
self.fields.purchase_price = purchase_price
# Save quantity and description
self.fields.quantity = int(data.get('quantity', 1))
@@ -543,6 +561,6 @@ class IndividualMinifigure(RebrickableMinifigure):
'figure': str(data['set_num']),
'number': int(number),
'name': str(data['set_name']),
'image': data.get('set_img_url'),
'image': str(data['set_img_url']) if data.get('set_img_url') else None,
'number_of_parts': int(data.get('num_parts', 0)),
}
+4 -4
View File
@@ -55,7 +55,7 @@ class RebrickableImage(object):
# Get the URL (this handles nil images via url() method)
url = self.url()
if url is None:
if not url:
return
# Grab the image
@@ -88,7 +88,7 @@ class RebrickableImage(object):
return self.part.fields.image_id
if self.minifigure is not None:
if self.minifigure.fields.image is None:
if not self.minifigure.fields.image:
return RebrickableImage.nil_minifigure_name()
else:
return self.minifigure.fields.figure
@@ -113,13 +113,13 @@ class RebrickableImage(object):
# Return the url depending on the objects provided
def url(self, /) -> str:
if self.part is not None:
if self.part.fields.image is None:
if not self.part.fields.image:
return current_app.config['REBRICKABLE_IMAGE_NIL']
else:
return self.part.fields.image
if self.minifigure is not None:
if self.minifigure.fields.image is None:
if not self.minifigure.fields.image:
return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
else:
return self.minifigure.fields.image
+1 -1
View File
@@ -110,5 +110,5 @@ class RebrickableMinifigure(BrickRecord):
'number': int(number),
'name': str(data['set_name']),
'quantity': int(data['quantity']),
'image': data['set_img_url'],
'image': str(data['set_img_url']) if data['set_img_url'] else None,
}
@@ -6,6 +6,8 @@ SELECT
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"bricktracker_individual_minifigures"."purchase_date",
"bricktracker_individual_minifigures"."purchase_price",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
@@ -6,6 +6,8 @@ SELECT
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"bricktracker_individual_minifigures"."purchase_date",
"bricktracker_individual_minifigures"."purchase_price",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
@@ -6,6 +6,8 @@ SELECT
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"bricktracker_individual_minifigures"."purchase_date",
"bricktracker_individual_minifigures"."purchase_price",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
@@ -6,6 +6,8 @@ SELECT
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"bricktracker_individual_minifigures"."purchase_date",
"bricktracker_individual_minifigures"."purchase_price",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
@@ -6,6 +6,8 @@ SELECT
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"bricktracker_individual_minifigures"."purchase_date",
"bricktracker_individual_minifigures"."purchase_price",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
+20 -20
View File
@@ -100,6 +100,23 @@ part_lot_stats AS (
FROM "bricktracker_individual_part_lots"
),
-- Combined min/max price across all item types (separate CTE to avoid scalar subquery issues in SQLite)
all_prices AS (
SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != ''
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != '' AND "lot_id" IS NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != ''
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != ''
),
price_range AS (
SELECT
MIN(price) AS combined_minimum_cost,
MAX(price) AS combined_maximum_cost
FROM all_prices
),
-- Rebrickable sets count (for sets we actually own)
rebrickable_stats AS (
SELECT COUNT(*) AS unique_rebrickable_sets
@@ -140,26 +157,9 @@ financial_stats AS (
END AS combined_average_cost,
-- Min/Max price across all item types
(SELECT MIN(price) FROM (
SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL
)) AS combined_minimum_cost,
(SELECT MAX(price) FROM (
SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL
UNION ALL
SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL
)) AS combined_maximum_cost
FROM set_stats, individual_part_stats, individual_minifig_stats, part_lot_stats
price_range.combined_minimum_cost,
price_range.combined_maximum_cost
FROM set_stats, individual_part_stats, individual_minifig_stats, part_lot_stats, price_range
)
-- Final select combining all statistics
+2 -2
View File
@@ -58,8 +58,8 @@ class BrickStatistics:
return {
'total_cost': overview.get('combined_total_cost') or 0,
'average_cost': overview.get('combined_average_cost') or 0,
'minimum_cost': overview.get('combined_minimum_cost') or 0,
'maximum_cost': overview.get('combined_maximum_cost') or 0,
'minimum_cost': float(overview['combined_minimum_cost']) if overview.get('combined_minimum_cost') not in (None, '') else None,
'maximum_cost': float(overview['combined_maximum_cost']) if overview.get('combined_maximum_cost') not in (None, '') else None,
'items_with_price': overview.get('total_items_with_price') or 0,
'sets_with_price': overview.get('sets_with_price') or 0,
'total_sets': overview.get('total_sets') or 0,
+8 -1
View File
@@ -304,6 +304,13 @@ def update_purchase_date(*, id: str):
def update_purchase_price(*, id: str):
item = IndividualMinifigure().select_by_id(id)
purchase_price = request.json.get('value')
if purchase_price is not None and str(purchase_price).strip() != '':
try:
purchase_price = float(purchase_price)
except (ValueError, TypeError):
purchase_price = None
else:
purchase_price = None
BrickSQL().execute_and_commit(
'individual_minifigure/update',
@@ -314,7 +321,7 @@ def update_purchase_price(*, id: str):
'storage': item.fields.storage,
'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None,
'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None,
'purchase_price': purchase_price if purchase_price else None,
'purchase_price': purchase_price,
}
)
+9 -2
View File
@@ -311,7 +311,7 @@ def update_purchase_price(*, id: str):
purchase_price = request.json.get('value', '')
# Convert to float if provided, otherwise None
if purchase_price and str(purchase_price).strip():
if purchase_price is not None and str(purchase_price).strip() != '':
try:
price = float(purchase_price)
item.update_field('purchase_price', price)
@@ -662,7 +662,14 @@ def update_lot_purchase_price(*, lot_id: str):
from ..sql import BrickSQL
sql = BrickSQL()
sql.execute_and_commit('individual_part_lot/update/purchase_price', parameters={'purchase_price': purchase_price if purchase_price else None, 'id': lot_id})
if purchase_price is not None and str(purchase_price).strip() != '':
try:
purchase_price = float(purchase_price)
except (ValueError, TypeError):
purchase_price = None
else:
purchase_price = None
sql.execute_and_commit('individual_part_lot/update/purchase_price', parameters={'purchase_price': purchase_price, 'id': lot_id})
logger.info('Updated lot {lot_id} purchase_price to: {price}'.format(lot_id=lot_id, price=purchase_price))
return jsonify({'success': True})
+6 -64
View File
@@ -1,6 +1,6 @@
// Add page - handles both sets and individual minifigures
// Server auto-routes fig- numbers to IndividualMinifigure via LOAD_SET / IMPORT_SET
document.addEventListener("DOMContentLoaded", () => {
// Initialize date pickers
document.querySelectorAll('[data-add-date="true"]').forEach(el => {
new Datepicker(el, {
buttonClass: 'btn',
@@ -8,81 +8,23 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// Get template data from data attributes
const addContainer = document.getElementById('add-set');
if (!addContainer) return;
// Read data from data attributes
const templateData = {
path: addContainer.dataset.path,
namespace: addContainer.dataset.namespace,
messages: {
new BrickSetSocket(
'add',
addContainer.dataset.path,
addContainer.dataset.namespace,
{
COMPLETE: addContainer.dataset.msgComplete,
FAIL: addContainer.dataset.msgFail,
IMPORT_SET: addContainer.dataset.msgImportSet,
LOAD_SET: addContainer.dataset.msgLoadSet,
PROGRESS: addContainer.dataset.msgProgress,
SET_LOADED: addContainer.dataset.msgSetLoaded,
IMPORT_MINIFIGURE: addContainer.dataset.msgImportMinifigure,
LOAD_MINIFIGURE: addContainer.dataset.msgLoadMinifigure,
MINIFIGURE_LOADED: addContainer.dataset.msgMinifigureLoaded,
}
};
// Default: create set socket
const setSocket = new BrickSetSocket(
'add',
templateData.path,
templateData.namespace,
{
COMPLETE: templateData.messages.COMPLETE,
FAIL: templateData.messages.FAIL,
IMPORT_SET: templateData.messages.IMPORT_SET,
LOAD_SET: templateData.messages.LOAD_SET,
PROGRESS: templateData.messages.PROGRESS,
SET_LOADED: templateData.messages.SET_LOADED,
},
false,
false
);
// Override the execute method to check for minifigures
const originalExecute = setSocket.execute.bind(setSocket);
let minifigSocket = null;
setSocket.execute = function() {
const inputValue = document.getElementById('add-set').value.trim();
if (inputValue.startsWith('fig-') || inputValue.match(/^fig\d/i)) {
// It's a minifigure - create minifig socket if needed and execute when ready
if (!minifigSocket) {
minifigSocket = new BrickMinifigureSocket(
'add',
templateData.path,
templateData.namespace,
{
COMPLETE: templateData.messages.COMPLETE,
FAIL: templateData.messages.FAIL,
IMPORT_MINIFIGURE: templateData.messages.IMPORT_MINIFIGURE,
LOAD_MINIFIGURE: templateData.messages.LOAD_MINIFIGURE,
MINIFIGURE_LOADED: templateData.messages.MINIFIGURE_LOADED,
PROGRESS: templateData.messages.PROGRESS,
}
);
// Wait for socket to connect before executing
const checkConnection = setInterval(() => {
if (minifigSocket.socket && minifigSocket.socket.connected) {
clearInterval(checkConnection);
minifigSocket.execute();
}
}, 100);
} else {
minifigSocket.execute();
}
} else {
// It's a set - use original execute
originalExecute();
}
};
});
+25 -16
View File
@@ -204,14 +204,20 @@ function setupButtonHandlers() {
});
}
// Reset button
// Reset button. Opens Bootstrap modal instead of browser confirm()
const resetBtn = document.getElementById('config-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
console.log('Reset clicked');
if (confirm('Are you sure you want to reset all settings to default values? This action cannot be undone.')) {
resetToDefaults();
}
const modal = new bootstrap.Modal(document.getElementById('resetDefaultsModal'));
modal.show();
});
}
// Confirm reset inside the modal
const confirmResetBtn = document.getElementById('confirm-reset-defaults');
if (confirmResetBtn) {
confirmResetBtn.addEventListener('click', () => {
resetToDefaults();
});
}
@@ -285,18 +291,21 @@ function saveLiveConfiguration() {
function resetToDefaults() {
console.log('Resetting to defaults');
// Reset all form inputs
document.querySelectorAll('.config-toggle, .config-number, .config-text').forEach(input => {
if (input.type === 'checkbox') {
input.checked = false;
} else {
input.value = '';
}
});
Object.keys(window.DEFAULT_CONFIG).forEach(varName => {
const defaultValue = window.DEFAULT_CONFIG[varName];
// Update badges
Object.keys(window.CURRENT_CONFIG).forEach(varName => {
updateConfigBadge(varName, null);
const toggle = document.getElementById(varName);
if (toggle && toggle.type === 'checkbox') {
toggle.checked = defaultValue === true;
}
document.querySelectorAll(`input[data-var="${varName}"]:not(.config-static)`).forEach(input => {
if (input.type !== 'checkbox') {
input.value = defaultValue !== null && defaultValue !== undefined ? defaultValue : '';
}
});
updateConfigBadge(varName, defaultValue);
});
// Show status message
+4
View File
@@ -55,6 +55,10 @@ class BrickChanger {
this.html_clear.addEventListener("click", ((changer) => (e) => {
changer.html_element.value = "";
changer.change();
// change() only POSTs to the server; dispatch an input event so
// client-side listeners (e.g. the parts table filter) react to
// the programmatic clear the same way they do to typing.
changer.html_element.dispatchEvent(new Event("input", { bubbles: true }));
})(this));
}
+6
View File
@@ -110,6 +110,9 @@ class PartsBulkOperations {
// Find all rows in this accordion
const rows = accordionElement.querySelectorAll('tbody tr');
rows.forEach(row => {
// Skip rows hidden by an active header filter
if (row.classList.contains('parts-filtered-out')) return;
// Find the quantity cell (usually 4th column)
const quantityCell = row.cells[3]; // Index 3 for quantity column
const missingInput = row.querySelector('input[id*="-missing-"]');
@@ -134,6 +137,7 @@ class PartsBulkOperations {
const missingInputs = accordionElement.querySelectorAll('input[id*="-missing-"]');
missingInputs.forEach(input => {
if (input.closest('tr')?.classList.contains('parts-filtered-out')) return;
if (input.value !== '') {
input.value = '';
// Trigger change event to activate BrickChanger
@@ -148,6 +152,7 @@ class PartsBulkOperations {
const checkboxes = accordionElement.querySelectorAll('input[id*="-checked-"][type="checkbox"]');
checkboxes.forEach(checkbox => {
if (checkbox.closest('tr')?.classList.contains('parts-filtered-out')) return;
if (!checkbox.checked) {
checkbox.checked = true;
// Trigger change event to activate BrickChanger
@@ -162,6 +167,7 @@ class PartsBulkOperations {
const checkboxes = accordionElement.querySelectorAll('input[id*="-checked-"][type="checkbox"]');
checkboxes.forEach(checkbox => {
if (checkbox.closest('tr')?.classList.contains('parts-filtered-out')) return;
if (checkbox.checked) {
checkbox.checked = false;
// Trigger change event to activate BrickChanger
+186
View File
@@ -0,0 +1,186 @@
// Per-column header filters for the accordion parts tables on the set details page.
class PartsTableFilter {
constructor(table) {
this.table = table;
this.body = table.querySelector('tbody');
this.rows = Array.from(this.body ? this.body.querySelectorAll('tr') : []);
this.nameInput = table.querySelector('[data-parts-filter="name"]');
this.colorSelect = table.querySelector('[data-parts-filter="color"]');
this.missingSelect = table.querySelector('[data-parts-filter="missing"]');
this.damagedSelect = table.querySelector('[data-parts-filter="damaged"]');
this.checkedSelect = table.querySelector('[data-parts-filter="checked"]');
this.clearButton = table.querySelector('[data-parts-filter-clear]');
this.setupListeners();
this.apply();
}
setupListeners() {
const debouncedApply = this.debounce(() => this.apply(), 150);
// Name search is debounced so typing stays smooth on big tables.
if (this.nameInput) {
this.nameInput.addEventListener('input', debouncedApply);
}
// Dropdowns re-filter immediately.
[this.colorSelect, this.missingSelect, this.damagedSelect, this.checkedSelect].forEach(select => {
if (select) {
select.addEventListener('change', () => this.apply());
}
});
if (this.clearButton) {
this.clearButton.addEventListener('click', (e) => {
e.stopPropagation();
this.clear();
});
}
// Live re-evaluation: editing a missing/damaged value or toggling a
// checkbox in the body should re-run the active filter.
if (this.body) {
this.body.addEventListener('change', () => this.apply());
this.body.addEventListener('input', debouncedApply);
}
}
cellText(row, col) {
const cell = row.querySelector(`[data-col="${col}"]`);
if (!cell) {
return '';
}
const sort = cell.getAttribute('data-sort');
return (sort !== null ? sort : cell.textContent).trim().toLowerCase();
}
// Numeric value for missing/damaged, reading the live input when present.
cellNumber(row, col) {
const cell = row.querySelector(`[data-col="${col}"]`);
if (!cell) {
return 0;
}
const input = cell.querySelector('input');
const raw = input ? input.value : (cell.getAttribute('data-sort') || cell.textContent);
const value = parseInt(raw, 10);
return Number.isNaN(value) ? 0 : value;
}
rowChecked(row) {
const cell = row.querySelector('[data-col="checked"]');
if (!cell) {
return false;
}
const checkbox = cell.querySelector('input[type="checkbox"]');
if (checkbox) {
return checkbox.checked;
}
return cell.getAttribute('data-sort') === '1';
}
matchesNumberFilter(value, mode) {
if (mode === 'with') {
return value > 0;
}
if (mode === 'without') {
return value === 0;
}
return true;
}
apply() {
const nameTerm = (this.nameInput ? this.nameInput.value : '').trim().toLowerCase();
const colorValue = (this.colorSelect ? this.colorSelect.value : '').trim().toLowerCase();
const missingMode = this.missingSelect ? this.missingSelect.value : '';
const damagedMode = this.damagedSelect ? this.damagedSelect.value : '';
const checkedValue = this.checkedSelect ? this.checkedSelect.value : '';
// Colors available among rows that pass every filter except color, so
// the picker only offers colors still present in the current results.
const availableColors = new Set();
let visibleCount = 0;
this.rows.forEach(row => {
const passNonColor =
(!nameTerm || this.cellText(row, 'name').includes(nameTerm)) &&
this.matchesNumberFilter(this.cellNumber(row, 'missing'), missingMode) &&
this.matchesNumberFilter(this.cellNumber(row, 'damaged'), damagedMode) &&
(checkedValue === '' ||
(checkedValue === '1' ? this.rowChecked(row) : !this.rowChecked(row)));
if (passNonColor) {
const colorCell = row.querySelector('[data-col="color"]');
if (colorCell) {
const name = colorCell.textContent.trim();
if (name) {
availableColors.add(name);
}
}
}
const passColor = !colorValue || this.cellText(row, 'color') === colorValue;
const visible = passNonColor && passColor;
row.classList.toggle('parts-filtered-out', !visible);
if (visible) {
visibleCount += 1;
}
});
this.updateColorOptions(availableColors);
this.updateEmptyState(visibleCount);
}
// Rebuild the color dropdown from the colors currently in the results,
// keeping the user's current selection if it is still available.
updateColorOptions(colors) {
if (!this.colorSelect) {
return;
}
const current = this.colorSelect.value;
const sorted = Array.from(colors).sort((a, b) => a.localeCompare(b));
const options = ['<option value="">All colors</option>']
.concat(sorted.map(name => `<option value="${name.toLowerCase()}">${name}</option>`));
this.colorSelect.innerHTML = options.join('');
// Restore selection (the <option> values are lowercased to match).
this.colorSelect.value = sorted.some(n => n.toLowerCase() === current) ? current : '';
}
updateEmptyState(visibleCount) {
if (!this.body) {
return;
}
if (!this.emptyRow) {
const columns = this.table.querySelectorAll('thead tr:first-child th').length || 1;
this.emptyRow = document.createElement('tr');
this.emptyRow.className = 'parts-filter-empty no-sort';
this.emptyRow.innerHTML = `<td colspan="${columns}" class="text-center text-body-secondary py-3">No matching parts</td>`;
this.body.appendChild(this.emptyRow);
}
this.emptyRow.style.display = visibleCount === 0 ? '' : 'none';
}
clear() {
if (this.nameInput) { this.nameInput.value = ''; }
[this.colorSelect, this.missingSelect, this.damagedSelect, this.checkedSelect].forEach(select => {
if (select) {
select.value = '';
}
});
this.apply();
}
debounce(fn, wait) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('table[data-parts-filterable="true"]').forEach(table => {
new PartsTableFilter(table);
});
});
+15
View File
@@ -331,3 +331,18 @@
background-color: #e9ecef;
border-color: #dee2e6;
}
/* Accordion parts table header filters */
.parts-filter-row > th {
padding: 0.35rem 0.5rem;
vertical-align: middle;
}
.parts-filter-row .parts-filter-input,
.parts-filter-row .parts-filter-select {
min-width: 6rem;
}
tr.parts-filtered-out {
display: none;
}
+26
View File
@@ -1019,6 +1019,32 @@
</div>
{{ accordion.footer() }}
<!-- Reset to Defaults Confirmation Modal -->
<div class="modal fade" id="resetDefaultsModal" tabindex="-1" aria-labelledby="resetDefaultsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title" id="resetDefaultsModalLabel">
<i class="ri-restart-line"></i> Reset to Defaults
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This will reset all live settings to their default values.</p>
<p class="text-muted small"><i class="ri-information-line"></i> Changes are not saved until you click <strong>Save All Changes</strong>.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="ri-close-line"></i> Cancel
</button>
<button type="button" class="btn btn-warning" id="confirm-reset-defaults" data-bs-dismiss="modal">
<i class="ri-restart-line"></i> Reset to Defaults
</button>
</div>
</div>
</div>
</div>
<!-- Initialize Configuration Data -->
<script type="text/javascript">
window.CURRENT_CONFIG = {
+1
View File
@@ -191,6 +191,7 @@
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.details' %}
<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/set-details.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
@@ -35,12 +35,12 @@
{% if management_read_only %}
<div class="input-group">
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ item.fields.purchase_price or '' }}" disabled autocomplete="off">
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ (item.fields.purchase_price if item.fields.purchase_price is not none else '') }}" disabled autocomplete="off">
<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>
<span class="input-group-text ri-prohibited-line px-1"></span>
</div>
{% else %}
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_minifigure.update_purchase_price', id=item.fields.id), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_minifigure.update_purchase_price', id=item.fields.id), (item.fields.purchase_price if item.fields.purchase_price is not none else ''), suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
{% endif %}
</div>
<div class="col-12 flex-grow-1">
+4 -4
View File
@@ -53,12 +53,12 @@
{% if management_read_only %}
<div class="input-group">
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ item.fields.purchase_price or '' }}" disabled autocomplete="off">
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ (item.fields.purchase_price if item.fields.purchase_price is not none else '') }}" disabled autocomplete="off">
<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>
<span class="input-group-text ri-prohibited-line px-1"></span>
</div>
{% else %}
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_part.update_lot_purchase_price', lot_id=item.fields.id), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_part.update_lot_purchase_price', lot_id=item.fields.id), (item.fields.purchase_price if item.fields.purchase_price is not none else ''), suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
{% endif %}
</div>
<div class="col-12 flex-grow-1">
@@ -112,12 +112,12 @@
{% if management_read_only %}
<div class="input-group">
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ item.fields.purchase_price or '' }}" disabled autocomplete="off">
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ (item.fields.purchase_price if item.fields.purchase_price is not none else '') }}" disabled autocomplete="off">
<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>
<span class="input-group-text ri-prohibited-line px-1"></span>
</div>
{% else %}
{{ form.input('Price', item.fields.id, 'purchase_price', item.url_for_purchase_price(), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
{{ form.input('Price', item.fields.id, 'purchase_price', item.url_for_purchase_price(), (item.fields.purchase_price if item.fields.purchase_price is not none else ''), suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
{% endif %}
</div>
<div class="col-12 flex-grow-1">
+2 -2
View File
@@ -43,7 +43,7 @@
{% endif %}
{% endmacro %}
{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none, hamburger_menu=none) %}
{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none, hamburger_menu=none, filters=false) %}
{% set size=table_collection | length %}
{% if size %}
{{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt, hamburger_menu=hamburger_menu) }}
@@ -57,7 +57,7 @@
<a class="btn border bg-secondary-text" href="{{ details }}">{% if icon %}<i class="ri-{{ icon }}"></i>{% endif %} Details</a>
</p>
{% endif %}
{% with solo=true, all=false, accordion_id=id, read_only=read_only, hamburger_menu=hamburger_menu %}
{% with solo=true, all=false, accordion_id=id, read_only=read_only, hamburger_menu=hamburger_menu, filters=filters %}
{% include target %}
{% endwith %}
{{ footer() }}
+8 -6
View File
@@ -118,12 +118,14 @@
{% endmacro %}
{% macro purchase_price(price, solo=false, last=false) %}
{% if last %}
{% set tooltip=price %}
{% else %}
{% set text=price %}
{% if price is not none %}
{% if last %}
{% set tooltip = price | string %}
{% else %}
{% set text = price | string %}
{% endif %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='wallet-3-line', text=text, tooltip=tooltip, collapsible='Price:') }}
{% endif %}
{{ badge(check=price, solo=solo, last=last, color='light border', icon='wallet-3-line', text=text, tooltip=tooltip, collapsible='Price:') }}
{% endmacro %}
{% macro set(set, solo=false, last=false, url=None, id=None) %}
@@ -294,7 +296,7 @@
{% if not last %}
{% if item.purchase_price is defined and item.purchase_price is callable %}
{{ purchase_price(item.purchase_price(), solo=solo, last=last) }}
{% elif item.fields.purchase_price is defined and item.fields.purchase_price %}
{% elif item.fields.purchase_price is defined and item.fields.purchase_price is not none %}
{{ purchase_price(item.fields.purchase_price, solo=solo, last=last) }}
{% endif %}
{% endif %}
+1 -1
View File
@@ -23,7 +23,7 @@
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
<div class="input-group">
{% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value is not none and value != '' %}{{ value }}{% endif %}"
{% if g.login.is_authenticated() %}
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
{% if date %}data-changer-date="true"{% endif %}
+67 -1
View File
@@ -1,4 +1,4 @@
{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false, checked=false, hamburger_menu=false, accordion_id='') %}
{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false, checked=false, hamburger_menu=false, accordion_id='', filters=false) %}
<thead>
<tr>
{% if image %}
@@ -59,6 +59,72 @@
{% endif %}
{% endif %}
</tr>
{% if filters %}
<tr class="parts-filter-row">
{% if image %}
<th class="no-sort"></th>
{% endif %}
<th class="no-sort">
<input type="search" class="form-control form-control-sm parts-filter-input" data-parts-filter="name" placeholder="Search…" aria-label="Filter by name" autocomplete="off">
</th>
{% if color %}
<th class="no-sort">
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="color" aria-label="Filter by color">
<option value="">All colors</option>
</select>
</th>
{% endif %}
{% if parts %}
<th class="no-sort"></th>
{% endif %}
{% if quantity %}
<th class="no-sort"></th>
{% endif %}
{% if missing and not config['HIDE_TABLE_MISSING_PARTS'] %}
<th class="no-sort">
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="missing" aria-label="Filter by missing">
<option value="">All</option>
<option value="with">With missing</option>
<option value="without">Without missing</option>
</select>
</th>
{% endif %}
{% if damaged and not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<th class="no-sort">
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="damaged" aria-label="Filter by damaged">
<option value="">All</option>
<option value="with">With damaged</option>
<option value="without">Without damaged</option>
</select>
</th>
{% endif %}
{% if sets %}
<th class="no-sort"></th>
{% endif %}
{% if minifigures %}
<th class="no-sort"></th>
{% endif %}
{% if checked and not config['HIDE_TABLE_CHECKED_PARTS'] %}
<th class="no-sort">
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="checked" aria-label="Filter by checked">
<option value="">All</option>
<option value="1">Checked</option>
<option value="0">Unchecked</option>
</select>
</th>
{% endif %}
{% if hamburger_menu and g.login.is_authenticated() %}
{% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %}
{% set show_checked_menu = not config['HIDE_TABLE_CHECKED_PARTS'] %}
{% set show_quick_add = not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'] %}
{% if show_missing_menu or show_checked_menu or show_quick_add %}
<th class="no-sort text-end">
<button type="button" class="btn btn-sm btn-outline-secondary parts-filter-clear" data-parts-filter-clear title="Clear filters"><i class="ri-filter-off-line"></i></button>
</th>
{% endif %}
{% endif %}
</tr>
{% endif %}
</thead>
{% endmacro %}
+7 -7
View File
@@ -2,13 +2,13 @@
{% import 'macro/table.html' as table %}
<div class="table-responsive-sm">
<table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" {% if all %}id="parts"{% endif %}>
{{ 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('')) }}
<table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" {% if all %}id="parts"{% endif %} {% if filters %}data-parts-filterable="true"{% endif %}>
{{ 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>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
<td data-sort="{{ item.fields.name }}">
<td data-sort="{{ item.fields.name }}" data-col="name">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
{% if all %}
@@ -16,7 +16,7 @@
{{ table.bricklink(item) }}
{% endif %}
</td>
<td data-sort="{{ item.fields.color_name }}">
<td data-sort="{{ item.fields.color_name }}" data-col="color">
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
@@ -28,12 +28,12 @@
{% endif %}
{% endif %}
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
<td data-sort="{{ item.fields.total_missing }}" data-col="missing" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
<td data-sort="{{ item.fields.total_damaged }}" data-col="damaged" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
</td>
{% endif %}
@@ -42,7 +42,7 @@
<td>{{ item.fields.total_minifigures }}</td>
{% else %}
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
<td data-sort="{{ item.fields.checked | default(0) | int }}" class="table-td-input">
<td data-sort="{{ item.fields.checked | default(0) | int }}" data-col="checked" class="table-td-input">
<center>{{ form.checkbox('', item.fields.id, item.html_id('checked'), item.url_for_checked(), item.fields.checked | default(false), parent='part', delete=read_only) }}</center>
</td>
{% endif %}
+1 -1
View File
@@ -130,7 +130,7 @@
</div>
{{ accordion.footer() }}
{% endif %}
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated())}}
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated(), filters=true)}}
{% 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 %}
+16 -12
View File
@@ -99,7 +99,11 @@
<div class="col-6">
<div class="text-center">
<div class="text-dark small">Range</div>
{% if financial_summary.minimum_cost is not none and financial_summary.maximum_cost is not none %}
<div class="fw-bold">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(financial_summary.minimum_cost) }} - {{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(financial_summary.maximum_cost) }}</div>
{% else %}
<div class="fw-bold">-</div>
{% endif %}
</div>
</div>
</div>
@@ -245,7 +249,7 @@
</td>
<td class="text-center">
{% if theme.total_spent %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(theme.total_spent) }}</small>
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(theme.total_spent) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
@@ -309,8 +313,8 @@
<small class="text-dark">{{ storage.total_minifigures }}{% if storage.individual_minifig_count %} ({{ storage.individual_minifig_count }} individual){% endif %}</small>
</td>
<td class="text-center">
{% if storage.total_value %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(storage.total_value) }}</small>
{% if storage.total_value is not none %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(storage.total_value) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
@@ -380,15 +384,15 @@
{% endif %}
</td>
<td class="text-center">
{% if location.avg_price %}
{% if location.avg_price is not none %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(location.avg_price) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
</td>
<td class="text-center">
{% if location.min_price and location.max_price %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(location.min_price) }}-{{ "%.0f"|format(location.max_price) }}</small>
{% if location.min_price is not none and location.max_price is not none %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(location.min_price) }}-{{ "%.2f"|format(location.max_price) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
@@ -460,14 +464,14 @@
</td>
<td class="text-center">
{% if year.total_spent %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year.total_spent) }}</small>
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year.total_spent) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
</td>
<td class="text-center">
{% if year.avg_price_per_set %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year.avg_price_per_set) }}</small>
{% if year.avg_price_per_set is not none %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year.avg_price_per_set) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
@@ -542,8 +546,8 @@
{% endif %}
</td>
<td class="text-center">
{% if year.avg_price_per_set %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year.avg_price_per_set) }}</small>
{% if year.avg_price_per_set is not none %}
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year.avg_price_per_set) }}</small>
{% else %}
<small class="text-dark">-</small>
{% endif %}
@@ -560,7 +564,7 @@
<small class="text-dark">Peak Year</small><br>
{% if year_summary.peak_spending_year %}
<strong>{{ year_summary.peak_spending_year }}</strong>
<small class="text-success d-block">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year_summary.max_spending) }}</small>
<small class="text-success d-block">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year_summary.max_spending) }}</small>
{% else %}
<small class="text-dark">N/A</small>
{% endif %}