Editor Support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jason
2026-05-05 15:42:30 -05:00
parent 9f7c078ccc
commit 29154bd651
32 changed files with 3698 additions and 17 deletions
+3
View File
@@ -19,10 +19,12 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
from blueprints.auth import auth_bp
from blueprints.users import users_bp
from blueprints.products import products_bp
from blueprints.canvas import canvas_bp
app.register_blueprint(auth_bp)
app.register_blueprint(users_bp)
app.register_blueprint(products_bp)
app.register_blueprint(canvas_bp)
# Comment Change
# Home route
@@ -44,6 +46,7 @@ def home():
<p>Current Location: {session.get('currentLocation', 'Not selected')}</p>
<ul>
<li><a href="{url_for('products.product_finder')}">🔍 Product Finder Quiz</a></li>
<li><a href="{url_for('products.product_manager')}">📦 Manage Products</a></li>
<li><a href="{url_for('users.user_manager')}">👥 Manage Users</a></li>
<li><a href="{url_for('auth.select_location_page')}">📍 Change Location</a></li>
<li><a href="{url_for('auth.logout')}">🚪 Logout</a></li>
+216
View File
@@ -0,0 +1,216 @@
"""
Canvas Image API - Hierarchical Image Fallback System
This module provides intelligent image serving with fallback logic:
1. Product-specific images
2. Subtype-level fallbacks
3. Type-level fallbacks
4. Global fallbacks
URL Pattern: /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
"""
from flask import Blueprint, send_file, request, jsonify, current_app
import os
from pathlib import Path
import json
canvas_bp = Blueprint('canvas', __name__)
# Load product data to get type/subtype information
def get_product_data():
"""Load product data from JSON file"""
data_path = os.path.join(current_app.root_path, 'data', 'products.json')
try:
with open(data_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
current_app.logger.error(f"Error loading product data: {e}")
return []
def get_product_info(product_code):
"""Get product type, subtype, and other metadata"""
products = get_product_data()
for product in products:
if product.get('productCode') == product_code or product.get('id') == product_code:
base_type = product.get('baseType', '').lower()
sub_type = None
# Extract subtype
subtype_obj = product.get('subType', {})
if isinstance(subtype_obj, dict):
if subtype_obj.get('door'):
sub_type = subtype_obj['door'].lower().replace(' ', '-')
elif subtype_obj.get('window'):
sub_type = subtype_obj['window'].lower().replace(' ', '-')
return {
'code': product_code,
'type': base_type,
'subtype': sub_type,
'colors': product.get('colors', []),
'materials': product.get('materials', [])
}
return None
def find_image_with_fallback(product_code, layer, color=None, material=None):
"""
Find image using hierarchical fallback system.
Priority order:
1. /images/<type>/<subtype>/<code>/<layer>-<color>.<ext>
2. /images/<type>/<subtype>/<code>/<layer>.<ext>
3. /images/<type>/<subtype>/layers/<layer>-<color>.<ext>
4. /images/<type>/<subtype>/layers/<layer>.<ext>
5. /images/<type>/layers/<layer>.<ext>
6. /images/layers/<layer>.<ext>
Returns: (file_path, mime_type) or (None, None)
"""
product_info = get_product_info(product_code)
if not product_info:
current_app.logger.warning(f"Product not found: {product_code}")
return None, None
images_dir = os.path.join(current_app.root_path, 'static', 'images')
base_type = product_info['type']
sub_type = product_info['subtype']
# Supported extensions
extensions = ['png', 'jpg', 'jpeg', 'webp']
# Build search paths in priority order
search_paths = []
# 1. Product-specific with color
if color and base_type and sub_type:
for ext in extensions:
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}-{color.lower()}.{ext}"))
# 2. Product-specific without color
if base_type and sub_type:
for ext in extensions:
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}.{ext}"))
# 3. Subtype fallback with color
if color and base_type and sub_type:
for ext in extensions:
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}-{color.lower()}.{ext}"))
# 4. Subtype fallback without color
if base_type and sub_type:
for ext in extensions:
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}.{ext}"))
# 5. Type fallback
if base_type:
for ext in extensions:
search_paths.append(os.path.join(images_dir, base_type, 'layers', f"{layer}.{ext}"))
# 6. Global fallback
for ext in extensions:
search_paths.append(os.path.join(images_dir, 'layers', f"{layer}.{ext}"))
# Search for first existing file
for path in search_paths:
if os.path.exists(path):
# Determine MIME type
ext = path.split('.')[-1].lower()
mime_types = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'webp': 'image/webp'
}
mime_type = mime_types.get(ext, 'image/png')
current_app.logger.info(f"Found image: {path}")
return path, mime_type
current_app.logger.warning(f"No image found for {product_code}/{layer} (color: {color})")
return None, None
@canvas_bp.route('/api/canvas/<product_code>/<layer>')
def get_layer_image(product_code, layer):
"""
Serve a specific layer image for a product with hierarchical fallback.
Query parameters:
- color: Optional color variant
- material: Optional material (for future use)
Returns: Image file or 404
"""
color = request.args.get('color')
material = request.args.get('material')
# Validate layer name (prevent directory traversal)
valid_layers = ['base', 'door', 'hardware', 'overlay', 'foreground']
if layer not in valid_layers:
return jsonify({'error': 'Invalid layer name'}), 400
# Find image with fallback
image_path, mime_type = find_image_with_fallback(product_code, layer, color, material)
if image_path:
return send_file(image_path, mimetype=mime_type)
else:
# Return placeholder or 404
return jsonify({
'error': 'Image not found',
'product': product_code,
'layer': layer,
'color': color
}), 404
@canvas_bp.route('/api/canvas/<product_code>/info')
def get_canvas_info(product_code):
"""
Get information about available layers for a product.
Useful for frontend to know which layers exist.
"""
product_info = get_product_info(product_code)
if not product_info:
return jsonify({'error': 'Product not found'}), 404
# Check which layers are available
available_layers = {}
layers_to_check = ['base', 'door', 'hardware', 'overlay', 'foreground']
for layer in layers_to_check:
# Check if layer exists (with or without color)
path, _ = find_image_with_fallback(product_code, layer)
if path:
available_layers[layer] = True
# For door layer, check which colors are available
if layer == 'door' and product_info['colors']:
available_colors = []
for color in product_info['colors']:
color_path, _ = find_image_with_fallback(product_code, layer, color)
if color_path:
available_colors.append(color)
available_layers['door_colors'] = available_colors
return jsonify({
'product': product_code,
'type': product_info['type'],
'subtype': product_info['subtype'],
'availableLayers': available_layers,
'colors': product_info['colors'],
'materials': product_info['materials']
})
@canvas_bp.route('/api/canvas/test')
def test_canvas():
"""Test endpoint to verify canvas API is working"""
return jsonify({
'status': 'ok',
'message': 'Canvas API is running',
'endpoints': {
'get_layer': '/api/canvas/<product_code>/<layer>?color=<color>',
'get_info': '/api/canvas/<product_code>/info',
'test': '/api/canvas/test'
}
})
+37 -3
View File
@@ -1,9 +1,9 @@
"""
"""
Product Finder Blueprint
Handles the product quiz and recommendations
"""
from flask import Blueprint, render_template, send_from_directory
from blueprints.auth import login_required
from flask import Blueprint, render_template, send_from_directory, session, jsonify, redirect, url_for
from blueprints.auth import login_required, get_current_user, can_user
import os
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
@@ -29,6 +29,31 @@ def advanced_search():
"""Serve the advanced search page"""
return render_template('advanced_search.html')
@products_bp.route('/manage')
@login_required
def product_manager():
"""Serve the product management page"""
# Check if user has permission to manage products
if not can_user('manage_products'):
return redirect(url_for('products.product_finder'))
return render_template('product_manager.html')
@products_bp.route('/api/user-session')
@login_required
def get_user_session():
"""Return current user's session data including accessible locations"""
user = get_current_user()
permissions = user.get('permissions', {}) if user else {}
return jsonify({
'username': session.get('username'),
'userId': session.get('user_id'),
'currentLocation': session.get('currentLocation'),
'accessibleLocations': session.get('accessibleLocations', []),
'role': session.get('role', 'user'),
'permissions': permissions
})
@products_bp.route('/css/<path:filename>')
def serve_css(filename):
"""Serve CSS files"""
@@ -48,3 +73,12 @@ def serve_images(filename):
def serve_data(filename):
"""Serve data files (JSON)"""
return send_from_directory(os.path.join(basedir, 'data'), filename)
@products_bp.route('/product/<product_code>')
@login_required
def product_detail(product_code):
"""
Serve product detail page with URL-based navigation
This allows direct linking to products via /quiz/product/404
"""
return render_template('product_finder.html', initial_product=product_code)
+90
View File
@@ -0,0 +1,90 @@
"""
Create simple test placeholder images for Canvas API testing
"""
from PIL import Image, ImageDraw, ImageFont
import os
def create_placeholder_image(path, text, color, size=(800, 600)):
"""Create a simple colored placeholder image with text"""
# Create image with solid color
img = Image.new('RGB', size, color)
draw = ImageDraw.Draw(img)
# Add text
try:
# Try to use a default font
font = ImageFont.truetype("arial.ttf", 48)
except:
# Fallback to default font
font = ImageFont.load_default()
# Get text bounding box for centering
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
position = ((size[0] - text_width) // 2, (size[1] - text_height) // 2)
# Draw text with outline for visibility
outline_color = 'white' if sum(color) < 400 else 'black'
for offset_x in [-2, 0, 2]:
for offset_y in [-2, 0, 2]:
draw.text((position[0] + offset_x, position[1] + offset_y),
text, font=font, fill=outline_color)
text_color = 'black' if sum(color) > 400 else 'white'
draw.text(position, text, font=font, fill=text_color)
# Save image
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
print(f"✓ Created: {path}")
# Base directory
base_dir = 'static/images'
# Create product-specific images for 404
create_placeholder_image(
f'{base_dir}/window/storm-window/404/door-white.png',
'404\nDoor - White',
(240, 240, 240) # Light gray
)
create_placeholder_image(
f'{base_dir}/window/storm-window/404/door-black.png',
'404\nDoor - Black',
(40, 40, 40) # Dark gray
)
# Create shared images for storm windows
create_placeholder_image(
f'{base_dir}/window/storm-window/layers/base.jpg',
'Storm Window\nBase Layer\n(Shared)',
(200, 220, 240) # Light blue
)
create_placeholder_image(
f'{base_dir}/window/storm-window/layers/hardware.png',
'Storm Window\nHardware\n(Shared)',
(180, 180, 180) # Silver
)
create_placeholder_image(
f'{base_dir}/window/storm-window/layers/foreground.png',
'Storm Window\nForeground\n(Shared Plants)',
(100, 180, 100) # Green
)
# Create global fallback images
create_placeholder_image(
f'{base_dir}/layers/base.jpg',
'Global\nBase Layer\n(Fallback)',
(220, 200, 180) # Beige
)
print("\n✅ All test images created successfully!")
print("\nNow test the Canvas API:")
print(" http://localhost:8080/api/canvas/404/base")
print(" http://localhost:8080/api/canvas/404/door?color=white")
print(" http://localhost:8080/api/canvas/404/hardware")
print(" http://localhost:8080/api/canvas/404/foreground")
+240
View File
@@ -0,0 +1,240 @@
"""
Create visual test images for the Canvas API layered system.
Demonstrates base layer, door layer (with colors), hardware layer, and foreground layer.
"""
from PIL import Image, ImageDraw, ImageFont
import os
# Canvas dimensions - all layers use the same size
CANVAS_WIDTH = 800
CANVAS_HEIGHT = 600
# Output directory
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'static', 'images')
def create_base_layer():
"""Create base layer - a simple house background"""
img = Image.new('RGB', (CANVAS_WIDTH, CANVAS_HEIGHT), color='#87CEEB') # Sky blue
draw = ImageDraw.Draw(img)
# Draw grass at bottom
draw.rectangle([(0, 450), (CANVAS_WIDTH, CANVAS_HEIGHT)], fill='#90EE90') # Light green
# Draw house body (tan/beige)
house_left = 150
house_right = 650
house_top = 200
house_bottom = 500
draw.rectangle([(house_left, house_top), (house_right, house_bottom)], fill='#D2B48C') # Tan
# Draw roof (dark brown triangle)
roof_points = [
(400, 100), # Top point
(house_left - 30, house_top), # Left bottom
(house_right + 30, house_top) # Right bottom
]
draw.polygon(roof_points, fill='#654321') # Dark brown
# Draw window openings (lighter rectangles where door/window will go)
# Door opening in center
door_left = 325
door_right = 475
door_top = 280
door_bottom = 480
draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill='#C9B896') # Lighter tan
# Add some house details (window on left side)
draw.rectangle([(200, 280), (280, 360)], fill='#87CEEB') # Window
draw.rectangle([(238, 280), (242, 360)], fill='#654321') # Window divider vertical
draw.rectangle([(200, 318), (280, 322)], fill='#654321') # Window divider horizontal
# Window on right side
draw.rectangle([(520, 280), (600, 360)], fill='#87CEEB') # Window
draw.rectangle([(558, 280), (562, 360)], fill='#654321') # Window divider vertical
draw.rectangle([(520, 318), (600, 322)], fill='#654321') # Window divider horizontal
return img
def create_door_layer(color_name, color_hex):
"""Create door layer with transparency - colored rectangle positioned in door opening"""
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
draw = ImageDraw.Draw(img)
# Door dimensions - positioned where the door opening is on the house
door_left = 330
door_right = 470
door_top = 285
door_bottom = 475
# Draw door
draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill=color_hex)
# Draw door panels (decorative)
panel_margin = 15
panel_gap = 10
# Top panel
draw.rectangle([
(door_left + panel_margin, door_top + panel_margin),
(door_right - panel_margin, door_top + panel_margin + 80)
], outline='#000000', width=3)
# Bottom panel
draw.rectangle([
(door_left + panel_margin, door_top + panel_margin + 80 + panel_gap),
(door_right - panel_margin, door_bottom - panel_margin)
], outline='#000000', width=3)
# Door handle position marker (small circle to show where handle will go)
# On the right side for left-hinge door
handle_x = door_right - 40
handle_y = door_top + (door_bottom - door_top) // 2
draw.ellipse([
(handle_x - 8, handle_y - 8),
(handle_x + 8, handle_y + 8)
], fill='#000000')
return img
def create_hardware_layer():
"""Create hardware layer with transparency - gold circle for door handle"""
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
draw = ImageDraw.Draw(img)
# Door handle position - matches the marker on the door
# Right side for left-hinge door
handle_x = 430 # door_right (470) - 40
handle_y = 380 # middle of door
# Draw decorative backplate (bronze/gold rectangle)
plate_width = 30
plate_height = 80
draw.rectangle([
(handle_x - plate_width//2, handle_y - plate_height//2),
(handle_x + plate_width//2, handle_y + plate_height//2)
], fill='#B8860B') # Dark goldenrod
# Draw handle (gold circle)
handle_radius = 20
draw.ellipse([
(handle_x - handle_radius, handle_y - handle_radius),
(handle_x + handle_radius, handle_y + handle_radius)
], fill='#FFD700') # Gold
# Add shine/highlight
highlight_offset = 6
draw.ellipse([
(handle_x - highlight_offset, handle_y - highlight_offset),
(handle_x - highlight_offset + 8, handle_y - highlight_offset + 8)
], fill='#FFFFE0') # Light yellow highlight
return img
def create_foreground_layer():
"""Create foreground layer with transparency - plants at bottom"""
img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
draw = ImageDraw.Draw(img)
# Draw plants/bushes at the bottom corners
# Left plant
left_plant_x = 120
plant_y = 480
for i in range(3):
offset_x = i * 25
offset_y = i * 10
# Draw leaves (green circles)
draw.ellipse([
(left_plant_x + offset_x - 30, plant_y + offset_y - 30),
(left_plant_x + offset_x + 30, plant_y + offset_y + 30)
], fill='#228B22') # Forest green
# Right plant
right_plant_x = 680
for i in range(3):
offset_x = -i * 25
offset_y = i * 10
# Draw leaves (green circles)
draw.ellipse([
(right_plant_x + offset_x - 30, plant_y + offset_y - 30),
(right_plant_x + offset_x + 30, plant_y + offset_y + 30)
], fill='#228B22') # Forest green
# Add some decorative flowers
flower_positions = [
(100, 520), (140, 510), (160, 530), # Left side
(640, 530), (660, 510), (700, 520) # Right side
]
for fx, fy in flower_positions:
# Flower petals (pink circles)
petal_radius = 8
for angle in range(0, 360, 72): # 5 petals
import math
px = fx + int(12 * math.cos(math.radians(angle)))
py = fy + int(12 * math.sin(math.radians(angle)))
draw.ellipse([
(px - petal_radius, py - petal_radius),
(px + petal_radius, py + petal_radius)
], fill='#FFB6C1') # Light pink
# Flower center (yellow circle)
center_radius = 6
draw.ellipse([
(fx - center_radius, fy - center_radius),
(fx + center_radius, fy + center_radius)
], fill='#FFD700') # Gold
return img
def main():
print("Creating visual test images for Canvas API...")
# Define paths
base_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', '404')
layers_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', 'layers')
# Create directories
os.makedirs(base_path, exist_ok=True)
os.makedirs(layers_path, exist_ok=True)
# Create base layer
print("Creating base layer (house)...")
base_img = create_base_layer()
base_img.save(os.path.join(layers_path, 'base.jpg'), quality=90)
print(f" ✓ Saved: {layers_path}/base.jpg")
# Create door layers in various colors
door_colors = {
'white': '#FFFFFF',
'black': '#2C2C2C',
'bronze': '#8B6914',
'tan': '#D2B48C'
}
print("\nCreating door layers...")
for color_name, color_hex in door_colors.items():
door_img = create_door_layer(color_name, color_hex)
door_img.save(os.path.join(base_path, f'door-{color_name}.png'))
print(f" ✓ Saved: {base_path}/door-{color_name}.png")
# Create hardware layer
print("\nCreating hardware layer (gold handle)...")
hardware_img = create_hardware_layer()
hardware_img.save(os.path.join(layers_path, 'hardware.png'))
print(f" ✓ Saved: {layers_path}/hardware.png")
# Create foreground layer
print("\nCreating foreground layer (plants)...")
foreground_img = create_foreground_layer()
foreground_img.save(os.path.join(layers_path, 'foreground.png'))
print(f" ✓ Saved: {layers_path}/foreground.png")
print("\n✅ All images created successfully!")
print("\nTest URLs:")
print(" Left-hinge: http://localhost:8080/quiz/product/404")
print(" Right-hinge: http://localhost:8080/quiz/product/404R")
print("\nThe right-hinge version will flip the door and hardware layers horizontally.")
if __name__ == '__main__':
main()
+215
View File
@@ -0,0 +1,215 @@
{
"locations": [
{
"code": "IOLA",
"name": "Iola"
},
{
"code": "LINDS",
"name": "Lindsborg"
},
{
"code": "KC",
"name": "Kansas City"
},
{
"code": "BMD",
"name": "BMD"
}
],
"statuses": [
{
"value": "default",
"label": "Default"
},
{
"value": "discontinued",
"label": "Discontinued"
},
{
"value": "hidden",
"label": "Hide"
}
],
"productTypes": [
{
"id": "storm_door",
"type": "Storm Door",
"category": "STORMS",
"baseType": "Door",
"subType": "Storm Door",
"availableMaterials": ["Aluminum", "Steel"],
"availableColors": ["White", "Bronze", "Black", "Sandstone"],
"additionalOptions": []
},
{
"id": "storm_window",
"type": "Storm Window",
"category": "STORMS",
"baseType": "Window",
"subType": "Storm Window",
"availableMaterials": ["Aluminum"],
"availableColors": ["White", "Bronze", "Black", "Sandstone"],
"additionalOptions": []
},
{
"id": "primary_window",
"type": "Primary Window",
"category": "SHPT",
"baseType": "Window",
"subType": "Primary Window",
"availableMaterials": ["Vinyl", "Aluminum"],
"availableColors": ["White", "Bronze", "Mill"],
"additionalOptions": []
},
{
"id": "sliding_window",
"type": "Sliding Window",
"category": "SHPW",
"baseType": "Window",
"subType": "Sliding Window",
"availableMaterials": ["Vinyl", "Aluminum"],
"availableColors": ["White", "Bronze", "Mill"],
"additionalOptions": []
},
{
"id": "fixed_lite",
"type": "Fixed Lite",
"category": "FPPW",
"baseType": "Window",
"subType": "Fixed Lite",
"availableMaterials": ["Vinyl", "Aluminum"],
"availableColors": ["White", "Bronze", "Mill"],
"additionalOptions": []
},
{
"id": "casement",
"type": "Casement",
"category": "CASEMNT",
"baseType": "Window",
"subType": "Casement",
"availableMaterials": ["Aluminum"],
"availableColors": ["White", "Bronze"],
"additionalOptions": ["1-Panel", "2-Panel", "3-Panel"]
},
{
"id": "double_hung",
"type": "Double Hung",
"category": "3000DHP",
"baseType": "Window",
"subType": "Double Hung",
"availableMaterials": ["Aluminum"],
"availableColors": ["White", "Bronze"],
"additionalOptions": ["Thermal Break", "Standard"]
},
{
"id": "screen_door",
"type": "Screen Door",
"category": "SCREENS",
"baseType": "Door",
"subType": "Screen Door",
"availableMaterials": ["Aluminum"],
"availableColors": ["White", "Bronze", "Black"],
"additionalOptions": ["Self-Storing", "Fixed"]
}
],
"allMaterials": [
"Aluminum",
"Vinyl",
"Steel",
"Wood",
"Fiberglass"
],
"allColors": [
"White",
"Black",
"Bronze",
"Sandstone",
"Mill",
"Almond",
"Clay",
"Gray"
],
"categories": [
{
"code": "STORMS",
"label": "Storm Products"
},
{
"code": "SHPW",
"label": "Single Hung Primary Windows"
},
{
"code": "SHPT",
"label": "Single Hung Primary Tilt"
},
{
"code": "FPPW",
"label": "Fixed Picture Primary Windows"
},
{
"code": "CASEMNT",
"label": "Casement Windows"
},
{
"code": "3000DHP",
"label": "3000 Series Double Hung Primary"
},
{
"code": "3000FPP",
"label": "3000 Series Fixed Picture Primary"
},
{
"code": "SCREENS",
"label": "Screen Doors"
}
],
"codeModifiers": {
"prefixes": [
{
"id": "replacement",
"label": "Replacement (R-)",
"value": "R",
"appliesTo": ["Window"],
"description": "For replacement windows"
},
{
"id": "new_construction",
"label": "New Construction (N-)",
"value": "N",
"appliesTo": ["Window"],
"description": "For new construction windows"
}
],
"suffixes": [
{
"id": "kickplate",
"label": "Kick Plate (KP)",
"value": "KP",
"appliesTo": ["Door"],
"description": "Add kick plate to door"
},
{
"id": "vent",
"label": "Vent (V)",
"value": "V",
"appliesTo": ["Door"],
"description": "Add vent to door"
},
{
"id": "screen",
"label": "Screen (SCR)",
"value": "SCR",
"appliesTo": ["Door", "Window"],
"description": "Include screen"
},
{
"id": "tempered",
"label": "Tempered Glass (T)",
"value": "T",
"appliesTo": ["Door", "Window"],
"description": "Tempered glass option"
}
]
}
}
+35 -1
View File
@@ -40,7 +40,41 @@
"Sandstone"
],
"isAccessory": false,
"compatibleAccessories": []
"compatibleAccessories": [],
"imageConfig": {
"useCanvasAPI": true
}
},
{
"id": "404R",
"productCode": "404R",
"category": "STORMS",
"description": "#404 FALCON STORM WINDOWS (RIGHT-HINGE)",
"discontinued": false,
"location": "Iola",
"baseType": "Window",
"subType": {
"door": null,
"window": "Storm Window"
},
"materials": [
"Aluminum"
],
"colors": [
"Black",
"White",
"Bronze",
"Sandstone"
],
"isAccessory": false,
"compatibleAccessories": [],
"imageConfig": {
"useCanvasAPI": true,
"layerTransforms": {
"door": "flip-horizontal",
"hardware": "flip-horizontal"
}
}
},
{
"id": "450",
+3 -1
View File
@@ -4,7 +4,8 @@
"password": "pbkdf2:sha256:1000000$tPRmseRGKLveXTxS$87eda9a20db29d12838fcb607f1f3536141f743ec9879702dc2a0dd893b4078d",
"defaultLocation": "KC",
"permissions": {
"manage_users": true
"manage_users": true,
"manage_products": true
},
"locationSettings": {
"LINDS": {
@@ -39,6 +40,7 @@
},
"permissions": {
"manage_users": true,
"manage_products": true,
"create_quotes": true
},
"active": true
+18
View File
@@ -300,6 +300,24 @@ body {
z-index: 4;
}
.layer-foreground {
z-index: 5;
pointer-events: none; /* Allow clicks to pass through to controls below */
}
/* Layer transform modifiers */
.layer-flip-horizontal {
transform: scaleX(-1);
}
.layer-flip-vertical {
transform: scaleY(-1);
}
.layer-flip-both {
transform: scale(-1, -1);
}
.config-preview-notice {
position: absolute;
bottom: 10px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+144 -11
View File
@@ -106,7 +106,13 @@ let history = ['start'];
function initFromURL() {
const params = new URLSearchParams(window.location.search);
// Priority 1: Direct product view
// Priority 0: Direct product from Flask route (e.g., /quiz/product/404)
if (window.INITIAL_PRODUCT && window.INITIAL_PRODUCT !== '') {
showProductByCode(window.INITIAL_PRODUCT);
return;
}
// Priority 1: Direct product view from query param
const productCode = params.get('p');
if (productCode) {
accumulatedBitValue = parseInt(params.get('b') || '0', 10);
@@ -705,6 +711,7 @@ function showProductDetail(product) {
// Get product image configuration
const productImage = product.image || `https://via.placeholder.com/400x400/e0e0e0/666?text=${encodeURIComponent(prodCode)}`;
const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
const hasLayeredImages = product.imageConfig && product.imageConfig.layered === true;
const hasFlatImages = product.flatImages === true || product.imagePattern;
const useDynamicAPI = product.useDynamicAPI === true;
@@ -795,20 +802,21 @@ function showProductDetail(product) {
const contentDiv = document.getElementById('content');
// Build the image display (layered, flat, or dynamic API)
// Build the image display (Canvas API, layered, flat, or dynamic API)
let imageDisplayHtml = '';
if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
// Use layered image system
const basePath = product.imageConfig.basePath || '';
const layers = product.imageConfig.layers || {};
if (useCanvasAPI || (hasLayeredImages && !hasFlatImages && !useDynamicAPI)) {
// Use layered image system (Canvas API or static paths)
const basePath = product.imageConfig?.basePath || '';
const layers = product.imageConfig?.layers || {};
imageDisplayHtml = `
<div class="door-configurator" id="door-configurator">
${layers.base ? `<img src="${basePath}${layers.base}" class="layer-base" alt="Base">` : ''}
<img src="" class="layer-base" alt="Base">
<img src="" class="layer-door" id="door-layer" alt="Door" style="display:none;">
${layers.hardware ? `<img src="${basePath}${layers.hardware}" class="layer-hardware" id="hardware-layer" alt="Hardware">` : ''}
<img src="" class="layer-hardware" id="hardware-layer" alt="Hardware" style="display:none;">
<img src="" class="layer-overlay" id="overlay-layer" alt="View" style="display:none;">
<img src="" class="layer-foreground" alt="Foreground" style="display:none;">
<div class="config-preview-notice" id="config-notice" style="display:none;">
⚠️ Preview not available for this configuration
</div>
@@ -958,7 +966,11 @@ function showProductDetail(product) {
updateBreadcrumb();
// Initialize images based on type
if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
if (useCanvasAPI) {
// Use Canvas API with hierarchical fallback
updateCanvasAPIPreview(prodCode);
} else if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
// Use static layered images
updateProductPreview(prodCode);
} else if (hasFlatImages || useDynamicAPI) {
// Flat images or dynamic API - will update on dropdown change
@@ -969,18 +981,138 @@ function showProductDetail(product) {
}
}
// ============================================================================
// Canvas API Functions - Hierarchical Image Fallback System
// ============================================================================
/**
* Build Canvas API URL for a specific layer
* @param {string} productCode - Product code
* @param {string} layer - Layer name (base, door, hardware, overlay, foreground)
* @param {string} color - Optional color
* @param {string} material - Optional material
* @returns {string} Canvas API URL
*/
function buildCanvasAPIUrl(productCode, layer, color = null, material = null) {
let url = `${BASE_URL}/api/canvas/${productCode}/${layer}`;
const params = new URLSearchParams();
if (color) params.append('color', color.toLowerCase());
if (material) params.append('material', material.toLowerCase());
const queryString = params.toString();
return queryString ? `${url}?${queryString}` : url;
}
/**
* Update layered image preview using Canvas API
* @param {string} productCode - Product code
*/
function updateCanvasAPIPreview(productCode) {
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
if (!product) return;
// Get selected values
const selectedColor = document.getElementById('config-color')?.value || '';
const selectedMaterial = document.getElementById('config-material')?.value || '';
// Get layer transform configuration (for flipping, etc.)
const layerTransforms = product.imageConfig?.layerTransforms || {};
// Helper function to apply transform classes
function applyTransform(element, layerName) {
if (!element) return;
// Remove any existing transform classes
element.classList.remove('layer-flip-horizontal', 'layer-flip-vertical', 'layer-flip-both');
// Apply new transform if configured
const transform = layerTransforms[layerName];
if (transform) {
element.classList.add(`layer-${transform}`);
}
}
// Update each layer
const baseLayer = document.querySelector('.layer-base');
const doorLayer = document.getElementById('door-layer');
const hardwareLayer = document.getElementById('hardware-layer');
const overlayLayer = document.getElementById('overlay-layer');
const foregroundLayer = document.querySelector('.layer-foreground');
// Base layer (no color variation)
if (baseLayer && baseLayer.tagName === 'IMG') {
baseLayer.src = buildCanvasAPIUrl(productCode, 'base');
applyTransform(baseLayer, 'base');
baseLayer.onerror = function() {
console.warn('Base layer not found via Canvas API');
};
}
// Door layer (with color)
if (doorLayer) {
applyTransform(doorLayer, 'door');
if (selectedColor) {
doorLayer.src = buildCanvasAPIUrl(productCode, 'door', selectedColor);
doorLayer.style.display = 'block';
doorLayer.onerror = function() {
console.warn(`Door layer not found for color: ${selectedColor}`);
this.style.display = 'none';
showConfigNotice(true);
};
doorLayer.onload = function() {
showConfigNotice(false);
};
} else {
doorLayer.style.display = 'none';
}
}
// Hardware layer
if (hardwareLayer) {
hardwareLayer.src = buildCanvasAPIUrl(productCode, 'hardware');
applyTransform(hardwareLayer, 'hardware');
hardwareLayer.onerror = function() {
console.warn('Hardware layer not found via Canvas API');
this.style.display = 'none';
};
hardwareLayer.onload = function() {
this.style.display = 'block';
};
}
// Foreground layer
if (foregroundLayer && foregroundLayer.tagName === 'IMG') {
foregroundLayer.src = buildCanvasAPIUrl(productCode, 'foreground');
applyTransform(foregroundLayer, 'foreground');
foregroundLayer.onerror = function() {
console.warn('Foreground layer not found via Canvas API');
// This is fine - foreground is optional
};
}
}
// ============================================================================
// Update product preview based on configuration
// ============================================================================
function updateProductPreview(productCode) {
console.log('updateProductPreview called with:', productCode);
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
if (!product) return;
// Check which system to use
// Check which system to use - Canvas API has priority
const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
const hasFlatImages = product.flatImages === true || product.imagePattern;
const useDynamicAPI = product.useDynamicAPI === true;
console.log('Product flags - hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
console.log('Product flags - useCanvasAPI:', useCanvasAPI, 'hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
// Priority: Canvas API > Flat Images > Dynamic API > Layered Static
if (useCanvasAPI) {
// Use Canvas API with hierarchical fallback
updateCanvasAPIPreview(productCode);
return;
}
if (hasFlatImages) {
// Use pre-rendered flat images
@@ -1810,6 +1942,7 @@ startOver = function() {
// Initialize search bar when page loads
window.addEventListener('DOMContentLoaded', function() {
init(); // Load quiz data and initialize
initSearchBar();
toggleSearchBar();
});
+11 -1
View File
@@ -77,8 +77,16 @@
locationDisplay = `📍 ${currentLoc}`;
}
let manageProductsLink = '';
if (data.permissions && data.permissions.manage_products) {
manageProductsLink = ` | <a href="{{ url_for('products.product_manager') }}" style="color: #6a4c93; text-decoration: none; font-weight: 500; cursor: pointer;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'"
title="Manage Products">Manage Products</a>`;
}
document.getElementById('userLocationInfo').innerHTML =
`👤 ${data.username} | ${locationDisplay}`;
`👤 ${data.username} | ${locationDisplay}${manageProductsLink}`;
}
} catch (error) {
console.error('Error loading user info:', error);
@@ -93,6 +101,8 @@
window.APP_BASE_URL = '';
// Set data path for the quiz
window.DATA_PATH = '{{ url_for("products.serve_data", filename="") }}';
// Set initial product if provided via URL
window.INITIAL_PRODUCT = '{{ initial_product or "" }}';
</script>
<script src="{{ url_for('products.serve_js', filename='script.js') }}"></script>
</body>
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Product Manager - CGW</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 40px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-section {
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 1px solid #e0e0e0;
}
.form-section:last-child {
border-bottom: none;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
font-size: 14px;
}
label .optional {
color: #999;
font-weight: normal;
font-size: 12px;
}
input[type="text"],
select,
textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.3s;
}
input[type="text"]:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
}
.note {
background: #fff9e6;
border-left: 4px solid #ffd700;
padding: 10px 15px;
margin-bottom: 15px;
font-size: 13px;
color: #666;
}
.product-codes-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
margin-top: 15px;
}
.type-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 10px;
}
.radio-option {
display: flex;
align-items: center;
gap: 5px;
}
.radio-option input[type="radio"] {
width: auto;
cursor: pointer;
}
.radio-option label {
margin: 0;
cursor: pointer;
font-weight: normal;
}
.dynamic-section {
background: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-top: 10px;
}
.dynamic-section.hidden {
display: none;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 30px;
}
button {
padding: 12px 30px;
border: none;
border-radius: 5px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-danger {
background: #ff4757;
color: white;
}
.btn-danger:hover {
background: #e84052;
}
.nav-links {
margin-bottom: 20px;
}
.nav-links a {
color: #667eea;
text-decoration: none;
margin-right: 15px;
font-size: 14px;
}
.nav-links a:hover {
text-decoration: underline;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
}
.status-default {
background: #4caf50;
}
.status-discontinued {
background: #ff4757;
}
.status-hidden {
background: #ffa502;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
.type-group {
grid-template-columns: 1fr;
}
.product-codes-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.product-codes-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="/">← Back to Home</a>
<a href="/quiz/">Product Finder</a>
</div>
<h1>Product Manager</h1>
<p class="subtitle">Add or edit product information</p>
<form id="productForm">
<!-- Product Location & Description -->
<div class="form-section">
<div class="form-group">
<label for="location">Product Location:</label>
<select id="location" name="location" required>
<option value="">Select location...</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Enter product description"></textarea>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<option value="default">Default</option>
<option value="discontinued">Discontinued</option>
<option value="hidden">Hide</option>
</select>
</div>
</div>
<!-- Product Codes -->
<div class="form-section">
<div class="section-title">Product Codes</div>
<div class="form-group">
<label for="standardCode">Standardized Code <span class="optional">(auto-generated from selections below)</span></label>
<input type="text" id="standardCode" name="standardCode" placeholder="Select type and modifiers to generate" readonly style="background-color: #f5f5f5;">
</div>
<div class="form-group">
<label for="baseCode">Base Code <span class="optional">(optional - e.g., 2000, 3000)</span></label>
<input type="text" id="baseCode" name="baseCode" placeholder="Enter base product code">
</div>
<div class="product-codes-grid" id="locationCodesContainer">
<!-- Dynamic location code fields will be inserted here based on user's accessible locations -->
</div>
</div>
<!-- Product Type -->
<div class="form-section">
<div class="section-title">Product Type</div>
<div class="type-group">
<div class="form-group">
<label for="productType">Type</label>
<select id="productType" name="productType">
<option value="">Select type...</option>
</select>
</div>
<div class="form-group">
<label for="productGroup">Group [Category]</label>
<input type="text" id="productGroup" name="productGroup" placeholder="" readonly>
</div>
</div>
</div>
<!-- Code Prefixes -->
<div class="form-section dynamic-section hidden" id="prefixesSection">
<div class="section-title">Code Prefixes</div>
<div class="radio-group" id="prefixesContainer">
<!-- Dynamic prefix options will be inserted here -->
</div>
</div>
<!-- Code Suffixes -->
<div class="form-section dynamic-section hidden" id="suffixesSection">
<div class="section-title">Code Suffixes</div>
<div style="margin-bottom: 10px; font-size: 13px; color: #666;">Select all that apply (multiple selections allowed)</div>
<div class="radio-group" id="suffixesContainer">
<!-- Dynamic suffix options will be inserted here -->
</div>
</div>
<!-- Materials -->
<div class="form-section dynamic-section hidden" id="materialsSection">
<div class="section-title">Materials</div>
<div class="radio-group" id="materialsContainer">
<!-- Dynamic material options will be inserted here -->
</div>
</div>
<!-- Colors -->
<div class="form-section dynamic-section hidden" id="colorsSection">
<div class="section-title">Colors</div>
<div class="radio-group" id="colorsContainer">
<!-- Dynamic color options will be inserted here -->
</div>
</div>
<!-- Additional Options -->
<div class="form-section dynamic-section hidden" id="additionalOptionsSection">
<div class="section-title">Additional Options</div>
<div class="radio-group" id="additionalOptionsContainer">
<!-- Dynamic additional options will be inserted here -->
</div>
</div>
<!-- Action Buttons -->
<div class="button-group">
<button type="submit" class="btn-primary">Save Product</button>
<button type="button" class="btn-secondary" onclick="resetForm()">Clear Form</button>
<button type="button" class="btn-danger" onclick="deleteProduct()">Delete Product</button>
</div>
</form>
</div>
<script>
let productAttributes = null;
let userAccessibleLocations = [];
// Load product attributes and user locations
async function loadProductAttributes() {
try {
const response = await fetch('/quiz/data/product_attributes.json');
productAttributes = await response.json();
// Get user's accessible locations from session
await fetchUserLocations();
initializeForm();
} catch (error) {
console.error('Error loading product attributes:', error);
alert('Failed to load product attributes');
}
}
// Fetch user's accessible locations from session
async function fetchUserLocations() {
try {
const response = await fetch('/quiz/api/user-session');
const userData = await response.json();
userAccessibleLocations = userData.accessibleLocations || [];
// If user has no accessible locations, default to their current location
if (userAccessibleLocations.length === 0 && userData.currentLocation) {
userAccessibleLocations = [userData.currentLocation];
}
} catch (error) {
console.error('Error fetching user locations:', error);
// Fallback to all location codes if API fails
userAccessibleLocations = productAttributes.locations.map(loc => loc.code);
}
}
// Initialize form with data
function initializeForm() {
// Populate locations based on user's access
// Match user's accessible location codes with full location details
const locationSelect = document.getElementById('location');
productAttributes.locations.forEach(loc => {
// Check if user has access to this location code
if (userAccessibleLocations.includes(loc.code)) {
const option = document.createElement('option');
option.value = loc.code;
option.textContent = loc.name;
locationSelect.appendChild(option);
}
});
// Initialize location code fields with user's accessible locations
updateLocationCodes();
// Populate product types
const typeSelect = document.getElementById('productType');
productAttributes.productTypes.forEach(type => {
const option = document.createElement('option');
option.value = type.id;
option.textContent = type.type;
option.dataset.category = type.category;
option.dataset.basetype = type.baseType;
option.dataset.materials = JSON.stringify(type.availableMaterials);
option.dataset.colors = JSON.stringify(type.availableColors);
option.dataset.options = JSON.stringify(type.additionalOptions);
typeSelect.appendChild(option);
});
// Setup event listeners
document.getElementById('productType').addEventListener('change', updateDynamicSections);
document.getElementById('baseCode').addEventListener('input', updateStandardizedCode);
}
// Update location code fields based on user's accessible locations
function updateLocationCodes() {
const container = document.getElementById('locationCodesContainer');
container.innerHTML = '';
// Get full location details for each accessible code
userAccessibleLocations.forEach(locCode => {
const locationDetails = productAttributes.locations.find(loc => loc.code === locCode);
if (locationDetails) {
const div = document.createElement('div');
div.className = 'form-group';
div.innerHTML = `
<label for="code_${locCode}">${locationDetails.name} code</label>
<input type="text" id="code_${locCode}" name="code_${locCode}" placeholder="">
`;
container.appendChild(div);
}
});
}
// Update dynamic sections based on product type
function updateDynamicSections() {
const typeSelect = document.getElementById('productType');
const selectedOption = typeSelect.options[typeSelect.selectedIndex];
if (!selectedOption.value) {
hideAllDynamicSections();
return;
}
// Update category
document.getElementById('productGroup').value = selectedOption.dataset.category || '';
// Get the baseType from the selected option
const baseType = selectedOption.dataset.basetype || '';
// Update code prefixes
if (baseType && productAttributes.codeModifiers && productAttributes.codeModifiers.prefixes) {
const applicablePrefixes = productAttributes.codeModifiers.prefixes.filter(
p => p.appliesTo.includes(baseType)
);
if (applicablePrefixes.length > 0) {
showPrefixesSection(applicablePrefixes);
} else {
hidePrefixesSection();
}
} else {
hidePrefixesSection();
}
// Update code suffixes
if (baseType && productAttributes.codeModifiers && productAttributes.codeModifiers.suffixes) {
const applicableSuffixes = productAttributes.codeModifiers.suffixes.filter(
s => s.appliesTo.includes(baseType)
);
if (applicableSuffixes.length > 0) {
showSuffixesSection(applicableSuffixes);
} else {
hideSuffixesSection();
}
} else {
hideSuffixesSection();
}
// Update materials
const materials = JSON.parse(selectedOption.dataset.materials || '[]');
if (materials.length > 0) {
showMaterialsSection(materials);
} else {
hideMaterialsSection();
}
// Update colors
const colors = JSON.parse(selectedOption.dataset.colors || '[]');
if (colors.length > 0) {
showColorsSection(colors);
} else {
hideColorsSection();
}
// Update additional options
const options = JSON.parse(selectedOption.dataset.options || '[]');
if (options.length > 0) {
showAdditionalOptionsSection(options);
} else {
hideAdditionalOptionsSection();
}
}
// Show/hide dynamic sections
function showMaterialsSection(materials) {
const section = document.getElementById('materialsSection');
const container = document.getElementById('materialsContainer');
container.innerHTML = '';
materials.forEach((material, index) => {
const div = document.createElement('div');
div.className = 'radio-option';
div.innerHTML = `
<input type="radio" id="material_${index}" name="material" value="${material}" ${index === 0 ? 'checked' : ''}>
<label for="material_${index}">${material}</label>
`;
container.appendChild(div);
});
section.classList.remove('hidden');
}
function hideMaterialsSection() {
document.getElementById('materialsSection').classList.add('hidden');
}
function showColorsSection(colors) {
const section = document.getElementById('colorsSection');
const container = document.getElementById('colorsContainer');
container.innerHTML = '';
colors.forEach((color, index) => {
const div = document.createElement('div');
div.className = 'radio-option';
div.innerHTML = `
<input type="radio" id="color_${index}" name="color" value="${color}" ${index === 0 ? 'checked' : ''}>
<label for="color_${index}">${color}</label>
`;
container.appendChild(div);
});
section.classList.remove('hidden');
}
function hideColorsSection() {
document.getElementById('colorsSection').classList.add('hidden');
}
function showAdditionalOptionsSection(options) {
const section = document.getElementById('additionalOptionsSection');
const container = document.getElementById('additionalOptionsContainer');
container.innerHTML = '';
options.forEach((option, index) => {
const div = document.createElement('div');
div.className = 'radio-option';
div.innerHTML = `
<input type="radio" id="option_${index}" name="additionalOption" value="${option}" ${index === 0 ? 'checked' : ''}>
<label for="option_${index}">${option}</label>
`;
container.appendChild(div);
});
section.classList.remove('hidden');
}
function hideAdditionalOptionsSection() {
document.getElementById('additionalOptionsSection').classList.add('hidden');
}
function showPrefixesSection(prefixes) {
const section = document.getElementById('prefixesSection');
const container = document.getElementById('prefixesContainer');
container.innerHTML = '';
// Add "None" option first
const noneDiv = document.createElement('div');
noneDiv.className = 'radio-option';
noneDiv.innerHTML = `
<input type="radio" id="prefix_none" name="codePrefix" value="" checked onchange="updateStandardizedCode()">
<label for="prefix_none">None</label>
`;
container.appendChild(noneDiv);
prefixes.forEach((prefix, index) => {
const div = document.createElement('div');
div.className = 'radio-option';
div.innerHTML = `
<input type="radio" id="prefix_${index}" name="codePrefix" value="${prefix.value}" onchange="updateStandardizedCode()" title="${prefix.description}">
<label for="prefix_${index}">${prefix.label}</label>
`;
container.appendChild(div);
});
section.classList.remove('hidden');
}
function hidePrefixesSection() {
document.getElementById('prefixesSection').classList.add('hidden');
}
function showSuffixesSection(suffixes) {
const section = document.getElementById('suffixesSection');
const container = document.getElementById('suffixesContainer');
container.innerHTML = '';
suffixes.forEach((suffix, index) => {
const div = document.createElement('div');
div.className = 'radio-option';
div.innerHTML = `
<input type="checkbox" id="suffix_${index}" name="codeSuffix" value="${suffix.value}" onchange="updateStandardizedCode()" title="${suffix.description}">
<label for="suffix_${index}">${suffix.label}</label>
`;
container.appendChild(div);
});
section.classList.remove('hidden');
}
function hideSuffixesSection() {
document.getElementById('suffixesSection').classList.add('hidden');
}
function updateStandardizedCode() {
const baseCode = document.getElementById('baseCode').value.trim();
// Get selected prefix
const prefixRadio = document.querySelector('input[name="codePrefix"]:checked');
const prefix = prefixRadio ? prefixRadio.value : '';
// Get selected suffixes (checkboxes)
const suffixCheckboxes = document.querySelectorAll('input[name="codeSuffix"]:checked');
const suffixes = Array.from(suffixCheckboxes).map(cb => cb.value);
// Build the standardized code
let standardizedCode = '';
if (baseCode) {
standardizedCode = prefix + baseCode;
if (suffixes.length > 0) {
standardizedCode += suffixes.join('');
}
}
document.getElementById('standardCode').value = standardizedCode;
}
function hideAllDynamicSections() {
document.getElementById('prefixesSection').classList.add('hidden');
document.getElementById('suffixesSection').classList.add('hidden');
document.getElementById('materialsSection').classList.add('hidden');
document.getElementById('colorsSection').classList.add('hidden');
document.getElementById('additionalOptionsSection').classList.add('hidden');
document.getElementById('additionalOptionsSection').classList.add('hidden');
document.getElementById('productGroup').value = '';
}
// Form submission
document.getElementById('productForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(e.target);
// Get selected prefix
const selectedPrefix = document.querySelector('input[name="codePrefix"]:checked');
const codePrefix = selectedPrefix ? selectedPrefix.value : '';
// Get selected suffixes
const selectedSuffixes = Array.from(document.querySelectorAll('input[name="codeSuffix"]:checked'))
.map(cb => cb.value);
const productData = {
location: formData.get('location'),
description: formData.get('description'),
status: formData.get('status'),
baseCode: formData.get('baseCode'),
standardCode: formData.get('standardCode'),
codePrefix: codePrefix,
codeSuffixes: selectedSuffixes,
locationCodes: {},
productType: formData.get('productType'),
category: formData.get('productGroup'),
material: formData.get('material'),
color: formData.get('color'),
additionalOption: formData.get('additionalOption')
};
// Collect location codes for user's accessible locations
userAccessibleLocations.forEach(locCode => {
const code = formData.get(`code_${locCode}`);
if (code) {
productData.locationCodes[locCode] = code;
}
});
console.log('Product Data:', productData);
// Here you would send the data to your backend
// For now, just show an alert
alert('Product saved! (This is a demo - in production, this would save to the database)');
});
// Utility functions
function resetForm() {
document.getElementById('productForm').reset();
hideAllDynamicSections();
}
function deleteProduct() {
if (confirm('Are you sure you want to delete this product?')) {
alert('Product deleted! (This is a demo)');
resetForm();
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', loadProductAttributes);
</script>
</body>
</html>
+7
View File
@@ -269,6 +269,10 @@
<input type="checkbox" id="perm_manage_users" value="manage_users">
<label for="perm_manage_users">Manage Users</label>
</div>
<div class="location-item">
<input type="checkbox" id="perm_manage_products" value="manage_products">
<label for="perm_manage_products">Manage Products</label>
</div>
<div class="location-item">
<input type="checkbox" id="perm_create_quotes" value="create_quotes">
<label for="perm_create_quotes">Create Quotes</label>
@@ -389,6 +393,9 @@
if (document.getElementById('perm_manage_users').checked) {
permissions.manage_users = true;
}
if (document.getElementById('perm_manage_products').checked) {
permissions.manage_products = true;
}
if (document.getElementById('perm_create_quotes').checked) {
permissions.create_quotes = true;
}