forked from FrederikBaerentsen/BrickTracker
Merge branch 'release/1.4' into release/1.4
This commit is contained in:
+19
-6
@@ -179,6 +179,19 @@
|
||||
# Default: false
|
||||
# BK_HIDE_WISHES=true
|
||||
|
||||
# Optional: Hide the 'Individual Minifigures' entry from the menu. Does not disable the route.
|
||||
# Default: false
|
||||
# BK_HIDE_INDIVIDUAL_MINIFIGURES=true
|
||||
|
||||
# Optional: Hide the 'Individual Parts' entry from the menu. Does not disable the route.
|
||||
# Default: false
|
||||
# BK_HIDE_INDIVIDUAL_PARTS=true
|
||||
|
||||
# Optional: Hide the 'Add to individual parts' quick-add buttons in parts tables.
|
||||
# The column header with menu options (mark all missing, check all, etc.) remains visible.
|
||||
# Default: false
|
||||
# BK_HIDE_QUICK_ADD_INDIVIDUAL_PARTS=true
|
||||
|
||||
# Optional: Change the default order of minifigures. By default ordered by insertion order.
|
||||
# Useful column names for this option are:
|
||||
# - "rebrickable_minifigures"."figure": minifigure ID (e.g., "fig-001234")
|
||||
@@ -207,11 +220,11 @@
|
||||
|
||||
# Optional: Change the default order of parts. By default ordered by insertion order.
|
||||
# Useful column names for this option are:
|
||||
# - "bricktracker_parts"."part": part number (e.g., "3001")
|
||||
# - "bricktracker_parts"."spare": part is a spare part (0 or 1)
|
||||
# - "bricktracker_parts"."quantity": quantity of this part
|
||||
# - "bricktracker_parts"."missing": number of missing parts
|
||||
# - "bricktracker_parts"."damaged": number of damaged parts
|
||||
# - "combined"."part": part number (e.g., "3001")
|
||||
# - "combined"."spare": part is a spare part (0 or 1)
|
||||
# - "combined"."quantity": quantity of this part
|
||||
# - "combined"."missing": number of missing parts
|
||||
# - "combined"."damaged": number of damaged parts
|
||||
# - "rebrickable_parts"."name": part name
|
||||
# - "rebrickable_parts"."color_name": part color name
|
||||
# - "total_missing": total missing across all sets (composite field)
|
||||
@@ -219,7 +232,7 @@
|
||||
# - "total_quantity": total quantity across all sets (composite field)
|
||||
# - "total_sets": number of sets containing this part (composite field)
|
||||
# - "total_minifigures": number of minifigures with this part (composite field)
|
||||
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC
|
||||
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC
|
||||
# Examples:
|
||||
# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name" ASC
|
||||
# BK_PARTS_DEFAULT_ORDER="rebrickable_parts"."color_name" ASC, "rebrickable_parts"."name" ASC
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Fixed client-side table sorting corruption** (Issue #136): Resolved data corruption when using sort buttons with DataTables header sorting in client-side pagination mode
|
||||
- Sort buttons now trigger actual table header clicks instead of using separate `columns.sort()`
|
||||
- Header clicks sync button states to match current sort
|
||||
- Prevents misaligned images, colors, and links when mixing sorting methods
|
||||
- **Fixed storage deletion error handling**: Added proper validation and user-friendly error messages when attempting to delete storage locations that are still in use
|
||||
- Shows detailed count of items using the storage (sets, individual minifigures, individual parts, part lots)
|
||||
- Provides clickable link to storage details page for easy navigation
|
||||
@@ -35,6 +39,14 @@
|
||||
|
||||
### New Features
|
||||
|
||||
- **Sortable Checked column** (Issue #137): The "Checked" column in set inventory tables can now be sorted
|
||||
- Click the "Checked" header to sort by checked/unchecked status
|
||||
- Works in both parts table and part lots table
|
||||
|
||||
- **Quick-add individual parts toggle**: New `BK_HIDE_QUICK_ADD_INDIVIDUAL_PARTS` setting to hide the quick-add menu in set parts tables
|
||||
- Hides the "Add to individual parts" option in the row menu dropdown
|
||||
- Useful when you want individual parts tracking enabled but don't need quick-add from set inventory
|
||||
|
||||
- **Individual Minifigures Tracking**
|
||||
- Track loose/individual minifigures outside of sets
|
||||
- Part-level tracking for individual minifigures with problem states (missing/damaged/checked)
|
||||
|
||||
@@ -22,6 +22,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
|
||||
{'n': 'DISABLE_INDIVIDUAL_MINIFIGURES', 'c': bool},
|
||||
{'n': 'DISABLE_INDIVIDUAL_PARTS', 'c': bool},
|
||||
{'n': 'DISABLE_QUICK_ADD_INDIVIDUAL_PARTS', 'c': bool},
|
||||
{'n': 'HIDE_QUICK_ADD_INDIVIDUAL_PARTS', 'c': bool},
|
||||
{'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''},
|
||||
{'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'},
|
||||
{'n': 'HOST', 'd': '0.0.0.0'},
|
||||
|
||||
@@ -6,7 +6,10 @@ from typing import Any, Self, TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import url_for
|
||||
from flask import (
|
||||
current_app,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .exceptions import NotFoundException, DatabaseException, ErrorException
|
||||
from .individual_part import IndividualPart
|
||||
@@ -222,54 +225,38 @@ class IndividualPartLot(BrickRecord):
|
||||
|
||||
# Create individual part with lot_id
|
||||
part_uuid = str(uuid4())
|
||||
|
||||
# Use the add method but with lot_id
|
||||
# We need to insert the part with the lot_id
|
||||
sql = BrickSQL()
|
||||
|
||||
# First ensure the part exists in rebrickable_parts (BEFORE inserting individual part)
|
||||
# Ensure color and part/color combination exist in rebrickable tables
|
||||
IndividualPart.get_or_fetch_color(color_id)
|
||||
part_name = cart_item.get('part_name', '')
|
||||
color_name = cart_item.get('color_name', '')
|
||||
image_url = color_info.get('part_img_url', '')
|
||||
|
||||
# Ensure part/color combination exists in rebrickable_parts
|
||||
try:
|
||||
# Check if part exists
|
||||
result = sql.fetchone('rebrickable_parts/check_exists', parameters={'part': part_num, 'color_id': color_id})
|
||||
exists = result[0] > 0
|
||||
# Extract image_id from element_ids or URL
|
||||
element_ids = color_info.get('elements', [])
|
||||
if element_ids and len(element_ids) > 0:
|
||||
image_id = str(element_ids[0])
|
||||
elif image_url:
|
||||
image_id, _ = os.path.splitext(os.path.basename(urlparse(image_url).path))
|
||||
else:
|
||||
image_id = None
|
||||
|
||||
if not exists:
|
||||
# Insert part data
|
||||
part_name = cart_item.get('part_name', '')
|
||||
color_name = cart_item.get('color_name', '')
|
||||
image_url = color_info.get('part_img_url', '')
|
||||
|
||||
# Extract image_id from element_ids or URL
|
||||
element_ids = color_info.get('elements', [])
|
||||
if element_ids and len(element_ids) > 0:
|
||||
image_id = str(element_ids[0])
|
||||
elif image_url:
|
||||
image_id, _ = os.path.splitext(os.path.basename(urlparse(image_url).path))
|
||||
else:
|
||||
image_id = None
|
||||
|
||||
sql.execute('rebrickable_parts/insert_part_color', parameters={
|
||||
'part': part_num,
|
||||
'name': part_name,
|
||||
'color_id': color_id,
|
||||
'color_name': color_name,
|
||||
'color_rgb': color_info.get('rgb', ''),
|
||||
'color_transparent': color_info.get('is_trans', False),
|
||||
'image': image_url,
|
||||
'image_id': image_id,
|
||||
'url': current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(part=part_num, color=color_id),
|
||||
'bricklink_color_id': color_info.get('bricklink_color_id', None),
|
||||
'bricklink_color_name': color_info.get('bricklink_color_name', None)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning('Could not ensure part data for {part_num}/{color_id}: {error}'.format(
|
||||
part_num=part_num,
|
||||
color_id=color_id,
|
||||
error=e
|
||||
))
|
||||
sql.execute('rebrickable_parts/insert_part_color', parameters={
|
||||
'part': part_num,
|
||||
'name': part_name,
|
||||
'color_id': color_id,
|
||||
'color_name': color_name,
|
||||
'color_rgb': color_info.get('rgb', ''),
|
||||
'color_transparent': color_info.get('is_trans', False),
|
||||
'image': image_url,
|
||||
'image_id': image_id,
|
||||
'url': current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(part=part_num, color=color_id),
|
||||
'bricklink_color_id': color_info.get('bricklink_color_id', None),
|
||||
'bricklink_color_name': color_info.get('bricklink_color_name', None)
|
||||
})
|
||||
# Commit so the foreign key constraint can be satisfied
|
||||
sql.commit()
|
||||
|
||||
# Now insert the part with lot_id (NO individual metadata - inherited from lot)
|
||||
sql.execute('individual_part/insert_with_lot', parameters={
|
||||
|
||||
+6
-5
@@ -39,13 +39,14 @@
|
||||
| `BK_INDEPENDENT_ACCORDIONS` | Make accordions independent | `false` | No |
|
||||
| `BK_DISABLE_INDIVIDUAL_MINIFIGURES` | Block write operations for individual minifigures | `false` | No |
|
||||
| `BK_DISABLE_INDIVIDUAL_PARTS` | Block write operations for individual parts | `false` | No |
|
||||
| `BK_HIDE_QUICK_ADD_INDIVIDUAL_PARTS` | Hide quick-add buttons in parts tables | `false` | No |
|
||||
|
||||
## Sort Order Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_SETS_DEFAULT_ORDER` | Default set sorting | `"rebrickable_sets"."number" DESC` | No |
|
||||
| `BK_PARTS_DEFAULT_ORDER` | Default part sorting | `"inventory"."name" ASC` | No |
|
||||
| `BK_MINIFIGURES_DEFAULT_ORDER` | Default minifig sorting | `"minifigures"."name" ASC` | No |
|
||||
| `BK_PARTS_DEFAULT_ORDER` | Default part sorting | `"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC` | No |
|
||||
| `BK_MINIFIGURES_DEFAULT_ORDER` | Default minifig sorting | `"rebrickable_minifigures"."name" ASC` | No |
|
||||
| `BK_WISHES_DEFAULT_ORDER` | Default wishlist sorting | `"bricktracker_wishes"."rowid" DESC` | No |
|
||||
| `BK_STORAGE_DEFAULT_ORDER` | Default storage sorting | `"bricktracker_metadata_storages"."name" ASC` | No |
|
||||
| `BK_PURCHASE_LOCATION_DEFAULT_ORDER` | Default purchase location sorting | `"bricktracker_metadata_purchase_locations"."name" ASC` | No |
|
||||
@@ -111,10 +112,10 @@
|
||||
BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" ASC
|
||||
|
||||
# Sort parts by missing count descending
|
||||
BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "inventory"."name" ASC
|
||||
BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name" ASC
|
||||
|
||||
# Sort minifigures by ID
|
||||
BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."fig_num" ASC
|
||||
# Sort minifigures by figure number
|
||||
BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."figure" ASC
|
||||
|
||||
# Sort wishlist by set number
|
||||
BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" ASC
|
||||
|
||||
@@ -176,6 +176,14 @@ class BrickChanger {
|
||||
// Not going through dataset to avoid converting
|
||||
this.html_parent.setAttribute(this.parent_dataset, value);
|
||||
}
|
||||
|
||||
// Update the data-sort attribute on the parent TD for sortable tables
|
||||
if (this.html_type == "checkbox") {
|
||||
const parentTd = this.html_element.closest('td');
|
||||
if (parentTd && parentTd.hasAttribute('data-sort')) {
|
||||
parentTd.setAttribute('data-sort', Number(this.html_element.checked));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error.message);
|
||||
|
||||
|
||||
@@ -175,6 +175,53 @@ window.setupSharedSortButtons = function(tableId, tableInstanceGlobal, columnMap
|
||||
const clearButton = document.querySelector('[data-sort-clear]');
|
||||
const isPaginationMode = isPaginationModeForTable(tableId);
|
||||
|
||||
// Listen for table header clicks to sync button states when user sorts via headers
|
||||
if (!isPaginationMode) {
|
||||
const tableElement = document.querySelector(`#${tableId}`);
|
||||
if (tableElement) {
|
||||
tableElement.addEventListener('click', (e) => {
|
||||
// Check if a sortable header was clicked
|
||||
const header = e.target.closest('th');
|
||||
if (header && !header.classList.contains('no-sort')) {
|
||||
// Find the column index of the clicked header
|
||||
const headers = Array.from(tableElement.querySelectorAll('thead th'));
|
||||
const clickedIndex = headers.indexOf(header);
|
||||
|
||||
// Find which attribute maps to this column index
|
||||
let clickedAttribute = null;
|
||||
for (const [attr, idx] of Object.entries(columnMap)) {
|
||||
if (idx === clickedIndex) {
|
||||
clickedAttribute = attr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update button states to match the header sort
|
||||
sortButtons.forEach(btn => {
|
||||
if (btn.dataset.sortAttribute === clickedAttribute) {
|
||||
// Activate this button
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
// Determine direction from header class (after click is processed)
|
||||
setTimeout(() => {
|
||||
if (header.classList.contains('datatable-ascending')) {
|
||||
btn.dataset.currentDirection = 'asc';
|
||||
} else if (header.classList.contains('datatable-descending')) {
|
||||
btn.dataset.currentDirection = 'desc';
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
// Deactivate other buttons
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sortButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const attribute = button.dataset.sortAttribute;
|
||||
@@ -206,34 +253,49 @@ window.setupSharedSortButtons = function(tableId, tableInstanceGlobal, columnMap
|
||||
|
||||
} else {
|
||||
// ORIGINAL MODE - Client-side sorting via Simple DataTables
|
||||
// Instead of using columns.sort() API, we click the actual table header
|
||||
// This ensures DataTables handles everything correctly and avoids data corruption
|
||||
const columnIndex = columnMap[attribute];
|
||||
const tableInstance = window[tableInstanceGlobal];
|
||||
const tableElement = document.querySelector(`#${tableId}`);
|
||||
|
||||
if (columnIndex !== undefined && tableInstance) {
|
||||
// Determine sort direction
|
||||
const isCurrentlyActive = button.classList.contains('btn-primary');
|
||||
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
|
||||
const newDirection = isCurrentlyActive ?
|
||||
(currentDirection === 'asc' ? 'desc' : 'asc') :
|
||||
(isDesc ? 'desc' : 'asc');
|
||||
if (columnIndex !== undefined && tableElement) {
|
||||
const headers = tableElement.querySelectorAll('thead th');
|
||||
const targetHeader = headers[columnIndex];
|
||||
|
||||
// Clear other active buttons
|
||||
sortButtons.forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
});
|
||||
if (targetHeader) {
|
||||
// Determine desired sort direction
|
||||
const isCurrentlyActive = button.classList.contains('btn-primary');
|
||||
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
|
||||
const newDirection = isCurrentlyActive ?
|
||||
(currentDirection === 'asc' ? 'desc' : 'asc') :
|
||||
(isDesc ? 'desc' : 'asc');
|
||||
|
||||
// Mark this button as active
|
||||
button.classList.remove('btn-outline-primary');
|
||||
button.classList.add('btn-primary');
|
||||
button.dataset.currentDirection = newDirection;
|
||||
// Check current header sort state
|
||||
const headerIsAsc = targetHeader.classList.contains('datatable-ascending');
|
||||
const headerIsDesc = targetHeader.classList.contains('datatable-descending');
|
||||
|
||||
// Apply sort using Simple DataTables API
|
||||
tableInstance.table.columns.sort(columnIndex, newDirection);
|
||||
// Click header to achieve desired sort direction
|
||||
if (newDirection === 'asc') {
|
||||
if (!headerIsAsc) {
|
||||
targetHeader.click();
|
||||
// If it went to desc, click again to get asc
|
||||
if (targetHeader.classList.contains('datatable-descending')) {
|
||||
targetHeader.click();
|
||||
}
|
||||
}
|
||||
} else { // desc
|
||||
if (!headerIsDesc) {
|
||||
targetHeader.click();
|
||||
// If it went to asc, click again to get desc
|
||||
if (targetHeader.classList.contains('datatable-ascending')) {
|
||||
targetHeader.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update sort icon to reflect new direction
|
||||
updateSortIcon(newDirection);
|
||||
// Update sort icon to reflect new direction
|
||||
updateSortIcon(newDirection);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,11 +15,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
sortColumnMap: {
|
||||
'name': 1,
|
||||
'color': 2,
|
||||
'quantity': 3,
|
||||
'missing': 4,
|
||||
'damaged': 5,
|
||||
'sets': 6,
|
||||
'minifigures': 7
|
||||
//'quantity': 3,
|
||||
'missing': 3,
|
||||
'damaged': 4,
|
||||
'sets': 5,
|
||||
'minifigures': 6
|
||||
},
|
||||
hasColorDropdown: true
|
||||
});
|
||||
|
||||
@@ -42,8 +42,13 @@ window.BrickTable = class BrickTable {
|
||||
const hasCustomInterface = isMinifiguresTable || isPartsTablePaginationMode || isProblemsTablePaginationMode;
|
||||
const hasCustomSearch = isPartsTable || isProblemsTable || isMinifiguresTable; // Parts, problems, and minifigures always have custom search
|
||||
|
||||
// Disable sorting for parts/problems/minifigures tables to prevent data corruption
|
||||
// These tables should use server-side pagination for sorting, or no sorting in client-side mode
|
||||
const disableSorting = isPartsTable || isProblemsTable || isMinifiguresTable;
|
||||
|
||||
this.table = new simpleDatatables.DataTable(`#${table.id}`, {
|
||||
columns: columns,
|
||||
sortable: !disableSorting, // Disable header sorting for tables that have issues with it
|
||||
paging: !(isPartsTablePaginationMode || isProblemsTablePaginationMode), // Disable built-in pagination for tables in pagination mode
|
||||
pagerDelta: 1,
|
||||
perPage: per_page,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<th data-table-number="true" scope="col"><i class="ri-group-line fw-normal"></i> Minifigures</th>
|
||||
{% endif %}
|
||||
{% if checked and not config['HIDE_TABLE_CHECKED_PARTS'] %}
|
||||
<th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-checkbox-line fw-normal"></i> Checked</th>
|
||||
<th data-table-number="true" scope="col"><i class="ri-checkbox-line fw-normal"></i> Checked</th>
|
||||
{% endif %}
|
||||
{% if hamburger_menu and g.login.is_authenticated() %}
|
||||
{% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% 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>
|
||||
<td>{{ item.fields.quantity }}</td>
|
||||
<td data-sort="{{ item.fields.quantity }}">{{ item.fields.quantity }}</td>
|
||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.missing }}" class="table-td-input">
|
||||
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.missing, all=false, read_only=read_only) }}
|
||||
@@ -27,7 +27,7 @@
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
|
||||
<td class="table-td-input">
|
||||
<td data-sort="{{ item.fields.checked | default(0) | int }}" 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 %}
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
<td>{{ item.fields.total_minifigures }}</td>
|
||||
{% else %}
|
||||
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
|
||||
<td class="table-td-input">
|
||||
<td data-sort="{{ item.fields.checked | default(0) | int }}" 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 %}
|
||||
{% if g.login.is_authenticated() and not read_only %}
|
||||
{% 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'] %}
|
||||
{% set show_quick_add = not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'] and not config['HIDE_QUICK_ADD_INDIVIDUAL_PARTS'] %}
|
||||
{% if show_missing_menu or show_checked_menu or show_quick_add %}
|
||||
<td class="text-end">
|
||||
{% if show_quick_add %}
|
||||
|
||||
Reference in New Issue
Block a user