feat(ui): add templates and scripts for individual items and bulk part addition

This commit is contained in:
2026-01-18 20:38:11 +01:00
parent e46e1d5f93
commit caaef97313
37 changed files with 2542 additions and 42 deletions
+80
View File
@@ -0,0 +1,80 @@
// Add page - handles both sets and individual minifigures
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: {
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();
}
};
});
+802
View File
@@ -0,0 +1,802 @@
// Add parts page - handles individual parts and lots
document.addEventListener("DOMContentLoaded", () => {
// Get template data from data attributes
const addPartInput = document.getElementById('add-part-input');
if (!addPartInput) return;
// Read data from data attributes
const templateData = {
path: addPartInput.dataset.path,
namespace: addPartInput.dataset.namespace,
messages: {
COMPLETE: addPartInput.dataset.msgComplete,
CREATE_LOT: addPartInput.dataset.msgCreateLot,
FAIL: addPartInput.dataset.msgFail,
LOAD_PART: addPartInput.dataset.msgLoadPart,
LOAD_PART_COLORS: addPartInput.dataset.msgLoadPartColors,
PART_COLORS_LOADED: addPartInput.dataset.msgPartColorsLoaded,
PROGRESS: addPartInput.dataset.msgProgress,
PART_LOADED: addPartInput.dataset.msgPartLoaded,
}
};
// Initialize the socket
const partSocket = new BrickPartColorSocket(
'add-part',
templateData.path,
templateData.namespace,
{
COMPLETE: templateData.messages.COMPLETE,
CREATE_LOT: templateData.messages.CREATE_LOT,
CREATE_BULK_INDIVIDUAL_PARTS: 'create_bulk_individual_parts',
FAIL: templateData.messages.FAIL,
LOAD_PART: templateData.messages.LOAD_PART,
LOAD_PART_COLORS: templateData.messages.LOAD_PART_COLORS,
PART_COLORS_LOADED: templateData.messages.PART_COLORS_LOADED,
PROGRESS: templateData.messages.PROGRESS,
PART_LOADED: templateData.messages.PART_LOADED,
}
);
});
// Individual Part Color Selection Socket class
class BrickPartColorSocket extends BrickSocket {
constructor(id, path, namespace, messages) {
super(id, path, namespace, messages, false);
// Form elements
this.html_button = document.getElementById(id + '-lookup');
this.html_input = document.getElementById(`${id}-input`);
this.html_owners = document.getElementById(`${id}-owners`);
this.html_purchase_location = document.getElementById(`${id}-purchase-location`);
this.html_purchase_date = document.getElementById(`${id}-purchase-date`);
this.html_purchase_price = document.getElementById(`${id}-purchase-price`);
this.html_storage = document.getElementById(`${id}-storage`);
this.html_tags = document.getElementById(`${id}-tags`);
// Color selection elements
this.html_colors_section = document.getElementById(`${id}-colors-section`);
this.html_colors_grid = document.getElementById(`${id}-colors-grid`);
this.html_metadata_section = document.getElementById(`${id}-metadata-section`);
// Add mode elements (radio buttons)
this.html_single_mode = document.getElementById(`${id}-single-mode`);
this.html_bulk_mode = document.getElementById(`${id}-bulk-mode`);
this.html_lot_mode = document.getElementById(`${id}-lot-mode`);
this.html_cart_section = document.getElementById(`${id}-cart-section`);
this.html_cart_items = document.getElementById(`${id}-cart-items`);
this.html_cart_count = document.getElementById(`${id}-cart-count`);
this.html_complete_lot = document.getElementById(`${id}-complete-lot`);
this.html_complete_button_text = document.getElementById(`${id}-complete-button-text`);
this.html_clear_cart = document.getElementById(`${id}-clear-cart`);
// State
this.current_part = null;
this.current_part_name = null;
this.current_colors = null;
this.selected_color = null;
// Cart state
this.add_mode = 'single'; // 'single', 'bulk', or 'lot'
this.cart = []; // Array of {part, part_name, color_id, color_name, quantity, color_info}
if (this.html_button) {
this.html_button.addEventListener("click", ((bricksocket) => (e) => {
bricksocket.lookup_part();
})(this));
}
if (this.html_input) {
this.html_input.addEventListener("keyup", ((bricksocket) => (e) => {
if (e.key === 'Enter') {
bricksocket.lookup_part();
}
})(this));
}
// Add mode radio buttons
if (this.html_single_mode) {
this.html_single_mode.addEventListener("change", ((bricksocket) => (e) => {
if (e.target.checked) bricksocket.update_add_mode('single');
})(this));
}
if (this.html_bulk_mode) {
this.html_bulk_mode.addEventListener("change", ((bricksocket) => (e) => {
if (e.target.checked) bricksocket.update_add_mode('bulk');
})(this));
}
if (this.html_lot_mode) {
this.html_lot_mode.addEventListener("change", ((bricksocket) => (e) => {
if (e.target.checked) bricksocket.update_add_mode('lot');
})(this));
}
// Clear cart button
if (this.html_clear_cart) {
this.html_clear_cart.addEventListener("click", ((bricksocket) => (e) => {
bricksocket.clear_cart();
})(this));
}
// Complete lot button
if (this.html_complete_lot) {
this.html_complete_lot.addEventListener("click", ((bricksocket) => (e) => {
bricksocket.complete_lot();
})(this));
}
// CSV file input
this.html_csv_file = document.getElementById(`${id}-csv-file`);
this.html_import_csv = document.getElementById(`${id}-import-csv`);
if (this.html_csv_file) {
this.html_csv_file.addEventListener("change", ((bricksocket) => (e) => {
// Enable/disable import button based on file selection
if (bricksocket.html_import_csv) {
bricksocket.html_import_csv.disabled = !e.target.files || e.target.files.length === 0;
}
})(this));
}
if (this.html_import_csv) {
this.html_import_csv.addEventListener("click", ((bricksocket) => () => {
bricksocket.import_csv();
})(this));
}
// Setup the socket
this.setup();
}
// Clear form
clear() {
super.clear();
if (this.html_colors_section) {
this.html_colors_section.classList.add("d-none");
}
if (this.html_colors_grid) {
this.html_colors_grid.innerHTML = '';
}
if (this.html_metadata_section) {
this.html_metadata_section.classList.add("d-none");
}
this.current_part = null;
this.current_part_name = null;
this.current_colors = null;
this.selected_color = null;
}
// Look up part and load available colors
lookup_part() {
if (this.disabled) {
return;
}
const part = this.html_input.value.trim();
if (!part) {
this.fail({ message: 'Please enter a part number' });
return;
}
// Clear previous results
this.clear();
this.clear_status();
this.toggle(false);
this.spinner(true);
console.log('Emitting LOAD_PART_COLORS event with part:', part);
this.socket.emit(this.messages.LOAD_PART_COLORS, { part: part });
}
// Create a color card element
create_color_card(color) {
const col = document.createElement('div');
col.className = 'col';
const card = document.createElement('div');
card.className = 'card h-100';
// Card image
const imgContainer = document.createElement('div');
imgContainer.className = 'card-img-top';
imgContainer.style.height = '150px';
imgContainer.style.backgroundImage = `url(${color.part_img_url || ''})`;
imgContainer.style.backgroundSize = 'contain';
imgContainer.style.backgroundRepeat = 'no-repeat';
imgContainer.style.backgroundPosition = 'center';
const img = document.createElement('img');
img.src = color.part_img_url || '';
img.alt = color.color_name;
img.className = 'd-none';
img.loading = 'lazy';
imgContainer.appendChild(img);
// Card body
const cardBody = document.createElement('div');
cardBody.className = 'card-body p-2';
const colorName = document.createElement('h6');
colorName.className = 'card-title mb-1';
colorName.textContent = color.color_name;
const colorId = document.createElement('small');
colorId.className = 'text-muted';
colorId.textContent = `ID: ${color.color_id}`;
cardBody.appendChild(colorName);
cardBody.appendChild(colorId);
// Card footer with quantity input and add button
const cardFooter = document.createElement('div');
cardFooter.className = 'card-footer p-2';
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm';
const quantityInput = document.createElement('input');
quantityInput.type = 'number';
quantityInput.className = 'form-control';
quantityInput.placeholder = 'Qty';
quantityInput.value = '1';
quantityInput.min = '1';
quantityInput.id = `qty-${color.color_id}`;
const addButton = document.createElement('button');
addButton.className = 'btn btn-primary';
addButton.innerHTML = '<i class="ri-add-line"></i> Add';
addButton.onclick = ((bricksocket, colorData, qtyInput) => () => {
bricksocket.add_part_with_color(colorData, parseInt(qtyInput.value) || 1);
})(this, color, quantityInput);
inputGroup.appendChild(quantityInput);
inputGroup.appendChild(addButton);
cardFooter.appendChild(inputGroup);
// Assemble card
card.appendChild(imgContainer);
card.appendChild(cardBody);
card.appendChild(cardFooter);
col.appendChild(card);
return col;
}
// Add part with selected color
add_part_with_color(color, quantity) {
if (this.disabled) {
return;
}
console.log('Adding part with color:', color, 'quantity:', quantity);
// Clear previous status messages
this.clear_status();
// If in bulk or lot mode, add to cart instead of adding immediately
if (this.add_mode === 'bulk' || this.add_mode === 'lot') {
this.add_to_cart(color, quantity);
return;
}
// Collect owners
const owners = [];
if (this.html_owners) {
this.html_owners.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
owners.push(cb.value);
});
}
// Collect tags
const tags = [];
if (this.html_tags) {
this.html_tags.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
tags.push(cb.value);
});
}
const data = {
part: this.current_part,
part_name: this.current_part_name,
color: color.color_id,
color_info: color,
quantity: quantity,
description: '', // No description field in this UI
owners: owners,
tags: tags,
storage: this.html_storage ? this.html_storage.value : null,
purchase_location: this.html_purchase_location ? this.html_purchase_location.value : null,
purchase_date: this.html_purchase_date ? this.html_purchase_date.value : null,
purchase_price: this.html_purchase_price ? parseFloat(this.html_purchase_price.value) : null
};
this.clear_status();
this.toggle(false);
this.spinner(true);
console.log('Emitting LOAD_PART event with data:', data);
this.socket.emit(this.messages.LOAD_PART, data);
}
// Update add mode (single, bulk, or lot)
update_add_mode(mode) {
this.add_mode = mode;
// Show/hide cart section based on mode
if (this.html_cart_section) {
if (mode === 'bulk' || mode === 'lot') {
this.html_cart_section.classList.remove("d-none");
} else {
this.html_cart_section.classList.add("d-none");
// Clear cart when switching to single mode
this.clear_cart();
}
}
// Update button text based on mode
if (this.html_complete_button_text) {
if (mode === 'bulk') {
this.html_complete_button_text.textContent = 'Add All Parts';
} else if (mode === 'lot') {
this.html_complete_button_text.textContent = 'Complete Lot & Add All Parts';
}
}
}
// Add part to cart
add_to_cart(color, quantity) {
const cart_item = {
part: this.current_part,
part_name: this.current_part_name,
color_id: color.color_id,
color_name: color.color_name,
quantity: quantity,
color_info: color
};
this.cart.push(cart_item);
this.render_cart();
this.progress_message(`Added ${this.current_part} in ${color.color_name} to cart (${this.cart.length} items)`);
}
// Remove item from cart
remove_from_cart(index) {
this.cart.splice(index, 1);
this.render_cart();
}
// Clear entire cart
clear_cart() {
this.cart = [];
this.render_cart();
this.clear_status();
}
// Render cart items
render_cart() {
if (!this.html_cart_items) return;
this.html_cart_items.innerHTML = '';
if (this.cart.length === 0) {
this.html_cart_items.innerHTML = '<p class="text-muted text-center">Cart is empty</p>';
if (this.html_complete_lot) {
this.html_complete_lot.disabled = true;
}
} else {
this.cart.forEach((item, index) => {
const cartItem = document.createElement('div');
cartItem.className = 'card mb-2';
const cardBody = document.createElement('div');
cardBody.className = 'card-body p-2 d-flex justify-content-between align-items-center';
const info = document.createElement('div');
info.innerHTML = `
<strong>${item.part}</strong> - ${item.part_name}<br>
<small class="text-muted">Color: ${item.color_name}, Qty: ${item.quantity}</small>
`;
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-sm btn-outline-danger';
removeBtn.innerHTML = '<i class="ri-delete-bin-line"></i>';
removeBtn.onclick = () => this.remove_from_cart(index);
cardBody.appendChild(info);
cardBody.appendChild(removeBtn);
cartItem.appendChild(cardBody);
this.html_cart_items.appendChild(cartItem);
});
if (this.html_complete_lot) {
this.html_complete_lot.disabled = false;
}
}
// Update cart count badge
if (this.html_cart_count) {
this.html_cart_count.textContent = this.cart.length;
}
}
// Complete lot and add all parts
complete_lot() {
if (this.cart.length === 0) {
this.fail({message: 'Cart is empty. Add parts before completing the lot.'});
return;
}
this.clear_status();
this.toggle(false);
this.spinner(true);
// Prepare cart data - convert to format expected by backend
const cart_data = this.cart.map(item => ({
part: item.part,
part_name: item.part_name,
color_id: item.color_id,
color_name: item.color_name,
quantity: item.quantity,
color_info: item.color_info
}));
// Gather metadata from form
const data = {
cart: cart_data,
name: null, // Could add optional lot name field
description: null, // Could add optional lot description field
storage: this.html_storage ? (this.html_storage.value || '') : '',
purchase_location: this.html_purchase_location ? (this.html_purchase_location.value || '') : '',
purchase_date: this.html_purchase_date && this.html_purchase_date.value ? new Date(this.html_purchase_date.value).getTime() / 1000 : null,
purchase_price: this.html_purchase_price && this.html_purchase_price.value ? parseFloat(this.html_purchase_price.value) : null,
owners: this.html_owners ? Array.from(this.html_owners.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value) : [],
tags: this.html_tags ? Array.from(this.html_tags.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value) : []
};
// Emit different event based on mode
if (this.add_mode === 'bulk') {
// Bulk mode: add individual parts (no lot)
this.socket.emit(this.messages.CREATE_BULK_INDIVIDUAL_PARTS, data);
} else if (this.add_mode === 'lot') {
// Lot mode: create lot with parts
this.socket.emit(this.messages.CREATE_LOT, data);
}
}
// Import CSV file and add parts to cart
import_csv() {
if (!this.html_csv_file || !this.html_csv_file.files || this.html_csv_file.files.length === 0) {
this.fail({message: 'Please select a CSV file to import'});
return;
}
const file = this.html_csv_file.files[0];
const reader = new FileReader();
reader.onload = ((bricksocket) => (e) => {
try {
const text = e.target.result;
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
if (lines.length === 0) {
bricksocket.fail({message: 'CSV file is empty'});
return;
}
// Parse CSV header
const header = lines[0].split(',').map(h => h.trim());
const partIndex = header.findIndex(h => h.toLowerCase() === 'part');
const colorIndex = header.findIndex(h => h.toLowerCase() === 'color');
const quantityIndex = header.findIndex(h => h.toLowerCase() === 'quantity');
if (partIndex === -1 || colorIndex === -1 || quantityIndex === -1) {
bricksocket.fail({message: 'CSV must have columns: Part, Color, Quantity'});
return;
}
// Parse rows
const parts = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
if (values.length < 3) continue; // Skip incomplete rows
const part = values[partIndex];
const color = parseInt(values[colorIndex]);
const quantity = parseInt(values[quantityIndex]);
if (part && !isNaN(color) && !isNaN(quantity) && quantity > 0) {
parts.push({part, color, quantity});
}
}
if (parts.length === 0) {
bricksocket.fail({message: 'No valid parts found in CSV file'});
return;
}
// Enable lot mode if not already in bulk/lot mode
if (bricksocket.add_mode === 'single') {
if (bricksocket.html_lot_mode) {
bricksocket.html_lot_mode.checked = true;
bricksocket.update_add_mode('lot');
}
}
// Clear previous status and progress
bricksocket.clear_status();
bricksocket.progress_message(`Importing ${parts.length} parts from CSV...`);
// Process each part
bricksocket.import_csv_parts(parts, 0);
} catch (error) {
bricksocket.fail({message: `Error parsing CSV: ${error.message}`});
}
})(this);
reader.onerror = ((bricksocket) => () => {
bricksocket.fail({message: 'Error reading CSV file'});
})(this);
reader.readAsText(file);
}
// Import CSV parts one by one
import_csv_parts(parts, index) {
if (index >= parts.length) {
// All parts processed - clear CSV import state
this.csv_import_parts = null;
this.csv_import_index = undefined;
// Final cart render
this.render_cart();
// Show metadata section
if (this.html_metadata_section) {
this.html_metadata_section.classList.remove('d-none');
}
// Set progress bar to 100%
if (this.html_progress_bar) {
this.html_progress.setAttribute('aria-valuenow', '100');
this.html_progress_bar.setAttribute('style', 'width: 100%');
this.html_progress_bar.textContent = '100%';
}
// Show success message
this.clear_status();
this.spinner(false);
this.toggle(true);
const successDiv = document.getElementById('add-part-complete');
if (successDiv) {
successDiv.textContent = `Successfully imported ${parts.length} parts to cart! You can now apply metadata and click "Complete Lot & Add All Parts".`;
successDiv.classList.remove('d-none');
}
// Clear the file input
if (this.html_csv_file) {
this.html_csv_file.value = '';
}
if (this.html_import_csv) {
this.html_import_csv.disabled = true;
}
return;
}
const part = parts[index];
// Fetch colors for this part (backend will handle image downloads)
this.socket.emit(this.messages.LOAD_PART_COLORS, {part: part.part});
// Store CSV import state
this.csv_import_parts = parts;
this.csv_import_index = index;
}
// Override part_colors_loaded to handle CSV import
part_colors_loaded(data) {
// Check if we're in CSV import mode
if (this.csv_import_parts && this.csv_import_index !== undefined) {
const parts = this.csv_import_parts;
const index = this.csv_import_index;
const part = parts[index];
// Find the color in the loaded colors
const color = data.colors.find(c => c.color_id === part.color);
if (color) {
// Add to cart (silently, without rendering each time for performance)
const cart_item = {
part: data.part,
part_name: data.part_name,
color_id: color.color_id,
color_name: color.color_name,
quantity: part.quantity,
color_info: color
};
this.cart.push(cart_item);
// Only render cart at the end or every 5 parts for performance
if ((index + 1) % 5 === 0 || (index + 1) === parts.length) {
this.render_cart();
}
// Update progress bar manually
const progress = ((index + 1) / parts.length) * 100;
if (this.html_progress_bar) {
this.html_progress.setAttribute('aria-valuenow', progress);
this.html_progress_bar.setAttribute('style', `width: ${progress}%`);
this.html_progress_bar.textContent = `${progress.toFixed(0)}%`;
}
// Update simple status without showing the backend progress
this.clear_status();
this.progress_message(`Added ${index + 1}/${parts.length} parts to cart`);
// Process next part
this.import_csv_parts(parts, index + 1);
} else {
this.fail({message: `Color ${part.color} not found for part ${part.part}. Import stopped at part ${index + 1}/${parts.length}.`});
this.csv_import_parts = null;
this.csv_import_index = undefined;
}
return;
}
// Normal part colors loaded flow
console.log('Received part colors:', data);
this.current_part = data.part;
this.current_part_name = data.part_name;
this.current_colors = data.colors;
// Show the colors section
if (this.html_colors_section) {
this.html_colors_section.classList.remove("d-none");
}
// Render color cards
if (this.html_colors_grid && this.current_colors) {
this.html_colors_grid.innerHTML = '';
this.current_colors.forEach((color) => {
const card = this.create_color_card(color);
this.html_colors_grid.appendChild(card);
});
}
// Show metadata section
if (this.html_metadata_section) {
this.html_metadata_section.classList.remove("d-none");
}
// Set progress bar to 100% for single part lookup
if (this.html_progress_bar) {
this.html_progress.setAttribute('aria-valuenow', '100');
this.html_progress_bar.setAttribute('style', 'width: 100%');
this.html_progress_bar.textContent = '100%';
}
this.spinner(false);
this.toggle(true);
this.progress_message(`Found ${data.count} colors for ${this.current_part_name} (${this.current_part})`);
}
// Override progress to suppress backend progress updates during CSV import
progress(data={}) {
// Ignore backend progress updates when importing CSV
if (this.csv_import_parts && this.csv_import_index !== undefined) {
return;
}
// Otherwise, use the default progress behavior
super.progress(data);
}
// Setup socket listeners
setup() {
super.setup();
if (this.socket) {
// Listen for part colors loaded
this.socket.on(this.messages.PART_COLORS_LOADED, ((bricksocket) => (data) => {
bricksocket.part_colors_loaded(data);
})(this));
}
}
// Override complete to clear the form but keep success message
complete(data) {
// Custom success display with green alert box
if (this.html_progress_bar) {
this.html_progress.setAttribute("aria-valuenow", "100");
this.html_progress_bar.setAttribute("style", "width: 100%");
this.html_progress_bar.textContent = "100%";
}
this.spinner(false);
// Show success message in green alert box
if (this.html_complete) {
this.html_complete.classList.remove("d-none");
this.html_complete.innerHTML = `<strong>Success:</strong> ${data.message}`;
}
if (this.html_fail) {
this.html_fail.classList.add("d-none");
}
// If lot was created, clear the cart (but don't clear status message)
if (data && (data.lot_id || data.parts_added)) {
// Clear cart without clearing status
this.cart = [];
this.render_cart();
// Reset to single mode after successful add
if (this.html_single_mode) {
this.html_single_mode.checked = true;
// Don't call update_add_mode('single') as it would call clear_cart() again
// Just update the mode and hide cart section manually
this.add_mode = 'single';
if (this.html_cart_section) {
this.html_cart_section.classList.add("d-none");
}
}
}
// Clear the form after successful add (but don't call this.clear() as it clears status)
if (this.html_input) {
this.html_input.value = '';
}
// Hide color selection section without clearing status
if (this.html_colors_section) {
this.html_colors_section.classList.add("d-none");
}
if (this.html_colors_grid) {
this.html_colors_grid.innerHTML = '';
}
if (this.html_metadata_section) {
this.html_metadata_section.classList.add("d-none");
}
// Clear state
this.current_part = null;
this.current_part_name = null;
this.current_colors = null;
this.selected_color = null;
// Uncheck all metadata
if (this.html_owners) {
this.html_owners.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
}
if (this.html_tags) {
this.html_tags.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
}
if (this.html_storage) {
this.html_storage.value = '';
}
if (this.html_purchase_location) {
this.html_purchase_location.value = '';
}
if (this.html_purchase_date) {
this.html_purchase_date.value = '';
}
if (this.html_purchase_price) {
this.html_purchase_price.value = '';
}
}
}
+51 -1
View File
@@ -93,6 +93,9 @@ document.addEventListener("DOMContentLoaded", () => {
// Initialize collapsible states (filter and sort)
initializeCollapsibleStates();
// Initialize individuals filter
initializeIndividualsFilter();
if (searchInput && searchClear) {
if (isPaginationMode()) {
// PAGINATION MODE - Server-side search
@@ -174,7 +177,7 @@ document.addEventListener("DOMContentLoaded", () => {
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('minifigures', ['owner', 'problems', 'theme', 'year']);
window.clearPageFilters('minifigures', ['owner', 'problems', 'theme', 'year', 'individuals']);
});
}
});
@@ -190,4 +193,51 @@ function setupSortButtons() {
};
// Use shared sort buttons setup from collapsible-state.js
window.setupSharedSortButtons('minifigures', 'brickTableInstance', columnMap);
}
// Initialize individuals filter functionality
function initializeIndividualsFilter() {
const individualsFilterButton = document.getElementById('individuals-filter-toggle');
if (!individualsFilterButton) return;
// Check if the filter should be active from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const isIndividualsFilterActive = urlParams.get('individuals') === 'only';
// Set initial button state
if (isIndividualsFilterActive) {
individualsFilterButton.classList.remove('btn-outline-secondary');
individualsFilterButton.classList.add('btn-secondary');
}
individualsFilterButton.addEventListener('click', () => {
const isCurrentlyActive = individualsFilterButton.classList.contains('btn-secondary');
const newState = !isCurrentlyActive;
// Update button appearance
if (newState) {
individualsFilterButton.classList.remove('btn-outline-secondary');
individualsFilterButton.classList.add('btn-secondary');
} else {
individualsFilterButton.classList.remove('btn-secondary');
individualsFilterButton.classList.add('btn-outline-secondary');
}
// Update URL parameter and reload
const currentUrl = new URL(window.location);
if (newState) {
currentUrl.searchParams.set('individuals', 'only');
} else {
currentUrl.searchParams.delete('individuals');
}
// Reset to page 1 when filtering in server-side pagination mode
if (isPaginationMode()) {
currentUrl.searchParams.set('page', '1');
}
// Navigate to updated URL
window.location.href = currentUrl.toString();
});
}
+57 -1
View File
@@ -1,10 +1,63 @@
// Parts page functionality - now uses shared functions
// Check if we're in pagination mode (server-side) or original mode (client-side)
function isPaginationMode() {
const tableElement = document.querySelector('#parts');
return tableElement && tableElement.getAttribute('data-table') === 'false';
}
// Keep filters expanded after selection
function applyFiltersAndKeepOpen() {
window.applyFiltersAndKeepState('parts', 'parts-filter-state');
}
// Initialize individuals filter functionality
function initializeIndividualsFilter() {
const individualsFilterButton = document.getElementById('individuals-filter-toggle');
if (!individualsFilterButton) return;
// Check if the filter should be active from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const isIndividualsFilterActive = urlParams.get('individuals') === 'only';
// Set initial button state
if (isIndividualsFilterActive) {
individualsFilterButton.classList.remove('btn-outline-secondary');
individualsFilterButton.classList.add('btn-secondary');
}
individualsFilterButton.addEventListener('click', () => {
const isCurrentlyActive = individualsFilterButton.classList.contains('btn-secondary');
const newState = !isCurrentlyActive;
// Update button appearance
if (newState) {
individualsFilterButton.classList.remove('btn-outline-secondary');
individualsFilterButton.classList.add('btn-secondary');
} else {
individualsFilterButton.classList.remove('btn-secondary');
individualsFilterButton.classList.add('btn-outline-secondary');
}
// Update URL parameter and reload
const currentUrl = new URL(window.location);
if (newState) {
currentUrl.searchParams.set('individuals', 'only');
} else {
currentUrl.searchParams.delete('individuals');
}
// Reset to page 1 when filtering in server-side pagination mode
if (isPaginationMode()) {
currentUrl.searchParams.set('page', '1');
}
// Navigate to updated URL
window.location.href = currentUrl.toString();
});
}
// Initialize parts page
document.addEventListener("DOMContentLoaded", () => {
// Use shared table page initialization
@@ -24,11 +77,14 @@ document.addEventListener("DOMContentLoaded", () => {
hasColorDropdown: true
});
// Initialize individuals filter
initializeIndividualsFilter();
// Initialize clear filters button
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year']);
window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year', 'individuals']);
});
}
});
+127
View File
@@ -0,0 +1,127 @@
// Quick Add Individual Part from Set Parts Table
// Handles the modal popup and form submission for adding individual parts
document.addEventListener('DOMContentLoaded', function() {
// Get modal and form elements
const modal = document.getElementById('quickAddIndividualPartModal');
if (!modal) return; // Modal only exists on set details page
const bsModal = new bootstrap.Modal(modal);
const form = document.getElementById('quickAddForm');
const submitBtn = document.getElementById('quickAddSubmit');
// Handle click on quick-add buttons/links
document.addEventListener('click', function(e) {
const trigger = e.target.closest('.quick-add-individual-part');
if (!trigger) return;
// Prevent default link behavior if it's a dropdown item
e.preventDefault();
// Get part data from trigger attributes
const partNumber = trigger.dataset.part;
const colorId = trigger.dataset.color;
const partName = trigger.dataset.name;
const colorName = trigger.dataset.colorName;
const imageUrl = trigger.dataset.image;
// Populate modal with part info
document.getElementById('quickAddPartImage').src = imageUrl;
document.getElementById('quickAddPartName').textContent = partName;
document.getElementById('quickAddPartNumber').textContent = `Part: ${partNumber}`;
document.getElementById('quickAddPartColor').textContent = `Color: ${colorName}`;
document.getElementById('quickAddPart').value = partNumber;
document.getElementById('quickAddColor').value = colorId;
// Reset form fields
document.getElementById('quickAddQuantity').value = 1;
document.getElementById('quickAddStorage').value = '';
document.getElementById('quickAddPurchaseLocation').value = '';
document.getElementById('quickAddDescription').value = '';
// Show modal
bsModal.show();
});
// Handle form submission
submitBtn.addEventListener('click', async function() {
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Disable submit button to prevent double-submit
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
// Collect form data
const formData = {
part: document.getElementById('quickAddPart').value,
color: document.getElementById('quickAddColor').value,
quantity: parseInt(document.getElementById('quickAddQuantity').value),
storage: document.getElementById('quickAddStorage').value || null,
purchase_location: document.getElementById('quickAddPurchaseLocation').value || null,
description: document.getElementById('quickAddDescription').value || null
};
try {
// Submit to backend
const response = await fetch('/individual-parts/quick-add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
// Success - show success message and close modal
bsModal.hide();
// Show success notification
showNotification('success', `Added ${formData.quantity}x ${document.getElementById('quickAddPartName').textContent} to individual parts inventory`);
// Optionally reload the page or update UI
// window.location.reload();
} else {
// Error from server
showNotification('error', result.error || 'Failed to add part to inventory');
submitBtn.disabled = false;
submitBtn.innerHTML = 'Add to Inventory';
}
} catch (error) {
// Network or other error
console.error('Error adding individual part:', error);
showNotification('error', 'Network error. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = 'Add to Inventory';
}
});
// Reset submit button when modal is hidden
modal.addEventListener('hidden.bs.modal', function() {
submitBtn.disabled = false;
submitBtn.innerHTML = 'Add to Inventory';
});
});
// Helper function to show notifications
function showNotification(type, message) {
// Create toast/alert element
const toast = document.createElement('div');
toast.className = `alert alert-${type === 'success' ? 'success' : 'danger'} alert-dismissible fade show position-fixed`;
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.remove();
}, 5000);
}
+34 -6
View File
@@ -6,10 +6,24 @@
{% block main %}
<div class="container">
{% if not bulk and not config['HIDE_ADD_BULK_SET'] %}
<div class="alert alert-primary" role="alert">
<h4 class="alert-heading">Too many to add?</h4>
<p class="mb-0">You can import multiple sets at once with <a href="{{ url_for('add.bulk') }}" class="btn btn-primary"><i class="ri-function-add-line"></i> Bulk add</a>.</p>
{% if not bulk and (not config['HIDE_ADD_BULK_SET'] or not config['HIDE_INDIVIDUAL_PARTS']) %}
<div class="row g-3 mb-3">
{% if not config['HIDE_ADD_BULK_SET'] %}
<div class="col-12 {% if not config['HIDE_INDIVIDUAL_PARTS'] %}col-md-6{% endif %}">
<div class="alert alert-primary mb-0 h-100" role="alert">
<h4 class="alert-heading">Too many to add?</h4>
<p class="mb-0">You can import multiple sets at once with <a href="{{ url_for('add.bulk') }}" class="btn btn-primary"><i class="ri-function-add-line"></i> Bulk add</a>.</p>
</div>
</div>
{% endif %}
{% if not config['HIDE_INDIVIDUAL_PARTS'] %}
<div class="col-12 {% if not config['HIDE_ADD_BULK_SET'] %}col-md-6{% endif %}">
<div class="alert alert-info mb-0 h-100" role="alert">
<h4 class="alert-heading">Adding individual parts?</h4>
<p class="mb-0">You can add standalone parts (not from a set) with <a href="{{ url_for('add.parts') }}" class="btn btn-info"><i class="ri-hammer-line"></i> Add parts</a>.</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="row">
@@ -26,8 +40,20 @@
<div id="add-complete"></div>
{% endif %}
<div class="mb-3">
<label for="add-set" class="form-label">{% if not bulk %}Set number (only one){% else %}List of sets (separated by a comma){% endif %}</label>
<input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}107-1 or 1642-1 or ...{% else %}107-1, 1642-1, ...{% endif %}">
<label for="add-set" class="form-label">{% if not bulk %}{% if not config['DISABLE_INDIVIDUAL_MINIFIGURES'] %}Set or Minifigure number (only one){% else %}Set number (only one){% endif %}{% else %}List of sets (separated by a comma){% endif %}</label>
<input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}{% if not config['DISABLE_INDIVIDUAL_MINIFIGURES'] %}107-1 or fig-001234 or ...{% else %}107-1 or ...{% endif %}{% else %}107-1, 1642-1, ...{% endif %}"
data-path="{{ path }}"
data-namespace="{{ namespace }}"
data-msg-complete="{{ messages['COMPLETE'] }}"
data-msg-fail="{{ messages['FAIL'] }}"
data-msg-import-set="{{ messages['IMPORT_SET'] }}"
data-msg-load-set="{{ messages['LOAD_SET'] }}"
data-msg-progress="{{ messages['PROGRESS'] }}"
data-msg-set-loaded="{{ messages['SET_LOADED'] }}"
data-msg-import-minifigure="{{ messages['IMPORT_MINIFIGURE'] }}"
data-msg-load-minifigure="{{ messages['LOAD_MINIFIGURE'] }}"
data-msg-minifigure-loaded="{{ messages['MINIFIGURE_LOADED'] }}">
<div class="form-text">Sets: use format like 107-1{% if not config['DISABLE_INDIVIDUAL_MINIFIGURES'] %}. Minifigures: use format like fig-001234{% endif %}</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="add-no-confirm" {% if bulk %}checked disabled{% endif %}>
@@ -141,7 +167,9 @@
</div>
</div>
</div>
{% if bulk %}
{% with id='add', bulk=bulk %}
{% include 'set/socket.html' %}
{% endwith %}
{% endif %}
{% endblock %}
+206
View File
@@ -0,0 +1,206 @@
{% import 'macro/accordion.html' as accordion %}
{% extends 'base.html' %}
{% block title %} - Add individual parts{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="ri-hammer-line"></i> Add individual parts</h5>
</div>
<div class="card-body">
<div id="add-part-fail" class="alert alert-danger d-none" role="alert"></div>
<div id="add-part-complete" class="alert alert-success d-none" role="alert"></div>
<div class="mb-3">
<label for="add-part-input" class="form-label">Part number</label>
<input type="text" class="form-control" id="add-part-input" placeholder="3001" required
data-path="{{ path }}"
data-namespace="{{ namespace }}"
data-msg-complete="{{ messages['COMPLETE'] }}"
data-msg-create-lot="{{ messages['CREATE_LOT'] }}"
data-msg-fail="{{ messages['FAIL'] }}"
data-msg-load-part="{{ messages['LOAD_PART'] }}"
data-msg-load-part-colors="{{ messages['LOAD_PART_COLORS'] }}"
data-msg-part-colors-loaded="{{ messages['PART_COLORS_LOADED'] }}"
data-msg-progress="{{ messages['PROGRESS'] }}"
data-msg-part-loaded="{{ messages['PART_LOADED'] }}">
<div class="form-text">Enter the Rebrickable part number (e.g., 3001, 3622, etc.)</div>
</div>
<div class="mb-3">
<label class="form-label"><strong>Add Mode</strong></label>
<div class="form-check">
<input class="form-check-input" type="radio" name="add-mode" id="add-part-single-mode" value="single" checked>
<label class="form-check-label" for="add-part-single-mode">
<strong>Single Mode</strong> - Add parts immediately one at a time
</label>
<div class="form-text">Each part is added to your inventory as soon as you select it</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="add-mode" id="add-part-bulk-mode" value="bulk">
<label class="form-check-label" for="add-part-bulk-mode">
<strong>Bulk Mode</strong> - Add multiple individual parts
</label>
<div class="form-text">Parts are added to a cart and saved together as individual parts (no lot created)</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="add-mode" id="add-part-lot-mode" value="lot">
<label class="form-check-label" for="add-part-lot-mode">
<strong>Lot Mode</strong> - Create a part lot
</label>
<div class="form-text">Parts are added to a cart and saved together as a lot</div>
</div>
</div>
<!-- CSV Import section -->
<div class="mb-3">
<label for="add-part-csv-file" class="form-label">Or import from Rebrickable CSV</label>
<div class="input-group">
<input type="file" class="form-control" id="add-part-csv-file" accept=".csv">
<button id="add-part-import-csv" type="button" class="btn btn-secondary" disabled>
<i class="ri-upload-line"></i> Import CSV
</button>
</div>
<div class="form-text">Upload a CSV file with columns: Part, Color, Quantity (automatically enables Lot Mode)</div>
</div>
<!-- Cart section (only visible in lot mode) -->
<div id="add-part-cart-section" class="d-none mb-3">
<h6 class="border-bottom">
<i class="ri-shopping-cart-line"></i> Cart
<span class="badge bg-primary" id="add-part-cart-count">0</span>
</h6>
<div id="add-part-cart-items" class="mb-2">
<!-- Cart items will be inserted here -->
</div>
<div class="d-grid gap-2">
<button id="add-part-complete-lot" type="button" class="btn btn-success" disabled>
<i class="ri-check-line"></i> <span id="add-part-complete-button-text">Complete Lot & Add All Parts</span>
</button>
<button id="add-part-clear-cart" type="button" class="btn btn-outline-danger btn-sm">
<i class="ri-delete-bin-line"></i> Clear Cart
</button>
</div>
</div>
<hr>
<div class="mb-3">
<p>
Progress <span id="add-part-count"></span>
<span id="add-part-spinner" class="d-none">
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
</span>
</p>
<div id="add-part-progress" class="progress" role="progressbar" aria-label="Add part progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="add-part-progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<p id="add-part-progress-message" class="text-center d-none"></p>
</div>
<!-- Color selection section (hidden until part is loaded) -->
<div id="add-part-colors-section" class="d-none">
<h6 class="border-bottom mt-3 mb-3">Select Color</h6>
<div id="add-part-colors-grid" class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-3">
<!-- Color cards will be inserted here dynamically -->
</div>
</div>
<!-- Metadata section (initially hidden, shown after selecting part) -->
<div id="add-part-metadata-section" class="d-none">
<h6 class="border-bottom mt-3">Metadata</h6>
<div class="accordion accordion" id="metadata">
{% if not (brickset_owners | length) and not (brickset_purchase_locations | length) and not (brickset_storages | length) and not (brickset_tags | length) %}
<div class="alert alert-warning" role="alert">
You have no metadata configured.
You can add entries in the <a href="{{ url_for('admin.admin', open_metadata=true) }}" class="btn btn-warning" role="button"><i class="ri-profile-line"></i> Set metadata management</a> section of the Admin panel.
</div>
{% else %}
{% if brickset_owners | length %}
{{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
<div id="add-part-owners">
{% for owner in brickset_owners %}
{% with id=owner.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="part-{{ id }}" autocomplete="off">
<label class="form-check-label" for="part-{{ id }}">{{ owner.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_purchase_locations | length %}
{{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }}
<label class="visually-hidden" for="add-part-purchase-location">Purchase location</label>
<div class="input-group">
<select id="add-part-purchase-location" class="form-select" autocomplete="off">
<option value="" selected><i>None</i></option>
{% for purchase_location in brickset_purchase_locations %}
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
{% endfor %}
</select>
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_storages | length %}
{{ accordion.header('Storage', 'storage', 'metadata', icon='archive-line') }}
<label class="visually-hidden" for="add-part-storage">Storage</label>
<div class="input-group">
<select id="add-part-storage" class="form-select" autocomplete="off">
<option value="" selected><i>None</i></option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_tags | length %}
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-3-line') }}
<div id="add-part-tags">
{% for tag in brickset_tags %}
{% with id=tag.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="part-{{ id }}" autocomplete="off">
<label class="form-check-label" for="part-{{ id }}">{{ tag.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
{% endif %}
{% if brickset_purchase_locations | length %}
{{ accordion.header('Purchase details', 'purchase', 'metadata', icon='money-dollar-circle-line') }}
<div class="mb-3">
<label for="add-part-purchase-date" class="form-label">Purchase date</label>
<input type="date" class="form-control" id="add-part-purchase-date">
</div>
<div class="mb-3">
<label for="add-part-purchase-price" class="form-label">Purchase price</label>
<input type="number" class="form-control" id="add-part-purchase-price" placeholder="0.00" step="0.01" min="0">
</div>
{{ accordion.footer() }}
{% endif %}
{% endif %}
</div>
</div>
</div>
<div class="card-footer text-end">
<span id="add-part-status-icon" class="me-1"></span><span id="add-part-status" class="me-1"></span>
<button id="add-part-lookup" type="button" class="btn btn-primary"><i class="ri-search-line"></i> Look up part</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+42
View File
@@ -164,6 +164,14 @@
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_INDIVIDUAL_MINIFIGURES" data-var="BK_HIDE_INDIVIDUAL_MINIFIGURES" {{ is_locked('BK_HIDE_INDIVIDUAL_MINIFIGURES') }}>
<label class="form-check-label" for="BK_HIDE_INDIVIDUAL_MINIFIGURES">
BK_HIDE_INDIVIDUAL_MINIFIGURES {{ config_badges('BK_HIDE_INDIVIDUAL_MINIFIGURES') }}
<div class="text-muted small">Hide individual minifigures page access</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PARTS" data-var="BK_HIDE_ALL_PARTS" {{ is_locked('BK_HIDE_ALL_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_ALL_PARTS">
@@ -171,6 +179,14 @@
<div class="text-muted small">Hide the "Parts" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_INDIVIDUAL_PARTS" data-var="BK_HIDE_INDIVIDUAL_PARTS" {{ is_locked('BK_HIDE_INDIVIDUAL_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_INDIVIDUAL_PARTS">
BK_HIDE_INDIVIDUAL_PARTS {{ config_badges('BK_HIDE_INDIVIDUAL_PARTS') }}
<div class="text-muted small">Hide individual parts and part lots dropdown from Parts menu</div>
</label>
</div>
</div>
<div class="col-md-6">
@@ -252,6 +268,14 @@
<div class="text-muted small">Hide the "Checked" column in parts tables</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS" data-var="BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS" {{ is_locked('BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS') }}>
<label class="form-check-label" for="BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS">
BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS {{ config_badges('BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS') }}
<div class="text-muted small">Disable quick-add individual parts from set parts tables</div>
</label>
</div>
</div>
<div class="col-md-6">
@@ -826,6 +850,24 @@
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_DISABLE_INDIVIDUAL_PARTS" data-var="BK_DISABLE_INDIVIDUAL_PARTS" {{ is_locked('BK_DISABLE_INDIVIDUAL_PARTS') }}>
<label class="form-check-label" for="static-BK_DISABLE_INDIVIDUAL_PARTS">
BK_DISABLE_INDIVIDUAL_PARTS {{ config_badges('BK_DISABLE_INDIVIDUAL_PARTS') }}
<div class="text-muted small">Disable individual parts feature (blocks adding/editing, allows viewing existing)</div>
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_DISABLE_INDIVIDUAL_MINIFIGURES" data-var="BK_DISABLE_INDIVIDUAL_MINIFIGURES" {{ is_locked('BK_DISABLE_INDIVIDUAL_MINIFIGURES') }}>
<label class="form-check-label" for="static-BK_DISABLE_INDIVIDUAL_MINIFIGURES">
BK_DISABLE_INDIVIDUAL_MINIFIGURES {{ config_badges('BK_DISABLE_INDIVIDUAL_MINIFIGURES') }}
<div class="text-muted small">Disable individual minifigures feature (blocks adding/editing, allows viewing existing)</div>
</label>
</div>
</div>
<div class="col-md-6">
<label for="static-BK_DOMAIN_NAME" class="form-label">
BK_DOMAIN_NAME {{ config_badges('BK_DOMAIN_NAME') }}
+102 -10
View File
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en" data-bs-theme="{{ 'dark' if config.get('DARK_MODE') in [True, 'True', 'true'] else 'light' }}">
<head>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BrickTracker{% block title %}{% endblock %}</title>
@@ -28,14 +28,90 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% for item in config['_NAVBAR'] %}
{% if item.flag and not config[item.flag] %}
<li class="nav-item px-1">
<a {% if request.url_rule.endpoint == item.endpoint %}class="nav-link active" aria-current="page"{% else %}class="nav-link"{% endif %} href="{{ url_for(item.endpoint) }}">
{% if item.icon %}
<i class="ri-{{ item.icon }}"></i>
{% endif %}
{{ item.title }}
</a>
</li>
{% if item.endpoint == 'add.add' %}
{# Add menu item with optional dropdown for Bulk/Parts #}
{% set has_bulk = not config['HIDE_ADD_BULK_SET'] %}
{% set has_parts = not config['HIDE_INDIVIDUAL_PARTS'] %}
{% if has_bulk or has_parts %}
<li class="nav-item dropdown px-1">
<a {% if request.url_rule.endpoint in ['add.add', 'add.bulk', 'add.parts'] %}class="nav-link active" aria-current="page"{% else %}class="nav-link"{% endif %}
href="{{ url_for(item.endpoint) }}">
{% if item.icon %}
<i class="ri-{{ item.icon }}"></i>
{% endif %}
{{ item.title }}
</a>
<a class="dropdown-toggle dropdown-toggle-split nav-link {% if request.url_rule.endpoint in ['add.add', 'add.bulk', 'add.parts'] %}active{% endif %}"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</a>
<ul class="dropdown-menu">
{% if has_bulk %}
<li><a class="dropdown-item" href="{{ url_for('add.bulk') }}"><i class="ri-function-add-line"></i> Bulk add</a></li>
{% endif %}
{% if has_parts %}
<li><a class="dropdown-item" href="{{ url_for('add.parts') }}"><i class="ri-hammer-line"></i> Add parts</a></li>
{% endif %}
</ul>
</li>
{% else %}
{# Just Add link, no dropdown #}
<li class="nav-item px-1">
<a {% if request.url_rule.endpoint == item.endpoint %}class="nav-link active" aria-current="page"{% else %}class="nav-link"{% endif %} href="{{ url_for(item.endpoint) }}">
{% if item.icon %}
<i class="ri-{{ item.icon }}"></i>
{% endif %}
{{ item.title }}
</a>
</li>
{% endif %}
{% elif item.endpoint == 'part.list' %}
{# Parts menu item with optional dropdown for Individual Parts/Lots #}
{% set has_individual_parts = not config.get('HIDE_INDIVIDUAL_PARTS', False) %}
{% if has_individual_parts %}
<li class="nav-item dropdown px-1">
<a {% if request.url_rule.endpoint in ['part.list', 'individual_part.list', 'individual_part.list_lots'] %}class="nav-link active" aria-current="page"{% else %}class="nav-link"{% endif %}
href="{{ url_for(item.endpoint) }}">
{% if item.icon %}
<i class="ri-{{ item.icon }}"></i>
{% endif %}
{{ item.title }}
</a>
<a class="dropdown-toggle dropdown-toggle-split nav-link {% if request.url_rule.endpoint in ['part.list', 'individual_part.list', 'individual_part.list_lots'] %}active{% endif %}"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('individual_part.list') }}"><i class="ri-hammer-line"></i> Individual parts</a></li>
<li><a class="dropdown-item" href="{{ url_for('individual_part.list_lots') }}"><i class="ri-stack-line"></i> Part lots</a></li>
</ul>
</li>
{% else %}
{# Just Parts link, no dropdown #}
<li class="nav-item px-1">
<a {% if request.url_rule.endpoint == item.endpoint %}class="nav-link active" aria-current="page"{% else %}class="nav-link"{% endif %} href="{{ url_for(item.endpoint) }}">
{% if item.icon %}
<i class="ri-{{ item.icon }}"></i>
{% endif %}
{{ item.title }}
</a>
</li>
{% endif %}
{% else %}
{# Regular menu item #}
<li class="nav-item px-1">
<a {% if request.url_rule.endpoint == item.endpoint %}class="nav-link active" aria-current="page"{% else %}class="nav-link"{% endif %} href="{{ url_for(item.endpoint) }}">
{% if item.icon %}
<i class="ri-{{ item.icon }}"></i>
{% endif %}
{{ item.title }}
</a>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ul>
@@ -84,7 +160,6 @@
<!-- BrickTracker scripts -->
<script src="{{ url_for('static', filename='scripts/collapsible-state.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/filter_toggle.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/sort.js') }}"></script>
@@ -93,7 +168,14 @@
<script src="{{ url_for('static', filename='scripts/socket/socket.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/minifigure.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/table.js') }}"></script>
{% if request.endpoint == 'add.add' %}
<script src="{{ url_for('static', filename='scripts/add.js') }}"></script>
{% endif %}
{% if request.endpoint == 'add.parts' %}
<script src="{{ url_for('static', filename='scripts/add_parts.js') }}"></script>
{% endif %}
{% if request.endpoint == 'minifigure.list' %}
<script src="{{ url_for('static', filename='scripts/minifigures.js') }}"></script>
{% endif %}
@@ -110,6 +192,16 @@
{% if request.endpoint == 'set.details' %}
<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>
{% endif %}
{% if request.endpoint == 'part.details' %}
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
{% endif %}
{% if request.endpoint == 'individual_part.lot_details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
{% endif %}
{% if request.endpoint == 'individual_minifigure.details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
{% endif %}
{% if request.endpoint == 'statistics.overview' %}
<script src="{{ url_for('static', filename='scripts/statistics.js') }}"></script>
+34
View File
@@ -0,0 +1,34 @@
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
{% import 'macro/form.html' as form %}
<div class="card mb-3 flex-fill">
{{ card.header(item, item.fields.name, solo=false, identifier=item.fields.figure, icon='user-line') }}
{{ card.image(item, solo=false, last=false, caption=item.fields.name, alt=item.fields.figure, medium=false) }}
<div class="card-body border-bottom p-1">
{# Always show quantity first #}
{{ badge.quantity(item.fields.quantity, solo=false, last=false) }}
{# Render all other badges in configured order #}
{{ badge.render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=false, last=false, context='grid') }}
{# Description badge at end if exists #}
{% if item.fields.description %}
<span class="badge text-bg-light text-dark text-wrap" data-bs-toggle="tooltip" title="{{ item.fields.description }}">
<i class="ri-file-text-line"></i> {{ item.fields.description[:config.get('DESCRIPTION_BADGE_MAX_LENGTH', 50)] }}{% if item.fields.description | length > config.get('DESCRIPTION_BADGE_MAX_LENGTH', 50) %}...{% endif %}
</span>
{% endif %}
</div>
{% if g.login.is_authenticated() %}
<div class="card-footer p-1 border-bottom">
{{ form.input('Qty', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }}
</div>
{% endif %}
{% if brickset_statuses | length %}
<ul class="list-group list-group-flush card-check border-bottom-0">
{% for status in brickset_statuses %}
<li class="d-flex list-group-item p-1 text-nowrap">
{{ form.checkbox(status.fields.name, item.fields.id, status.as_dataset(), status.url_for_individual_minifigure_state(item.fields.id), item.fields[status.as_column()]) }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
@@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/form.html' as form %}
{% block title %} - Individual Minifigure {{ item.fields.name }}{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="ri-user-line"></i> {{ item.fields.name }}
<span class="badge text-bg-secondary fw-normal"><i class="ri-hashtag"></i> {{ item.fields.figure }}</span>
</h5>
<div>
<a href="{{ url_for('minifigure.details', figure=item.fields.figure) }}" class="btn btn-sm btn-secondary">
<i class="ri-arrow-left-line"></i> Back to {{ item.fields.figure }}
</a>
</div>
</div>
<div class="card-img border-bottom" style="background-image: url({{ item.url_for_image() }})">
<a data-lightbox data-caption="{{ item.fields.name }}" href="{{ item.url_for_image() }}" target="_blank">
<img class="card-medium-img" src="{{ item.url_for_image() }}" alt="{{ item.fields.figure }}" loading="lazy">
</a>
</div>
<div class="accordion accordion-flush border-top" id="individual-minifigure-details-{{ item.fields.id }}">
{{ accordion.header('Quantity', 'accordion-quantity-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='functions') }}
{{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }}
{{ accordion.footer() }}
{{ accordion.table(item.generic_parts(), 'Parts', 'accordion-parts-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, 'part/table.html', icon='shapes-line', alt=item.fields.figure, hamburger_menu=g.login.is_authenticated()) }}
{% include 'individual_minifigure/management.html' %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Danger zone', 'accordion-danger-zone-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, danger=true, class='text-end') }}
<a href="{{ url_for('individual_minifigure.delete', id=item.fields.id) }}" class="btn btn-danger" role="button" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="ri-close-line"></i> Delete this individual minifigure instance</a>
{{ accordion.footer() }}
{% endif %}
</div>
<div class="card-footer"></div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this individual minifigure instance? This action cannot be undone.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('individual_minifigure.delete', id=item.fields.id) }}">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,67 @@
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/form.html' as form %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Management', 'accordion-management-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='settings-4-line', class='p-0') }}
{{ accordion.header('Owners', 'accordion-owners-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='group-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_owners | length %}
{% for owner in brickset_owners %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.id, owner.as_dataset(), owner.url_for_individual_minifigure_state(item.fields.id), item.fields[owner.as_column()]) }}</li>
{% endfor %}
{% else %}
<li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
{% endif %}
</ul>
<div class="list-group list-group-flush border-top">
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the minifigure owners</a>
</div>
{{ accordion.footer() }}
{{ accordion.header('Purchase', 'accordion-purchase-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='wallet-3-line') }}
<div class="alert alert-info" role="alert">The expected date format here is <code>yyyy/mm/dd</code> (year/month/day), but you can configured how it is displayed in the minifigure card with the <code>PURCHASE_DATE_FORMAT</code> variable.</div>
<div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
<div class="col-12">
{{ form.input('Date', item.fields.id, 'purchase_date', url_for('individual_minifigure.update_purchase_date', id=item.fields.id), item.fields.purchase_date or '', date=true, icon='calendar-line') }}
</div>
<div class="col-12 flex-grow-1">
{{ 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') }}
</div>
<div class="col-12 flex-grow-1">
{% if brickset_purchase_locations | length %}
{{ form.select('Location', item.fields.id, brickset_purchase_locations.as_prefix(), brickset_purchase_locations.url_for_individual_minifigure_value(item.fields.id), item.fields.purchase_location, brickset_purchase_locations, icon='building-line') }}
{% else %}
<i class="ri-error-warning-line"></i> No purchase location found.
{% endif %}
</div>
</div>
<hr>
<a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the minifigure purchase locations</a>
{{ accordion.footer() }}
{{ accordion.header('Notes', 'accordion-notes-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='sticky-note-line') }}
{{ form.textarea('Notes', item.fields.id, 'description', url_for('individual_minifigure.update_description', id=item.fields.id), item.fields.description, rows=4) }}
{{ accordion.footer() }}
{{ accordion.header('Storage', 'accordion-storage-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='archive-2-line') }}
{% if brickset_storages | length %}
{{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_individual_minifigure_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line') }}
{% else %}
<p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
{% endif %}
<hr>
<a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the minifigure storages</a>
{{ accordion.footer() }}
{{ accordion.header('Tags', 'accordion-tags-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='price-tag-2-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_tags | length %}
{% for tag in brickset_tags %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(tag.fields.name, item.fields.id, tag.as_dataset(), tag.url_for_individual_minifigure_state(item.fields.id), item.fields[tag.as_column()]) }}</li>
{% endfor %}
{% else %}
<li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No tag found.</li>
{% endif %}
</ul>
<div class="list-group list-group-flush border-top">
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the minifigure tags</a>
</div>
{{ accordion.footer() }}
{{ accordion.footer() }}
{% endif %}
+38
View File
@@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% block title %} - Individual Minifigures{% endblock %}
{% block main %}
<div class="container-fluid">
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2><i class="ri-user-line"></i> Individual Minifigures</h2>
<a href="{{ url_for('add.add') }}" class="btn btn-primary">
<i class="ri-add-line"></i> Add Minifigures
</a>
</div>
</div>
</div>
{% if minifigures | length %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
{% for item in minifigures %}
<div class="col">
{% include 'individual_minifigure/card.html' with context %}
</div>
{% endfor %}
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="alert alert-info" role="alert">
<i class="ri-information-line"></i> No individual minifigures found.
<a href="{{ url_for('add.add') }}" class="alert-link">Add some minifigures</a> to get started!
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
{% import 'macro/form.html' as form %}
<div class="card mb-3 flex-fill">
<div class="card-header">
<h5 class="mb-0">
<a href="{{ item.url() }}" class="text-decoration-none">
<i class="ri-hammer-line"></i>
{{ item.fields.name }} ({{ item.fields.color_name }})
</a>
</h5>
</div>
{{ card.image(item, solo=false, last=false, caption=item.fields.name, alt=item.fields.part, medium=false) }}
<div class="card-body border-bottom p-1">
{# Always show quantity first #}
{{ badge.quantity(item.fields.quantity, solo=false, last=false) }}
{# Render all other badges in configured order #}
{{ badge.render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=false, last=false, context='grid') }}
{# Description badge at end if exists #}
{% if item.fields.description %}
<span class="badge text-bg-light text-dark text-wrap" data-bs-toggle="tooltip" title="{{ item.fields.description }}">
<i class="ri-file-text-line"></i> {{ item.fields.description[:config.get('DESCRIPTION_BADGE_MAX_LENGTH', 50)] }}{% if item.fields.description | length > config.get('DESCRIPTION_BADGE_MAX_LENGTH', 50) %}...{% endif %}
</span>
{% endif %}
</div>
{% if g.login.is_authenticated() %}
<div class="card-footer p-1">
{{ form.input('Qty', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }}
</div>
{% endif %}
</div>
+98
View File
@@ -0,0 +1,98 @@
{% extends 'base.html' %}
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/badge.html' as badge %}
{% import 'macro/form.html' as form %}
{% block title %} - Individual Part {{ item.fields.name }}{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="ri-hammer-line"></i>
{{ badge.identifier(item.fields.part, icon='shapes-line', solo=true, header=true) }}
{{ badge.color(item, header=true) }}
{{ item.fields.name }}
</h5>
<div>
<a href="{{ url_for('part.details', part=item.fields.part, color=item.fields.color) }}" class="btn btn-sm btn-secondary">
<i class="ri-arrow-left-line"></i> Back to {{ item.fields.part }}
</a>
</div>
</div>
{% if item.fields.image %}
<div class="card-img border-bottom" style="background-image: url({{ item.url_for_image() }})">
<a data-lightbox data-caption="{{ item.fields.name }}" href="{{ item.url_for_image() }}" target="_blank">
<img class="card-medium-img" src="{{ item.url_for_image() }}" alt="{{ item.fields.part }}" loading="lazy">
</a>
</div>
{% endif %}
<div class="card-body border-bottom">
<div class="row g-2">
<div class="col-12 col-lg-4">
{{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }}
</div>
<div class="col-12 col-lg-4">
{{ form.input('Missing', item.fields.id, 'missing', item.url_for_problem('missing'), item.fields.missing, icon='question-line') }}
</div>
<div class="col-12 col-lg-4">
{{ form.input('Damaged', item.fields.id, 'damaged', item.url_for_problem('damaged'), item.fields.damaged, icon='error-warning-line') }}
</div>
</div>
</div>
<div class="accordion accordion-flush border-top" id="individual-part-details-{{ item.fields.id }}">
{% if lot %}
{{ accordion.header('Lot', 'accordion-lot-' ~ item.fields.id, 'individual-part-details-' ~ item.fields.id, icon='shopping-cart-line') }}
<div class="alert alert-info" role="alert">
<i class="ri-information-line"></i> This part belongs to a lot
{% if lot.fields.name %}<strong>"{{ lot.fields.name }}"</strong>{% endif %}
with {{ lot.parts()|length }} total parts.
</div>
<p class="mb-3">
All metadata (storage, purchase information, owners, tags) is managed at the lot level.
View the full lot to see all metadata and other parts.
</p>
<a href="{{ lot.url() }}" class="btn btn-primary">
<i class="ri-eye-line"></i> View Full Lot
</a>
{{ accordion.footer() }}
{% else %}
{# Only show management accordion if NOT part of a lot #}
{% include 'individual_part/management.html' %}
{% endif %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Danger zone', 'accordion-danger-zone-' ~ item.fields.id, 'individual-part-details-' ~ item.fields.id, danger=true, class='text-end') }}
<a href="{{ url_for('individual_part.delete_part', id=item.fields.id) }}" class="btn btn-danger" role="button" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="ri-close-line"></i> Delete this individual part instance</a>
{{ accordion.footer() }}
{% endif %}
</div>
<div class="card-footer"></div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this individual part instance? This action cannot be undone.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('individual_part.delete_part', id=item.fields.id) }}">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
{% import 'macro/form.html' as form %}
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="ri-shopping-cart-line"></i>
{% if item.fields.name %}
{{ item.fields.name }}
{% else %}
Individual Part Lot
{% endif %}
</h5>
{% if not solo %}
<a href="{{ item.url() }}" class="btn btn-sm btn-primary">
<i class="ri-eye-line"></i> View
</a>
{% else %}
<a href="{{ url_for('individual_part.list_lots') }}" class="btn btn-sm btn-secondary">
<i class="ri-arrow-left-line"></i> Back
</a>
{% endif %}
</div>
<!-- Part preview grid (2x2 grid of top 4 parts) -->
<div class="row g-0 border-bottom">
{% set lot_parts = item.parts() %}
{% for part in lot_parts[:4] %}
<div class="col-6">
<div class="{% if solo %}p-3{% else %}p-1{% endif %}" style="{% if solo %}height: 200px{% else %}height: 100px{% endif %}; background-image: url('{{ part.url_for_image() }}'); background-size: contain; background-repeat: no-repeat; background-position: center;">
<img src="{{ part.url_for_image() }}" alt="{{ part.fields.name }}" class="d-none" loading="lazy">
</div>
</div>
{% endfor %}
{% if lot_parts|length > 4 %}
<div class="col-12 text-center p-1 bg-light">
<small class="text-muted">+{{ lot_parts|length - 4 }} more parts</small>
</div>
{% endif %}
</div>
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
{# Always show unique parts count and total quantity first for part lots #}
<span class="badge text-bg-secondary fw-normal mb-1 {% if solo %}fs-6{% endif %}"><i class="ri-shapes-line"></i> {{ item.parts()|length }} unique parts</span>
{{ badge.quantity(item.total_quantity(), solo=solo, last=false) }}
{# Render remaining badges in configured order #}
{{ badge.render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=solo, last=false, context='detail' if solo else 'grid') }}
</div>
{% if solo %}
<div class="accordion accordion-flush border-top" id="lot-details">
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'lot-details', 'part/lot_table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated())}}
{% include 'individual_part/management.html' %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Danger zone', 'danger-zone', 'lot-details', danger=true, class='text-end') }}
<a href="{{ url_for('individual_part.delete_lot', lot_id=item.fields.id) }}" class="btn btn-danger" role="button" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="ri-delete-bin-line"></i> Delete entire lot and all parts
</a>
{{ accordion.footer() }}
{% endif %}
</div>
{% endif %}
<div class="card-footer"></div>
</div>
{% if solo and g.login.is_authenticated() %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this entire lot and all {{ item.parts()|length }} parts in it? This action cannot be undone.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('individual_part.delete_lot', lot_id=item.fields.id) }}">
<button type="submit" class="btn btn-danger">Delete Lot</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %} - {% if item.fields.name %}{{ item.fields.name }}{% else %}Individual Part Lot{% endif %}{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
{% with solo=true %}
{% include 'individual_part/lot_card.html' %}
{% endwith %}
</div>
</div>
</div>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends 'base.html' %}
{% block title %} - Individual Part Lots{% endblock %}
{% block main %}
<div class="container-fluid">
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2><i class="ri-shopping-cart-line"></i> Individual Part Lots</h2>
<a href="{{ url_for('add.parts') }}" class="btn btn-primary">
<i class="ri-add-line"></i> Add Parts
</a>
</div>
</div>
</div>
{% if lots | length %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
{% for item in lots %}
<div class="col">
{% include 'individual_part/lot_card.html' with context %}
</div>
{% endfor %}
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="alert alert-info" role="alert">
<i class="ri-information-line"></i> No lots found.
<a href="{{ url_for('add.parts') }}" class="alert-link">Add some parts</a> to get started!
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/form.html' as form %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Management', 'accordion-management-' ~ item.fields.id, 'individual-part-details-' ~ item.fields.id, icon='settings-4-line', class='p-0') }}
{% if item.__class__.__name__ == 'IndividualPartLot' %}
{{ accordion.header('Title', 'accordion-title-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='file-text-line') }}
<div class="alert alert-info" role="alert">Give this part lot a descriptive name to help identify it later. This is shown in the lot card header and list views.</div>
{{ form.input('', item.fields.id, 'lot-name-' ~ item.fields.id, url_for('individual_part.update_lot_name', lot_id=item.fields.id), item.fields.name) }}
{{ accordion.footer() }}
{% endif %}
{{ accordion.header('Owners', 'accordion-owners-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='group-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_owners | length %}
{% for owner in brickset_owners %}
{% if item.__class__.__name__ == 'IndividualPartLot' %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.id, owner.as_dataset(), url_for('individual_part.update_lot_owner', lot_id=item.fields.id, metadata_id=owner.fields.id), item.fields[owner.as_column()] | default(false)) }}</li>
{% else %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.id, owner.as_dataset(), owner.url_for_individual_part_state(item.fields.id), item.fields[owner.as_column()]) }}</li>
{% endif %}
{% endfor %}
{% else %}
<li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No owner found.</li>
{% endif %}
</ul>
<div class="list-group list-group-flush border-top">
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the part owners</a>
</div>
{{ accordion.footer() }}
{% if item.__class__.__name__ == 'IndividualPartLot' %}
{{ accordion.header('Purchase', 'accordion-purchase-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='wallet-3-line') }}
<div class="alert alert-info" role="alert">The expected date format here is <code>yyyy/mm/dd</code> (year/month/day), but you can configured how it is displayed in the part lot card with the <code>PURCHASE_DATE_FORMAT</code> variable.</div>
<div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
<div class="col-12">
{{ form.input('Date', item.fields.id, 'purchase_date', url_for('individual_part.update_lot_purchase_date', lot_id=item.fields.id), item.fields.purchase_date or '', date=true, icon='calendar-line') }}
</div>
<div class="col-12 flex-grow-1">
{{ 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') }}
</div>
<div class="col-12 flex-grow-1">
{% if brickset_purchase_locations | length %}
{{ form.select('Location', item.fields.id, brickset_purchase_locations.as_prefix(), url_for('individual_part.update_lot_purchase_location', lot_id=item.fields.id), item.fields.purchase_location, brickset_purchase_locations, icon='building-line') }}
{% else %}
<i class="ri-error-warning-line"></i> No purchase location found.
{% endif %}
</div>
</div>
<hr>
<a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the part lot purchase locations</a>
{{ accordion.footer() }}
{{ accordion.header('Notes', 'accordion-notes-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='sticky-note-line') }}
{{ form.textarea('', item.fields.id, 'lot-description-' ~ item.fields.id, url_for('individual_part.update_lot_description', lot_id=item.fields.id), item.fields.description, rows=4) }}
{{ accordion.footer() }}
{{ accordion.header('Storage', 'accordion-storage-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='archive-2-line') }}
{% if brickset_storages | length %}
{{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), url_for('individual_part.update_lot_storage', lot_id=item.fields.id), item.fields.storage, brickset_storages, icon='building-line') }}
{% else %}
<p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
{% endif %}
<hr>
<a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the part lot storages</a>
{{ accordion.footer() }}
{% else %}
{{ accordion.header('Storage', 'accordion-storage-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='archive-2-line') }}
{% if brickset_storages | length %}
{{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_individual_part_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line') }}
{% else %}
<p class="text-center"><i class="ri-error-warning-line"></i> No storage found.</p>
{% endif %}
<hr>
<a href="{{ url_for('admin.admin', open_storage=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the storages</a>
{{ accordion.footer() }}
{{ accordion.header('Purchase', 'accordion-purchase-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='wallet-3-line') }}
<div class="alert alert-info" role="alert">The expected date format here is <code>yyyy/mm/dd</code> (year/month/day), but you can configured how it is displayed in the part card with the <code>PURCHASE_DATE_FORMAT</code> variable.</div>
<div class="row row-cols-lg-auto g-1 justify-content-start align-items-center pb-2">
<div class="col-12">
{{ form.input('Date', item.fields.id, 'purchase_date', item.url_for_purchase_date(), item.fields.purchase_date or '', date=true, icon='calendar-line') }}
</div>
<div class="col-12 flex-grow-1">
{{ 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') }}
</div>
<div class="col-12 flex-grow-1">
{% if brickset_purchase_locations | length %}
{{ form.select('Location', item.fields.id, brickset_purchase_locations.as_prefix(), brickset_purchase_locations.url_for_individual_part_value(item.fields.id), item.fields.purchase_location, brickset_purchase_locations, icon='building-line') }}
{% else %}
<i class="ri-error-warning-line"></i> No purchase location found.
{% endif %}
</div>
</div>
<hr>
<a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the purchase locations</a>
{{ accordion.footer() }}
{{ accordion.header('Notes', 'accordion-notes-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='sticky-note-line') }}
{{ form.textarea('', item.fields.id, 'description', item.url_for_description(), item.fields.description or '', rows=4) }}
{{ accordion.footer() }}
{% endif %}
{% if item.__class__.__name__ != 'IndividualPartLot' %}
{{ accordion.header('Statuses', 'accordion-statuses-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='checkbox-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_statuses | length %}
{% for status in brickset_statuses %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(status.fields.name, item.fields.id, status.as_dataset(), status.url_for_individual_part_state(item.fields.id), item.fields[status.as_column()]) }}</li>
{% endfor %}
{% else %}
<li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No status found.</li>
{% endif %}
</ul>
<div class="list-group list-group-flush border-top">
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_status=true) }}"><i class="ri-settings-4-line"></i> Manage the statuses</a>
</div>
{{ accordion.footer() }}
{% endif %}
{{ accordion.header('Tags', 'accordion-tags-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='price-tag-2-line', class='p-0') }}
<ul class="list-group list-group-flush">
{% if brickset_tags | length %}
{% for tag in brickset_tags %}
{% if item.__class__.__name__ == 'IndividualPartLot' %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(tag.fields.name, item.fields.id, tag.as_dataset(), url_for('individual_part.update_lot_tag', lot_id=item.fields.id, metadata_id=tag.fields.id), item.fields[tag.as_column()] | default(false)) }}</li>
{% else %}
<li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(tag.fields.name, item.fields.id, tag.as_dataset(), tag.url_for_individual_part_state(item.fields.id), item.fields[tag.as_column()]) }}</li>
{% endif %}
{% endfor %}
{% else %}
<li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No tag found.</li>
{% endif %}
</ul>
<div class="list-group list-group-flush border-top">
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the tags</a>
</div>
{{ accordion.footer() }}
{{ accordion.footer() }}
{% endif %}
+38
View File
@@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% block title %} - Individual Parts{% endblock %}
{% block main %}
<div class="container-fluid">
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h2><i class="ri-hammer-line"></i> Individual Parts</h2>
<a href="{{ url_for('add.parts') }}" class="btn btn-primary">
<i class="ri-add-line"></i> Add Parts
</a>
</div>
</div>
</div>
{% if parts | length %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
{% for item in parts %}
<div class="col">
{% include 'individual_part/card.html' with context %}
</div>
{% endfor %}
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="alert alert-info" role="alert">
<i class="ri-information-line"></i> No individual parts found.
<a href="{{ url_for('add.parts') }}" class="alert-link">Add some parts</a> to get started!
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+1 -1
View File
@@ -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 %}
{% with solo=true, all=false, accordion_id=id, read_only=read_only, hamburger_menu=hamburger_menu %}
{% include target %}
{% endwith %}
{{ footer() }}
+43 -11
View File
@@ -230,27 +230,44 @@
{# Render each badge in the configured order #}
{% for badge_key in badge_order %}
{% if badge_key == 'theme' %}
{{ theme(item.theme.name, solo=solo, last=last) }}
{# Only sets have themes #}
{% if item.theme is defined %}
{{ theme(item.theme.name, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'tag' %}
{% for tag_item in brickset_tags %}
{{ tag(item, tag_item, solo=solo, last=last) }}
{% endfor %}
{% elif badge_key == 'year' %}
{% if not last %}
{# Only sets have years #}
{% if not last and item.fields.year is defined %}
{{ year(item.fields.year, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'parts' %}
{{ parts(item.fields.number_of_parts, solo=solo, last=last) }}
{# Only sets and minifigures have number_of_parts #}
{% if item.fields.number_of_parts is defined %}
{{ parts(item.fields.number_of_parts, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'instance_count' %}
{# Only consolidated sets have instance_count #}
{% if item.fields.instance_count is defined and item.fields.instance_count > 1 %}
<span class="badge bg-primary"><i class="ri-stack-line"></i> {{ item.fields.instance_count }} copies</span>
{% endif %}
{% elif badge_key == 'total_minifigures' %}
{{ total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
{# Only sets have total_minifigures #}
{% if item.fields.total_minifigures is defined %}
{{ total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'total_missing' %}
{{ total_missing(item.fields.total_missing, solo=solo, last=last) }}
{# Sets have total_missing, individual items have missing #}
{% if item.fields.total_missing is defined %}
{{ total_missing(item.fields.total_missing, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'total_damaged' %}
{{ total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
{# Sets have total_damaged, individual items have damaged #}
{% if item.fields.total_damaged is defined %}
{{ total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'owner' %}
{% for owner_item in brickset_owners %}
{{ owner(item, owner_item, solo=solo, last=last) }}
@@ -258,27 +275,42 @@
{% elif badge_key == 'storage' %}
{{ storage(item, brickset_storages, solo=solo, last=last) }}
{% elif badge_key == 'purchase_date' %}
{# Sets and part lots have purchase_date methods, individual parts/minifigures have raw fields #}
{% if not last %}
{{ purchase_date(item.purchase_date(), solo=solo, last=last, date_max_formatted=item.purchase_date_max_formatted()) }}
{% if item.purchase_date is defined and item.purchase_date is callable %}
{{ purchase_date(item.purchase_date(), solo=solo, last=last, date_max_formatted=item.purchase_date_max_formatted()) }}
{% elif item.purchase_date_formatted is defined and item.purchase_date_formatted is callable %}
{{ purchase_date(item.purchase_date_formatted(), solo=solo, last=last) }}
{% elif item.fields.purchase_date is defined and item.fields.purchase_date %}
{{ purchase_date(item.fields.purchase_date, solo=solo, last=last) }}
{% endif %}
{% endif %}
{% elif badge_key == 'purchase_location' %}
{% if not last %}
{{ purchase_location(item, brickset_purchase_locations, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'purchase_price' %}
{# Sets and part lots have purchase_price methods, individual parts/minifigures have raw fields #}
{% if not last %}
{{ purchase_price(item.purchase_price(), solo=solo, last=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 %}
{{ purchase_price(item.fields.purchase_price, solo=solo, last=last) }}
{% endif %}
{% endif %}
{% elif badge_key == 'instructions' %}
{% if not last and not solo %}
{# Only sets have instructions #}
{% if not last and not solo and item.instructions is defined %}
{{ instructions(item, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'rebrickable' %}
{% if not last %}
{# Only items with url_for_rebrickable method (sets, parts, minifigures) #}
{% if not last and item.url_for_rebrickable is defined %}
{{ rebrickable(item, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'bricklink' %}
{% if not last %}
{# Only items with url_for_bricklink method (sets, parts) #}
{% if not last and item.url_for_bricklink is defined %}
{{ bricklink(item, solo=solo, last=last) }}
{% endif %}
{% endif %}
+2 -1
View File
@@ -32,7 +32,8 @@
{% 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'] %}
{% if show_missing_menu or show_checked_menu %}
{% 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 data-table-no-sort-and-search="true" class="no-sort text-end" scope="col">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="hamburger-{{ accordion_id }}" data-bs-toggle="dropdown" aria-expanded="false">
+3
View File
@@ -22,6 +22,9 @@
{% if solo %}
<div class="accordion accordion-flush" id="minifigure-details">
{{ accordion.table(item.generic_parts(), 'Parts', item.fields.figure, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only=read_only)}}
{% if individual_instances is defined and individual_instances | length %}
{{ accordion.cards(individual_instances, 'Individual instances (not in sets)', 'individual-instances', 'minifigure-details', 'individual_minifigure/card.html', icon='user-star-line') }}
{% endif %}
{{ accordion.cards(using, 'Sets using this minifigure', 'using-inventory', 'minifigure-details', 'set/card.html', icon='hashtag') }}
{{ accordion.cards(missing, 'Sets missing parts for this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='question-line') }}
{{ accordion.cards(damaged, 'Sets with damaged parts for this minifigure', 'damaged-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }}
+5
View File
@@ -23,6 +23,11 @@
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
{% if not config.get('HIDE_INDIVIDUAL_MINIFIGURES', False) %}
<button class="btn {% if selected_individuals == 'only' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" type="button" id="individuals-filter-toggle" title="Show individual minifigures only">
<i class="ri-user-line"></i> Individuals
</button>
{% endif %}
</div>
</div>
</div>
+93 -1
View File
@@ -3,7 +3,26 @@
{% import 'macro/card.html' as card %}
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
{{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.part, icon='shapes-line') }}
<div class="card-header {% if solo %}d-flex justify-content-between align-items-center{% endif %}">
<h5 class="mb-0">
{{ badge.identifier(item.fields.part, icon='shapes-line', solo=solo, header=true) }}
{% if solo %}
{{ badge.color(item, header=true) }}
{{ badge.print(item, header=true) }}
{% endif %}
{{ item.fields.name }}
</h5>
{% if solo and g.login.is_authenticated() and not config.get('HIDE_INDIVIDUAL_PARTS', False) and not config.get('DISABLE_QUICK_ADD_INDIVIDUAL_PARTS', False) %}
<button class="btn btn-sm btn-primary quick-add-individual-part"
data-part="{{ item.fields.part }}"
data-color="{{ item.fields.color }}"
data-name="{{ item.fields.name }}"
data-color-name="{{ item.fields.color_name }}"
data-image="{{ item.url_for_image() }}">
<i class="ri-add-line"></i> Add to Inventory
</button>
{% endif %}
</div>
{{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.image_id, medium=true) }}
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
{% if not solo %}
@@ -29,9 +48,82 @@
{{ accordion.cards(minifigures_using, 'Minifigures using this part', 'minifigures-using-inventory', 'part-details', 'minifigure/card.html', icon='group-line') }}
{{ accordion.cards(minifigures_missing, 'Minifigures missing this part', 'minifigures-missing-inventory', 'part-details', 'minifigure/card.html', icon='question-line') }}
{{ accordion.cards(minifigures_damaged, 'Minifigures with this part damaged', 'minifigures-damaged-inventory', 'part-details', 'minifigure/card.html', icon='error-warning-line') }}
{{ accordion.cards(individual_parts, 'Individual part instances', 'individual-parts-inventory', 'part-details', 'individual_part/card.html', icon='box-3-line') }}
{{ accordion.cards(individual_lots, 'Lot instances', 'individual-lots-inventory', 'part-details', 'individual_part/lot_card.html', icon='shopping-cart-line') }}
{{ accordion.cards(different_color, 'Same part with a different color', 'different-color', 'part-details', 'part/card.html', icon='palette-line') }}
{{ accordion.cards(similar_prints, 'Prints using the same base', 'similar-prints', 'part-details', 'part/card.html', icon='paint-brush-line') }}
</div>
<div class="card-footer"></div>
{% endif %}
</div>
{% if solo and g.login.is_authenticated() and not config.get('HIDE_INDIVIDUAL_PARTS', False) and not config.get('DISABLE_QUICK_ADD_INDIVIDUAL_PARTS', False) %}
<!-- Quick Add Individual Part Modal -->
<div class="modal fade" id="quickAddIndividualPartModal" tabindex="-1" aria-labelledby="quickAddModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="quickAddModalLabel">Add to Individual Parts Inventory</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="quickAddForm">
<!-- Part info (read-only) -->
<div class="mb-3">
<label class="form-label fw-bold">Part</label>
<div class="d-flex align-items-center">
<img id="quickAddPartImage" src="" alt="" style="max-width: 100px; max-height: 100px;" class="me-3">
<div>
<div id="quickAddPartName" class="fw-bold"></div>
<div id="quickAddPartNumber" class="text-muted small"></div>
<div id="quickAddPartColor" class="text-muted small"></div>
</div>
</div>
</div>
<input type="hidden" id="quickAddPart" name="part">
<input type="hidden" id="quickAddColor" name="color">
<!-- Quantity -->
<div class="mb-3">
<label for="quickAddQuantity" class="form-label">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="quickAddQuantity" name="quantity" value="1" min="1" required>
</div>
<!-- Storage (optional) -->
<div class="mb-3">
<label for="quickAddStorage" class="form-label">Storage (optional)</label>
<select class="form-select" id="quickAddStorage" name="storage">
<option value="">-- No storage --</option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
<!-- Purchase Location (optional) -->
<div class="mb-3">
<label for="quickAddPurchaseLocation" class="form-label">Purchase Location (optional)</label>
<select class="form-select" id="quickAddPurchaseLocation" name="purchase_location">
<option value="">-- No purchase location --</option>
{% for location in brickset_purchase_locations %}
<option value="{{ location.fields.id }}">{{ location.fields.name }}</option>
{% endfor %}
</select>
</div>
<!-- Description/Notes (optional) -->
<div class="mb-3">
<label for="quickAddDescription" class="form-label">Notes (optional)</label>
<textarea class="form-control" id="quickAddDescription" name="description" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="quickAddSubmit">Add to Inventory</button>
</div>
</div>
</div>
</div>
{% endif %}
+45
View File
@@ -0,0 +1,45 @@
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
<div class="table-responsive-sm">
<table class="table table-striped align-middle sortable mb-0">
{{ table.header(color=true, quantity=true, sets=false, minifigures=false, checked=not read_only, hamburger_menu=not read_only, accordion_id=accordion_id|default('')) }}
<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 }}">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
</td>
<td data-sort="{{ item.fields.color_name }}">
{% 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>
{% 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) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.damaged }}" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.damaged, all=false, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
<td 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'] %}
{% if show_missing_menu or show_checked_menu %}
<td></td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
+23 -2
View File
@@ -49,8 +49,29 @@
{% 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'] %}
{% if show_missing_menu or show_checked_menu %}
<td></td>
{% 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 %}
<td class="text-end">
{% if show_quick_add %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="ri-more-2-fill"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item quick-add-individual-part" href="#"
data-part="{{ item.fields.part }}"
data-color="{{ item.fields.color }}"
data-name="{{ item.fields.name }}"
data-color-name="{{ item.fields.color_name }}"
data-image="{{ item.url_for_image() }}">
<i class="ri-add-line me-2"></i>Add to individual parts
</a>
</li>
</ul>
</div>
{% endif %}
</td>
{% endif %}
{% endif %}
{% endif %}
+5
View File
@@ -24,6 +24,11 @@
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
{% if not config.get('HIDE_INDIVIDUAL_PARTS', False) %}
<button class="btn {% if selected_individuals == 'only' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" type="button" id="individuals-filter-toggle" title="Show individual parts only">
<i class="ri-shapes-line"></i> Individuals
</button>
{% endif %}
</div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %} - Purchase Location {{ item.fields.name}}{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
{% with solo=true %}
{% include 'purchase_location/card.html' %}
{% endwith %}
</div>
</div>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% import 'macro/accordion.html' as accordion with context %}
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
{{ card.header(item, item.fields.name, solo=solo, icon='map-pin-line') }}
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
{{ badge.total_sets(sets | length, solo=solo, last=last) }}
{% if individual_minifigures | length %}
{{ badge.badge(check=individual_minifigures | length, solo=solo, last=last, color='info', icon='user-line', collapsible='Individual Minifigures:', text=individual_minifigures | length) }}
{% endif %}
{% if individual_parts | length %}
{{ badge.badge(check=individual_parts | length, solo=solo, last=last, color='success', icon='shapes-line', collapsible='Individual Parts:', text=individual_parts | length) }}
{% endif %}
{% if individual_part_lots | length %}
{{ badge.badge(check=individual_part_lots | length, solo=solo, last=last, color='warning', icon='hammer-line', collapsible='Part Lots:', text=individual_part_lots | length) }}
{% endif %}
</div>
{% if solo %}
<div class="accordion accordion-flush border-top" id="purchase-location-details">
{{ accordion.cards(sets, 'Sets', 'sets-purchased', 'purchase-location-details', 'set/card.html', expanded=true, icon='hashtag') }}
{% if individual_minifigures | length %}
{{ accordion.cards(individual_minifigures, 'Individual Minifigures', 'individual-minifigures-purchased', 'purchase-location-details', 'individual_minifigure/card.html', expanded=false, icon='user-line') }}
{% endif %}
{% if individual_parts | length %}
{{ accordion.cards(individual_parts, 'Individual Parts', 'individual-parts-purchased', 'purchase-location-details', 'individual_part/card.html', expanded=false, icon='shapes-line') }}
{% endif %}
{% if individual_part_lots | length %}
{{ accordion.cards(individual_part_lots, 'Individual Part Lots', 'individual-part-lots-purchased', 'purchase-location-details', 'individual_part/lot_card.html', expanded=false, icon='hammer-line') }}
{% endif %}
</div>
<div class="card-footer"></div>
{% endif %}
</div>
+16
View File
@@ -0,0 +1,16 @@
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle" id="purchase-locations">
{{ table.header(image=false, missing=false, damaged=false, sets=true) }}
<tbody>
{% for item in table_collection %}
<tr>
<td data-sort="{{ item.fields.name }}"><a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a></td>
<td>{{ item.fields.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %} - All purchase locations{% endblock %}
{% block main %}
<div class="container-fluid px-0">
{% with all=true %}
{% include 'purchase_location/table.html' %}
{% endwith %}
</div>
{% endblock %}
+71
View File
@@ -154,3 +154,74 @@
<div class="card-footer"></div>
{% endif %}
</div>
{% if solo and g.login.is_authenticated() %}
<!-- Quick Add Individual Part Modal -->
<div class="modal fade" id="quickAddIndividualPartModal" tabindex="-1" aria-labelledby="quickAddModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="quickAddModalLabel">Add to Individual Parts Inventory</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="quickAddForm">
<!-- Part info (read-only) -->
<div class="mb-3">
<label class="form-label fw-bold">Part</label>
<div class="d-flex align-items-center">
<img id="quickAddPartImage" src="" alt="" style="max-width: 100px; max-height: 100px;" class="me-3">
<div>
<div id="quickAddPartName" class="fw-bold"></div>
<div id="quickAddPartNumber" class="text-muted small"></div>
<div id="quickAddPartColor" class="text-muted small"></div>
</div>
</div>
</div>
<input type="hidden" id="quickAddPart" name="part">
<input type="hidden" id="quickAddColor" name="color">
<!-- Quantity -->
<div class="mb-3">
<label for="quickAddQuantity" class="form-label">Quantity <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="quickAddQuantity" name="quantity" value="1" min="1" required>
</div>
<!-- Storage (optional) -->
<div class="mb-3">
<label for="quickAddStorage" class="form-label">Storage (optional)</label>
<select class="form-select" id="quickAddStorage" name="storage">
<option value="">-- No storage --</option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
<!-- Purchase Location (optional) -->
<div class="mb-3">
<label for="quickAddPurchaseLocation" class="form-label">Purchase Location (optional)</label>
<select class="form-select" id="quickAddPurchaseLocation" name="purchase_location">
<option value="">-- No purchase location --</option>
{% for location in brickset_purchase_locations %}
<option value="{{ location.fields.id }}">{{ location.fields.name }}</option>
{% endfor %}
</select>
</div>
<!-- Description/Notes (optional) -->
<div class="mb-3">
<label for="quickAddDescription" class="form-label">Notes (optional)</label>
<textarea class="form-control" id="quickAddDescription" name="description" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="quickAddSubmit">Add to Inventory</button>
</div>
</div>
</div>
</div>
{% endif %}
+1
View File
@@ -11,6 +11,7 @@
LOAD_SET: '{{ messages['LOAD_SET'] }}',
PROGRESS: '{{ messages['PROGRESS'] }}',
SET_LOADED: '{{ messages['SET_LOADED'] }}',
MINIFIGURE_LOADED: '{{ messages['MINIFIGURE_LOADED'] }}',
},
{% if bulk %}true{% else %}false{% endif %},
{% if refresh %}true{% else %}false{% endif %}
+8 -8
View File
@@ -273,7 +273,7 @@
<div class="card-header">
<h5 class="card-title mb-0">
<a class="text-decoration-none text-dark" data-bs-toggle="collapse" href="#collapseStorage" role="button" aria-expanded="{{ 'true' if config['STATISTICS_DEFAULT_EXPANDED'] else 'false' }}" aria-controls="collapseStorage">
<i class="ri-archive-2-line"></i> Sets by Storage
<i class="ri-archive-2-line"></i> Storage
<i class="ri-arrow-down-s-line float-end"></i>
</a>
</h5>
@@ -286,8 +286,8 @@
<tr>
<th>Storage Location</th>
<th class="text-center">Sets</th>
<th class="text-center">Parts</th>
<th class="text-center">Minifigures</th>
<th class="text-center">Parts (lots, individual parts)</th>
<th class="text-center">Minifigures (individual minifigures)</th>
<th class="text-center">Value</th>
</tr>
</thead>
@@ -295,7 +295,7 @@
{% for storage in storage_statistics %}
<tr>
<td>
<a href="{{ url_for('set.list', storage=storage.storage_id) }}" class="text-decoration-none">
<a href="{{ url_for('storage.details', id=storage.storage_id) }}" class="text-decoration-none">
{{ storage.storage_name or 'Unknown' }}
</a>
</td>
@@ -303,10 +303,10 @@
<small class="text-dark">{{ storage.set_count }}</small>
</td>
<td class="text-center">
<small class="text-dark">{{ "{:,}".format(storage.total_parts) }}</small>
<small class="text-dark">{{ "{:,}".format(storage.total_parts) }}{% if storage.lot_count or storage.individual_part_count %} ({% if storage.lot_count %}{{ storage.lot_count }} lot{{ 's' if storage.lot_count != 1 else '' }}{% endif %}{% if storage.lot_count and storage.individual_part_count %}, {% endif %}{% if storage.individual_part_count %}{{ "{:,}".format(storage.individual_part_count) }} individual{% endif %}){% endif %}</small>
</td>
<td class="text-center">
<small class="text-dark">{{ storage.total_minifigures }}</small>
<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 %}
@@ -335,7 +335,7 @@
<div class="card-header">
<h5 class="card-title mb-0">
<a class="text-decoration-none text-dark" data-bs-toggle="collapse" href="#collapsePurchase" role="button" aria-expanded="{{ 'true' if config['STATISTICS_DEFAULT_EXPANDED'] else 'false' }}" aria-controls="collapsePurchase">
<i class="ri-shopping-cart-line"></i> Sets by Purchase Location
<i class="ri-shopping-cart-line"></i> Purchase Location
<i class="ri-arrow-down-s-line float-end"></i>
</a>
</h5>
@@ -359,7 +359,7 @@
{% for location in purchase_location_statistics %}
<tr>
<td>
<a href="{{ url_for('set.list', purchase_location=location.location_id) }}" class="text-decoration-none">
<a href="{{ url_for('purchase_location.details', id=location.location_id) }}" class="text-decoration-none">
{{ location.location_name or 'Unknown' }}
</a>
</td>
+18
View File
@@ -6,10 +6,28 @@
{{ card.header(item, item.fields.name, solo=solo, icon='archive-2-line') }}
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
{{ badge.total_sets(sets | length, solo=solo, last=last) }}
{% if individual_minifigures | length %}
{{ badge.badge(check=individual_minifigures | length, solo=solo, last=last, color='info', icon='user-line', collapsible='Individual Minifigures:', text=individual_minifigures | length) }}
{% endif %}
{% if individual_parts | length %}
{{ badge.badge(check=individual_parts | length, solo=solo, last=last, color='success', icon='shapes-line', collapsible='Individual Parts:', text=individual_parts | length) }}
{% endif %}
{% if individual_part_lots | length %}
{{ badge.badge(check=individual_part_lots | length, solo=solo, last=last, color='warning', icon='hammer-line', collapsible='Part Lots:', text=individual_part_lots | length) }}
{% endif %}
</div>
{% if solo %}
<div class="accordion accordion-flush border-top" id="storage-details">
{{ accordion.cards(sets, 'Sets', 'sets-stored', 'storage-details', 'set/card.html', expanded=true, icon='hashtag') }}
{% if individual_minifigures | length %}
{{ accordion.cards(individual_minifigures, 'Individual Minifigures', 'individual-minifigures-stored', 'storage-details', 'individual_minifigure/card.html', expanded=false, icon='user-line') }}
{% endif %}
{% if individual_parts | length %}
{{ accordion.cards(individual_parts, 'Individual Parts', 'individual-parts-stored', 'storage-details', 'individual_part/card.html', expanded=false, icon='shapes-line') }}
{% endif %}
{% if individual_part_lots | length %}
{{ accordion.cards(individual_part_lots, 'Individual Part Lots', 'individual-part-lots-stored', 'storage-details', 'individual_part/lot_card.html', expanded=false, icon='hammer-line') }}
{% endif %}
</div>
<div class="card-footer"></div>
{% endif %}