Initial commit - Mold Cost Calculator Application (Security Fixed)
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
from flask import Flask
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy.exc import SQLAlchemyError, OperationalError
|
||||
from sqlalchemy import inspect
|
||||
from time import sleep
|
||||
from .config import get_config
|
||||
from .logging_config import configure_logging
|
||||
from dotenv import load_dotenv
|
||||
from .extensions import db, login_manager, migrate, csrf, limiter, cache, mail
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_caching import Cache
|
||||
|
||||
# Load environment variables first
|
||||
load_dotenv()
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
cache = Cache()
|
||||
|
||||
def create_app(config_class=None):
|
||||
app = Flask(__name__, static_folder='static')
|
||||
|
||||
@app.context_processor
|
||||
def inject_now():
|
||||
return {'now': datetime.utcnow()}
|
||||
|
||||
# Load configuration
|
||||
if config_class is None:
|
||||
config_class = get_config()
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize configuration
|
||||
config_class.init_app(app)
|
||||
|
||||
# Ensure secret key is properly set
|
||||
if not app.config.get('SECRET_KEY'):
|
||||
raise ValueError("SECRET_KEY must be set in the configuration")
|
||||
app.config['SECRET_KEY'] = app.config['SECRET_KEY'].encode('utf-8')
|
||||
|
||||
# Ensure instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Configure logging
|
||||
configure_logging(app)
|
||||
|
||||
# Initialize extensions with retry logic
|
||||
max_retries = 3
|
||||
retry_delay = 5
|
||||
|
||||
# Initialize extensions only if they haven't been initialized
|
||||
if not hasattr(app, 'extensions') or 'sqlalchemy' not in app.extensions:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Initialize SQLAlchemy only if not already initialized
|
||||
if 'sqlalchemy' not in app.extensions:
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Initialize other extensions
|
||||
if 'csrf' not in app.extensions:
|
||||
csrf.init_app(app)
|
||||
if 'limiter' not in app.extensions:
|
||||
limiter.init_app(app)
|
||||
if 'cache' not in app.extensions:
|
||||
cache.init_app(app)
|
||||
if 'mail' not in app.extensions:
|
||||
mail.init_app(app)
|
||||
if 'login_manager' not in app.extensions:
|
||||
login_manager.init_app(app)
|
||||
|
||||
# Test database connection
|
||||
with app.app_context():
|
||||
db.engine.connect()
|
||||
break
|
||||
except OperationalError as e:
|
||||
if attempt == max_retries - 1:
|
||||
app.logger.error(f"Failed to connect to database after {max_retries} attempts: {str(e)}")
|
||||
raise
|
||||
app.logger.warning(f"Database connection attempt {attempt + 1} failed: {str(e)}")
|
||||
sleep(retry_delay)
|
||||
|
||||
# Configure login manager
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from .models import User
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from .routes import main, auth, admin
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
|
||||
# Register error handlers
|
||||
from .errors import register_error_handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Debug: log all registered endpoints at startup (warning level and flush)
|
||||
app.logger.warning("Registered endpoints (debug):")
|
||||
for rule in app.url_map.iter_rules():
|
||||
app.logger.warning(f"{rule.endpoint} {rule.methods} {rule.rule}")
|
||||
for handler in app.logger.handlers:
|
||||
handler.flush()
|
||||
|
||||
# Debug: write all registered endpoints to a file
|
||||
with open('logs/endpoints.log', 'w') as f:
|
||||
for rule in app.url_map.iter_rules():
|
||||
f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
|
||||
|
||||
return app
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# Base directories
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
INSTANCE_DIR = BASE_DIR / 'instance'
|
||||
|
||||
# Default values
|
||||
DEFAULT_NET_BASE = 12.5
|
||||
|
||||
# Allowed emails for registration
|
||||
ALLOWED_EMAILS = {
|
||||
'jenny.tay@adidas.com',
|
||||
'gema.yostisiano@adidas.com',
|
||||
'dirgasapto.nugratama@adidas.com',
|
||||
'test@example.com', # Original test email
|
||||
'test1@example.com',
|
||||
'test2@example.com',
|
||||
'test3@example.com',
|
||||
'test4@example.com',
|
||||
'test5@example.com',
|
||||
'test6@example.com',
|
||||
'test7@example.com',
|
||||
'test8@example.com',
|
||||
'test9@example.com',
|
||||
'test10@example.com'
|
||||
}
|
||||
|
||||
# Tax rates by country
|
||||
TAX_RATES = {
|
||||
'China': 0.06,
|
||||
'Vietnam': 0.10,
|
||||
'Indonesia': 0.07
|
||||
}
|
||||
|
||||
# Cost factors for calculations
|
||||
COST_FACTORS = {
|
||||
'mold': {
|
||||
'Flat & Thin': 1.0,
|
||||
'Flat & Thick': 1.25,
|
||||
'Flat with Top/Heat Tip': 1.35,
|
||||
'Top PL': 1.05,
|
||||
'Short PL (Heat/Forefoot)': 1.18,
|
||||
'Visible PL': 1.28,
|
||||
'Wave PL': 1.32,
|
||||
'2 Plates Mold': 1.0,
|
||||
'3 Plates Mold': 1.35,
|
||||
'Breathable + In-mold channel': 1.45,
|
||||
'With Upper Bandage': 1.15,
|
||||
'2 plate without Bandage': 1.05,
|
||||
'Co-shot mold': 1.25,
|
||||
'Foaming mold': 1.4
|
||||
},
|
||||
'complexity': {
|
||||
'high_sidewall': 0.18,
|
||||
'slider': 0.1,
|
||||
'cavity': 0.12
|
||||
},
|
||||
'texture': {
|
||||
'standard': 0.0,
|
||||
'custom': 0.15,
|
||||
'laser': 0.2
|
||||
}
|
||||
}
|
||||
|
||||
class Config:
|
||||
"""Base configuration class."""
|
||||
|
||||
# Flask configuration
|
||||
SECRET_KEY = os.getenv('SECRET_KEY') # Get SECRET_KEY directly from environment
|
||||
FLASK_APP = 'app'
|
||||
FLASK_ENV = 'development'
|
||||
FLASK_DEBUG = True
|
||||
|
||||
# Security configuration
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
|
||||
REMEMBER_COOKIE_DURATION = timedelta(days=30)
|
||||
REMEMBER_COOKIE_SECURE = True
|
||||
REMEMBER_COOKIE_HTTPONLY = True
|
||||
REMEMBER_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Database configuration
|
||||
SQLALCHEMY_DATABASE_URI = None # Will be set from environment variable
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_size': 10,
|
||||
'pool_recycle': 3600,
|
||||
'pool_pre_ping': True
|
||||
}
|
||||
|
||||
# Email configuration
|
||||
MAIL_SERVER = None # Will be set from environment variable
|
||||
MAIL_PORT = 587
|
||||
MAIL_USE_TLS = True
|
||||
MAIL_USERNAME = None # Will be set from environment variable
|
||||
MAIL_PASSWORD = None # Will be set from environment variable
|
||||
MAIL_DEFAULT_SENDER = None # Will be set from environment variable
|
||||
|
||||
# Admin account configuration
|
||||
ADMIN_EMAIL = None # Will be set from environment variable
|
||||
ADMIN_PASSWORD = None # Will be set from environment variable
|
||||
|
||||
# Logging configuration
|
||||
LOG_LEVEL = 'INFO'
|
||||
LOG_TO_STDOUT = True
|
||||
LOG_FORMAT = '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# Application config
|
||||
ALLOWED_EMAILS = ALLOWED_EMAILS
|
||||
TAX_RATES = TAX_RATES
|
||||
DEFAULT_NET_BASE = DEFAULT_NET_BASE
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Rate Limiting Configuration
|
||||
RATELIMIT_DEFAULT = "200 per day;50 per hour;10 per minute"
|
||||
RATELIMIT_STORAGE_URL = "memory://"
|
||||
RATELIMIT_STRATEGY = "fixed-window"
|
||||
RATELIMIT_HEADERS_ENABLED = True
|
||||
RATELIMIT_HEADERS_RESET = True
|
||||
RATELIMIT_HEADERS_RETRY_AFTER = True
|
||||
|
||||
# Cache Configuration
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
CACHE_THRESHOLD = 1000
|
||||
|
||||
@classmethod
|
||||
def init_app(cls, app):
|
||||
"""Initialize application with configuration."""
|
||||
# Load configuration from environment variables
|
||||
db_user = os.getenv('POSTGRES_USER')
|
||||
db_password = os.getenv('POSTGRES_PASSWORD')
|
||||
db_host = os.getenv('DATABASE_HOST')
|
||||
db_name = os.getenv('POSTGRES_DB')
|
||||
|
||||
if db_user and db_password and db_host and db_name:
|
||||
cls.SQLALCHEMY_DATABASE_URI = f"postgresql://{db_user}:{db_password}@{db_host}/{db_name}"
|
||||
else:
|
||||
cls.SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL') # Fallback for other setups
|
||||
|
||||
cls.MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
cls.MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
cls.MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
cls.MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
||||
cls.ADMIN_EMAIL = os.getenv('ADMIN_EMAIL')
|
||||
cls.ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
|
||||
cls.LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
cls.LOG_TO_STDOUT = os.getenv('LOG_TO_STDOUT', 'True').lower() == 'true'
|
||||
|
||||
# Ensure SECRET_KEY is set
|
||||
if not cls.SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY environment variable is not set")
|
||||
|
||||
# Set SQLALCHEMY_DATABASE_URI on the app config
|
||||
if not cls.SQLALCHEMY_DATABASE_URI:
|
||||
raise ValueError("Database connection URI is not set. Please check DATABASE_URL or its components.")
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = cls.SQLALCHEMY_DATABASE_URI
|
||||
|
||||
# Configure logging
|
||||
if not os.path.exists('logs'):
|
||||
os.mkdir('logs')
|
||||
file_handler = RotatingFileHandler('logs/app.log',
|
||||
maxBytes=10240,
|
||||
backupCount=10)
|
||||
file_handler.setFormatter(logging.Formatter(cls.LOG_FORMAT))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.info('Application startup')
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
REMEMBER_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
SQLALCHEMY_ECHO = True
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Development-specific settings
|
||||
RATELIMIT_DEFAULT = "1000 per day;100 per hour;20 per minute"
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
REMEMBER_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SQLALCHEMY_ECHO = False
|
||||
LOG_LEVEL = 'INFO'
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Production-specific settings
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
WTF_CSRF_ENABLED = False
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
|
||||
# Testing-specific settings
|
||||
SECRET_KEY = 'testing-secret-key-not-used-in-production'
|
||||
MAIL_SUPPRESS_SEND = True
|
||||
RATELIMIT_ENABLED = False
|
||||
RATELIMIT_DEFAULT = "1000 per day;100 per hour;20 per minute"
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {}
|
||||
|
||||
def get_config():
|
||||
"""Get the appropriate configuration class based on environment."""
|
||||
config_name = os.getenv('FLASK_CONFIG', 'development')
|
||||
config_map = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig
|
||||
}
|
||||
return config_map.get(config_name, DevelopmentConfig)
|
||||
@@ -0,0 +1,86 @@
|
||||
from flask import render_template, jsonify, request, send_from_directory, current_app
|
||||
import logging
|
||||
import os
|
||||
from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def register_error_handlers(app):
|
||||
@app.errorhandler(400)
|
||||
def handle_400_error(e):
|
||||
logger.error(f'400 error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Invalid request parameters'}), 400
|
||||
return render_template('errors/400.html'), 400
|
||||
|
||||
@app.errorhandler(403)
|
||||
def handle_403_error(e):
|
||||
logger.error(f'403 error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Access forbidden'}), 403
|
||||
return render_template('errors/403.html'), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_404_error(e):
|
||||
logger.error(f'404 error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Resource not found'}), 404
|
||||
if request.path.startswith('/static/'):
|
||||
app.logger.warning(f'Static file not found: {request.path}')
|
||||
return '', 404
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def handle_500_error(e):
|
||||
logger.error(f'500 error: {str(e)}')
|
||||
app.logger.error(f'Server error: {str(e)}')
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
if request.path.startswith('/static/'):
|
||||
app.logger.error(f'Error serving static file {request.path}: {str(e)}')
|
||||
return '', 404
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(OperationalError)
|
||||
def handle_db_operational_error(error):
|
||||
logger.error(f'Database operational error: {str(error)}', exc_info=True)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Database connection error',
|
||||
'message': 'Unable to connect to the database. Please try again later.'
|
||||
}), 503
|
||||
return render_template('errors/503.html',
|
||||
message='Database connection error. Please try again later.'), 503
|
||||
|
||||
@app.errorhandler(IntegrityError)
|
||||
def handle_db_integrity_error(error):
|
||||
logger.error(f'Database integrity error: {str(error)}', exc_info=True)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Database integrity error',
|
||||
'message': 'The operation could not be completed due to data constraints.'
|
||||
}), 409
|
||||
return render_template('errors/409.html',
|
||||
message='The operation could not be completed due to data constraints.'), 409
|
||||
|
||||
@app.errorhandler(SQLAlchemyError)
|
||||
def handle_db_error(error):
|
||||
logger.error(f'Database error: {str(error)}', exc_info=True)
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'An unexpected database error occurred.'
|
||||
}), 500
|
||||
return render_template('errors/500.html',
|
||||
message='An unexpected database error occurred.'), 500
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_unhandled_exception(e):
|
||||
logger.error(f'Unhandled exception: {str(e)}', exc_info=True)
|
||||
app.logger.error(f'Unhandled exception: {str(e)}', exc_info=True)
|
||||
if request.path.startswith('/calculate'):
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
if request.path.startswith('/static/'):
|
||||
app.logger.error(f'Error serving static file {request.path}: {str(e)}')
|
||||
return '', 404
|
||||
return render_template('errors/500.html'), 500
|
||||
@@ -0,0 +1,16 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_caching import Cache
|
||||
from flask_mail import Mail
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
csrf = CSRFProtect()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
cache = Cache()
|
||||
mail = Mail()
|
||||
@@ -0,0 +1,19 @@
|
||||
from .auth import LoginForm, RegistrationForm
|
||||
from .quotation import QuotationForm
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, EqualTo
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
__all__ = ['LoginForm', 'RegistrationForm', 'QuotationForm']
|
||||
|
||||
class RequestPasswordResetForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
submit = SubmitField('Request Password Reset')
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
password2 = PasswordField('Repeat Password', validators=[DataRequired(), EqualTo('password')])
|
||||
submit = SubmitField('Reset Password')
|
||||
@@ -0,0 +1,108 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
PasswordField,
|
||||
BooleanField,
|
||||
SubmitField
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Email,
|
||||
Length,
|
||||
ValidationError,
|
||||
EqualTo
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from app.models import User
|
||||
import re
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
password = PasswordField('Password', validators=[
|
||||
DataRequired(),
|
||||
Length(min=8)
|
||||
])
|
||||
remember_me = BooleanField('Remember Me')
|
||||
submit = SubmitField('Log In')
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField('Username', validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=64)
|
||||
])
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
company = StringField('Company', validators=[
|
||||
DataRequired(),
|
||||
Length(max=100)
|
||||
])
|
||||
password = PasswordField('Password', validators=[
|
||||
DataRequired(),
|
||||
Length(min=8)
|
||||
])
|
||||
password_confirm = PasswordField('Confirm Password', validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message='Passwords must match')
|
||||
])
|
||||
terms = BooleanField('I agree to the terms and conditions', validators=[
|
||||
DataRequired(message='You must agree to the terms and conditions')
|
||||
])
|
||||
submit = SubmitField('Register')
|
||||
|
||||
def validate_username(self, username):
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username.data):
|
||||
raise ValidationError('Username can only contain letters, numbers, underscores and hyphens')
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user:
|
||||
raise ValidationError('Username already taken')
|
||||
|
||||
def validate_email(self, email):
|
||||
user = User.query.filter_by(email=email.data).first()
|
||||
if user:
|
||||
raise ValidationError('Email already registered')
|
||||
|
||||
def validate_password(self, password):
|
||||
if not re.search(r'[A-Z]', password.data):
|
||||
raise ValidationError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', password.data):
|
||||
raise ValidationError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'[0-9]', password.data):
|
||||
raise ValidationError('Password must contain at least one number')
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password.data):
|
||||
raise ValidationError('Password must contain at least one special character')
|
||||
|
||||
class RequestPasswordResetForm(FlaskForm):
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
submit = SubmitField('Request Password Reset')
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = PasswordField('New Password', validators=[
|
||||
DataRequired(),
|
||||
Length(min=8)
|
||||
])
|
||||
password_confirm = PasswordField('Confirm New Password', validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message='Passwords must match')
|
||||
])
|
||||
submit = SubmitField('Reset Password')
|
||||
|
||||
def validate_password(self, password):
|
||||
if not re.search(r'[A-Z]', password.data):
|
||||
raise ValidationError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'[a-z]', password.data):
|
||||
raise ValidationError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'[0-9]', password.data):
|
||||
raise ValidationError('Password must contain at least one number')
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password.data):
|
||||
raise ValidationError('Password must contain at least one special character')
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
SelectField,
|
||||
DateField,
|
||||
SubmitField
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Length,
|
||||
ValidationError
|
||||
)
|
||||
|
||||
def validate_tooling_id_format(form, field):
|
||||
"""Custom validator to ensure tooling_id is exactly 5 digits"""
|
||||
if not field.data.isdigit() or len(field.data) != 5:
|
||||
raise ValidationError('Tooling ID must be exactly 5 digits')
|
||||
|
||||
class QuotationForm(FlaskForm):
|
||||
# Factory Information
|
||||
mold_shop_name = StringField('Mold Shop Name', validators=[DataRequired()])
|
||||
mold_shop_country = SelectField('Mold Shop Country', validators=[DataRequired()], choices=[
|
||||
('', 'Select Country'),
|
||||
('China', 'China'),
|
||||
('Vietnam', 'Vietnam'),
|
||||
('Indonesia', 'Indonesia')
|
||||
])
|
||||
t1_shoe_factory_name = StringField('T1 Shoe Factory Name', validators=[DataRequired()])
|
||||
t1_shoe_factory_country = SelectField('T1 Shoe Factory Country', validators=[DataRequired()], choices=[
|
||||
('', 'Select Country'),
|
||||
('China', 'China'),
|
||||
('Vietnam', 'Vietnam'),
|
||||
('Indonesia', 'Indonesia')
|
||||
])
|
||||
t2_component_factory_name = StringField('T2 Component Factory Name', validators=[DataRequired()])
|
||||
t2_component_factory_country = SelectField('T2 Component Factory Country', validators=[DataRequired()], choices=[
|
||||
('', 'Select Country'),
|
||||
('China', 'China'),
|
||||
('Vietnam', 'Vietnam'),
|
||||
('Indonesia', 'Indonesia')
|
||||
])
|
||||
|
||||
# Mold Information
|
||||
model_name = StringField('Model Name', validators=[DataRequired()])
|
||||
tooling_id = StringField('Tooling ID', validators=[DataRequired(), Length(min=5, max=5), validate_tooling_id_format], id='tooling_id')
|
||||
season = StringField('Season', validators=[DataRequired()])
|
||||
|
||||
# Define stage-type mapping
|
||||
STAGE_TYPE_MAPPING = {
|
||||
'CR0': ['Sample'],
|
||||
'CR1': ['Sample'],
|
||||
'CR2': ['Sample'],
|
||||
'SMS': ['A Set'],
|
||||
'commercialization': ['A Set'],
|
||||
'production': ['Additional']
|
||||
}
|
||||
|
||||
# Define the mapping of main types to sub-types
|
||||
MOLD_TYPE_MAPPING = {
|
||||
'Rubber': ['Flat Outsole', 'Cupsole', 'Rubber Sole'],
|
||||
'CMEVA': ['2 Plates Mold', '3 Plates Mold', '2 Plates with in-mold channel', 'Breathable + in-mold channel'],
|
||||
'IMEVA': ['2 Plates Mold', '3 Plates Mold'],
|
||||
'Sandal': ['Sandal with Upper Bandage', '2 Plate without Upper Bandage'],
|
||||
'Co-shot mold': ['Co-shot mold', 'Foaming mold'],
|
||||
'PU': ['2 Plates Mold', '3 Plates Mold']
|
||||
}
|
||||
|
||||
# Define the mapping of sub-types to their specific options
|
||||
MOLD_SPECIFIC_TYPE_OPTIONS = {
|
||||
'Flat Outsole': ['Flat', 'Flat with Top/Heel Tip'],
|
||||
'Cupsole': ['Top PL', 'Visible PL'],
|
||||
'Rubber Sole': ['Top PL', 'Short PL (Heel/Forefoot)', 'Visible PL', 'Wave PL']
|
||||
}
|
||||
|
||||
mold_main_type = SelectField('Mold Main Type', validators=[DataRequired()], choices=[
|
||||
('', 'Select Main Type'),
|
||||
('Rubber', 'Rubber'),
|
||||
('CMEVA', 'CMEVA'),
|
||||
('IMEVA', 'IMEVA'),
|
||||
('Sandal', 'Sandal'),
|
||||
('Co-shot mold', 'Co-shot mold'),
|
||||
('PU', 'PU')
|
||||
], id='mold_main_type')
|
||||
|
||||
mold_sub_type = SelectField('Mold Sub Type', validators=[DataRequired()], choices=[
|
||||
('', 'Select Sub Type')
|
||||
], id='mold_sub_type')
|
||||
|
||||
mold_specific_type = SelectField('Mold Specific Type', validators=[], choices=[
|
||||
('', 'Select Specific Type')
|
||||
], id='mold_specific_type', coerce=str, default='')
|
||||
|
||||
stage = SelectField('Stage', validators=[DataRequired()], choices=[
|
||||
('', 'Select Stage'),
|
||||
('CR0', 'CR0'),
|
||||
('CR1', 'CR1'),
|
||||
('CR2', 'CR2'),
|
||||
('SMS', 'SMS'),
|
||||
('commercialization', 'Commercialization'),
|
||||
('production', 'Production')
|
||||
], id='stage')
|
||||
|
||||
type = SelectField('Type', validators=[DataRequired()], choices=[
|
||||
('', 'Select Type'),
|
||||
('Sample', 'Sample'),
|
||||
('A Set', 'A Set'),
|
||||
('Additional', 'Additional')
|
||||
], id='type')
|
||||
|
||||
# Dates and Approvals
|
||||
issue_date = DateField('Issue Date', validators=[DataRequired()])
|
||||
approved_by_mold_shop = StringField('Approved by (Mold Shop Representative)', validators=[DataRequired()])
|
||||
approved_by_t1_tooling = StringField('Approved by (T1 Tooling Representative)', validators=[DataRequired()])
|
||||
approved_by_lo_tooling = StringField('Approved by (LO Tooling)', validators=[DataRequired()])
|
||||
|
||||
# Mold Cost
|
||||
sliders_count = SelectField('Number of Sliders', validators=[DataRequired()], choices=[
|
||||
('', 'Select Number of Sliders'),
|
||||
('0', '0'),
|
||||
('1', '1'),
|
||||
('2', '2'),
|
||||
('3', '3'),
|
||||
('4', '4')
|
||||
])
|
||||
cavity_count = SelectField('Number of Cavities', validators=[DataRequired()], choices=[
|
||||
('', 'Select Number of Pairs'),
|
||||
('0.5', '0.5 pair'),
|
||||
('1', '1 pair'),
|
||||
('1.5', '1.5 pair'),
|
||||
('2', '2 pair'),
|
||||
('2.5', '2.5 pair'),
|
||||
('3', '3 pair')
|
||||
])
|
||||
digital_texture_type = SelectField('Digital Texture', validators=[DataRequired()], choices=[
|
||||
('', 'Select Texture Type'),
|
||||
('adidas_digital', 'adidas digital texture'),
|
||||
('customized texture', 'customized texture'),
|
||||
('laser', 'laser texture')
|
||||
])
|
||||
complexity_high_sidewall = SelectField('High Sidewall >60mm', validators=[DataRequired()], choices=[('no', 'No'), ('yes', 'Yes')])
|
||||
submit = SubmitField('Calculate')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(QuotationForm, self).__init__(*args, **kwargs)
|
||||
# Set initial choices for mold_sub_type based on default mold_main_type
|
||||
if self.mold_main_type.data in self.MOLD_TYPE_MAPPING:
|
||||
self.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in self.MOLD_TYPE_MAPPING[self.mold_main_type.data]]
|
||||
|
||||
def validate_mold_sub_type(self, field):
|
||||
main_type = self.mold_main_type.data
|
||||
print(f"Validating sub_type: {field.data} for main_type: {main_type}")
|
||||
if main_type and main_type in self.MOLD_TYPE_MAPPING:
|
||||
valid_sub_types = self.MOLD_TYPE_MAPPING[main_type]
|
||||
print(f"Valid sub_types for {main_type}: {valid_sub_types}")
|
||||
if field.data not in valid_sub_types and field.data != '':
|
||||
print(f"Invalid sub_type: {field.data}. Valid types: {valid_sub_types}")
|
||||
raise ValidationError('Invalid sub-type for selected main type')
|
||||
|
||||
def validate_mold_specific_type(self, field):
|
||||
"""Validate that the selected specific type is valid for the selected sub type."""
|
||||
sub_type = self.mold_sub_type.data
|
||||
if not sub_type:
|
||||
field.data = ''
|
||||
return
|
||||
|
||||
# Get valid specific types for the selected sub type
|
||||
valid_specific_types = self.MOLD_SPECIFIC_TYPE_OPTIONS.get(sub_type, [])
|
||||
|
||||
# If there are no specific types for this sub-type, the field should be empty
|
||||
if not valid_specific_types:
|
||||
field.data = '' # Always clear the field if it shouldn't have a value
|
||||
return
|
||||
|
||||
# If there are specific types, validate the selection
|
||||
if field.data is None or field.data == '':
|
||||
raise ValidationError(f'Specific type is required for {sub_type}')
|
||||
if field.data not in valid_specific_types:
|
||||
raise ValidationError(f'Invalid specific type for {sub_type}. Valid options are: {", ".join(valid_specific_types)}')
|
||||
|
||||
def validate_type(self, field):
|
||||
stage = self.stage.data
|
||||
print(f"Validating type: {field.data} for stage: {stage}")
|
||||
if stage and stage in self.STAGE_TYPE_MAPPING:
|
||||
valid_types = self.STAGE_TYPE_MAPPING[stage]
|
||||
print(f"Valid types for {stage}: {valid_types}")
|
||||
if field.data not in valid_types and field.data != '':
|
||||
print(f"Invalid type: {field.data}")
|
||||
raise ValidationError(f'Invalid type for {stage} stage. Allowed types: {", ".join(valid_types)}')
|
||||
elif field.data != '':
|
||||
raise ValidationError('Please select a stage first')
|
||||
|
||||
def validate_tooling_id(self, field):
|
||||
validate_tooling_id_format(self, field)
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
import psutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from flask import jsonify
|
||||
from sqlalchemy import text
|
||||
from . import db
|
||||
|
||||
def check_database():
|
||||
"""Check database connection and response time."""
|
||||
try:
|
||||
start_time = time.time()
|
||||
# Execute a simple query
|
||||
db.session.execute(text('SELECT 1'))
|
||||
response_time = (time.time() - start_time) * 1000 # Convert to milliseconds
|
||||
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'response_time_ms': round(response_time, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_disk_space():
|
||||
"""Check available disk space."""
|
||||
try:
|
||||
disk = psutil.disk_usage('/')
|
||||
return {
|
||||
'status': 'healthy' if disk.percent < 90 else 'warning',
|
||||
'total_gb': round(disk.total / (1024**3), 2),
|
||||
'used_gb': round(disk.used / (1024**3), 2),
|
||||
'free_gb': round(disk.free / (1024**3), 2),
|
||||
'percent_used': disk.percent
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_memory():
|
||||
"""Check memory usage."""
|
||||
try:
|
||||
memory = psutil.virtual_memory()
|
||||
return {
|
||||
'status': 'healthy' if memory.percent < 90 else 'warning',
|
||||
'total_gb': round(memory.total / (1024**3), 2),
|
||||
'used_gb': round(memory.used / (1024**3), 2),
|
||||
'free_gb': round(memory.available / (1024**3), 2),
|
||||
'percent_used': memory.percent
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_cpu():
|
||||
"""Check CPU usage."""
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
return {
|
||||
'status': 'healthy' if cpu_percent < 90 else 'warning',
|
||||
'usage_percent': cpu_percent,
|
||||
'core_count': psutil.cpu_count()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_application():
|
||||
"""Check application-specific metrics."""
|
||||
try:
|
||||
# Check if required directories exist
|
||||
required_dirs = ['instance', 'logs', 'backups']
|
||||
dir_status = {}
|
||||
for dir_name in required_dirs:
|
||||
dir_status[dir_name] = os.path.exists(dir_name)
|
||||
|
||||
# Check if log files are writable
|
||||
log_status = {}
|
||||
log_files = ['logs/mold_cost_calculator.log', 'logs/error.log', 'logs/access.log']
|
||||
for log_file in log_files:
|
||||
log_status[log_file] = os.access(os.path.dirname(log_file), os.W_OK)
|
||||
|
||||
return {
|
||||
'status': 'healthy' if all(dir_status.values()) and all(log_status.values()) else 'warning',
|
||||
'directories': dir_status,
|
||||
'log_files': log_status,
|
||||
'startup_time': datetime.utcnow().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_health_status():
|
||||
"""Get comprehensive health status of the application."""
|
||||
checks = {
|
||||
'database': check_database(),
|
||||
'disk_space': check_disk_space(),
|
||||
'memory': check_memory(),
|
||||
'cpu': check_cpu(),
|
||||
'application': check_application()
|
||||
}
|
||||
|
||||
# Determine overall status
|
||||
statuses = [check['status'] for check in checks.values()]
|
||||
if 'unhealthy' in statuses:
|
||||
overall_status = 'unhealthy'
|
||||
elif 'warning' in statuses:
|
||||
overall_status = 'warning'
|
||||
else:
|
||||
overall_status = 'healthy'
|
||||
|
||||
return {
|
||||
'status': overall_status,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'checks': checks
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler, SMTPHandler
|
||||
from pathlib import Path
|
||||
|
||||
def configure_logging(app):
|
||||
"""Configure logging for the application."""
|
||||
# Create logs directory if it doesn't exist
|
||||
log_dir = Path('logs')
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Set up basic logging configuration
|
||||
logging.basicConfig(
|
||||
level=app.config['LOG_LEVEL'],
|
||||
format=app.config['LOG_FORMAT'],
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# Configure application logger
|
||||
app.logger.setLevel(app.config['LOG_LEVEL'])
|
||||
|
||||
# Remove default handlers
|
||||
for handler in app.logger.handlers[:]:
|
||||
app.logger.removeHandler(handler)
|
||||
|
||||
# Add file handler for application logs
|
||||
file_handler = RotatingFileHandler(
|
||||
'logs/mold_cost_calculator.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
file_handler.setLevel(app.config['LOG_LEVEL'])
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
# Add file handler for error logs
|
||||
error_handler = RotatingFileHandler(
|
||||
'logs/error.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10,
|
||||
encoding='utf-8'
|
||||
)
|
||||
error_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
app.logger.addHandler(error_handler)
|
||||
|
||||
# Add file handler for access logs
|
||||
access_handler = RotatingFileHandler(
|
||||
'logs/access.log',
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10,
|
||||
encoding='utf-8'
|
||||
)
|
||||
access_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
access_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(access_handler)
|
||||
|
||||
# Add console handler if LOG_TO_STDOUT is set
|
||||
if app.config.get('LOG_TO_STDOUT'):
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
|
||||
console_handler.setLevel(app.config['LOG_LEVEL'])
|
||||
app.logger.addHandler(console_handler)
|
||||
|
||||
# Add email handler for critical errors in production
|
||||
if not app.debug and not app.testing and app.config.get('MAIL_SERVER'):
|
||||
mail_handler = SMTPHandler(
|
||||
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
|
||||
fromaddr=app.config['MAIL_DEFAULT_SENDER'],
|
||||
toaddrs=[app.config['ADMIN_EMAIL']],
|
||||
subject='Mold Cost Calculator Error'
|
||||
)
|
||||
mail_handler.setLevel(logging.ERROR)
|
||||
mail_handler.setFormatter(logging.Formatter('''
|
||||
Message type: %(levelname)s
|
||||
Location: %(pathname)s:%(lineno)d
|
||||
Module: %(module)s
|
||||
Function: %(funcName)s
|
||||
Time: %(asctime)s
|
||||
Message:
|
||||
%(message)s
|
||||
'''))
|
||||
app.logger.addHandler(mail_handler)
|
||||
|
||||
# Configure SQLAlchemy logging
|
||||
if app.config.get('SQLALCHEMY_ECHO'):
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||
|
||||
# Configure Werkzeug logging
|
||||
logging.getLogger('werkzeug').setLevel(logging.INFO)
|
||||
|
||||
# Log startup message
|
||||
app.logger.info('Mold Cost Calculator startup')
|
||||
|
||||
return app.logger
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class CostFactor(db.Model):
|
||||
"""Model for storing cost factors that can be managed via admin interface"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category = db.Column(db.String(50), nullable=False) # 'mold', 'complexity', 'texture', 'tax'
|
||||
subcategory = db.Column(db.String(50)) # For mold types: 'Rubber', 'CMEVA', etc.
|
||||
name = db.Column(db.String(100), nullable=False) # Factor name
|
||||
factor_value = db.Column(db.Float, nullable=False) # The actual factor value
|
||||
calculation_formula = db.Column(db.String(200)) # For complexity factors
|
||||
percentage = db.Column(db.String(20)) # For display purposes
|
||||
description = db.Column(db.String(500))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
updated_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
|
||||
# Relationship
|
||||
updater = db.relationship('User', backref='cost_factor_updates')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactor {self.category}:{self.name}={self.factor_value}>'
|
||||
|
||||
class CostFactorHistory(db.Model):
|
||||
"""Model for tracking changes to cost factors"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
cost_factor_id = db.Column(db.Integer, db.ForeignKey('cost_factor.id'), nullable=False)
|
||||
old_value = db.Column(db.Float)
|
||||
new_value = db.Column(db.Float, nullable=False)
|
||||
changed_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
change_reason = db.Column(db.String(500))
|
||||
|
||||
# Relationships
|
||||
cost_factor = db.relationship('CostFactor', backref='history')
|
||||
user = db.relationship('User', backref='cost_factor_changes')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactorHistory {self.cost_factor_id}:{self.old_value}->{self.new_value}>'
|
||||
@@ -0,0 +1,6 @@
|
||||
from .. import db
|
||||
from .user import User
|
||||
from .quotation import Quotation
|
||||
from .cost_factor import CostFactor, CostFactorHistory
|
||||
|
||||
__all__ = ['db', 'User', 'Quotation', 'CostFactor', 'CostFactorHistory']
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import event
|
||||
from .. import db
|
||||
|
||||
class CostFactor(db.Model):
|
||||
"""Model for storing cost factors that can be managed via admin interface"""
|
||||
__tablename__ = 'cost_factors'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category = db.Column(db.String(50), nullable=False) # 'mold', 'complexity', 'texture', 'tax'
|
||||
subcategory = db.Column(db.String(50)) # For mold types: 'Rubber', 'CMEVA', etc.
|
||||
name = db.Column(db.String(100), nullable=False) # Factor name
|
||||
factor_value = db.Column(db.Float, nullable=False) # The actual factor value
|
||||
calculation_formula = db.Column(db.String(200)) # For complexity factors
|
||||
percentage = db.Column(db.String(20)) # For display purposes
|
||||
description = db.Column(db.String(500))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
updated_by = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'))
|
||||
|
||||
# Relationship
|
||||
updater = db.relationship('User', backref='cost_factor_updates')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_cost_factor_category', category),
|
||||
db.Index('ix_cost_factor_name', name),
|
||||
db.Index('ix_cost_factor_active', is_active),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactor {self.category}:{self.name}={self.factor_value}>'
|
||||
|
||||
class CostFactorHistory(db.Model):
|
||||
"""Model for tracking changes to cost factors"""
|
||||
__tablename__ = 'cost_factor_history'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
cost_factor_id = db.Column(db.Integer, db.ForeignKey('cost_factors.id'), nullable=False)
|
||||
old_value = db.Column(db.Float)
|
||||
new_value = db.Column(db.Float, nullable=False)
|
||||
changed_by = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'), nullable=False)
|
||||
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
change_reason = db.Column(db.String(500))
|
||||
|
||||
# Relationships
|
||||
cost_factor = db.relationship('CostFactor', backref='history')
|
||||
user = db.relationship('User', backref='cost_factor_changes')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_cost_factor_history_factor_id', cost_factor_id),
|
||||
db.Index('ix_cost_factor_history_changed_at', changed_at),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CostFactorHistory {self.cost_factor_id}:{self.old_value}->{self.new_value}>'
|
||||
|
||||
# Event listeners for model changes
|
||||
@event.listens_for(CostFactor, 'before_insert')
|
||||
def set_cost_factor_timestamps(mapper, connection, target):
|
||||
target.created_at = datetime.now(timezone.utc)
|
||||
target.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@event.listens_for(CostFactor, 'before_update')
|
||||
def set_cost_factor_update_timestamp(mapper, connection, target):
|
||||
target.updated_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import event
|
||||
from .. import db
|
||||
|
||||
class Quotation(db.Model):
|
||||
__tablename__ = 'mold_quotations'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'), nullable=False)
|
||||
|
||||
# Factory Information
|
||||
mold_shop_name = db.Column(db.String(100), nullable=False)
|
||||
mold_shop_country = db.Column(db.String(50), nullable=False)
|
||||
t1_shoe_factory_name = db.Column(db.String(100), nullable=False)
|
||||
t1_shoe_factory_country = db.Column(db.String(50), nullable=False)
|
||||
t2_component_factory_name = db.Column(db.String(100), nullable=False)
|
||||
t2_component_factory_country = db.Column(db.String(50), nullable=False)
|
||||
|
||||
# Mold Information
|
||||
model_name = db.Column(db.String(100), nullable=False)
|
||||
tooling_id = db.Column(db.String(5), nullable=False)
|
||||
season = db.Column(db.String(10), nullable=False)
|
||||
stage = db.Column(db.String(10), nullable=False)
|
||||
type = db.Column(db.String(20), nullable=False)
|
||||
|
||||
# Dates and Approvals
|
||||
issue_date = db.Column(db.Date, nullable=False)
|
||||
approved_by_mold_shop = db.Column(db.String(100), nullable=False)
|
||||
approved_by_t1_tooling = db.Column(db.String(100), nullable=False)
|
||||
approved_by_lo_tooling = db.Column(db.String(100), nullable=False)
|
||||
|
||||
# Mold Specifications
|
||||
mold_main_type = db.Column(db.String(50), nullable=False)
|
||||
mold_sub_type = db.Column(db.String(50), nullable=False)
|
||||
mold_specific_type = db.Column(db.String(50), nullable=False)
|
||||
complexity_high_sidewall = db.Column(db.Boolean, default=False)
|
||||
sliders_count = db.Column(db.Integer, nullable=False)
|
||||
cavity_count = db.Column(db.Float, nullable=False)
|
||||
digital_texture_type = db.Column(db.String(50), nullable=False)
|
||||
|
||||
# Calculation Results
|
||||
base_price = db.Column(db.Float, nullable=False)
|
||||
tax_rate = db.Column(db.Float)
|
||||
total_cost = db.Column(db.Float, nullable=False)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_archived = db.Column(db.Boolean, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('ix_quotation_user_id', user_id),
|
||||
db.Index('ix_quotation_created_at', created_at),
|
||||
db.Index('ix_quotation_issue_date', issue_date),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Quotation {self.id}>'
|
||||
|
||||
# Event listeners for model changes
|
||||
@event.listens_for(Quotation, 'before_insert')
|
||||
def set_quotation_timestamps(mapper, connection, target):
|
||||
target.created_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,116 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import event, func
|
||||
from .. import db
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'enterprise_users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(25), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
company = db.Column(db.String(100), nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
created_at = db.Column(db.DateTime)
|
||||
password_changed_at = db.Column(db.DateTime)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
last_login = db.Column(db.DateTime)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||
last_failed_login = db.Column(db.DateTime)
|
||||
reset_token = db.Column(db.String(100), unique=True)
|
||||
reset_token_expires = db.Column(db.DateTime)
|
||||
|
||||
# Relationships
|
||||
quotations = db.relationship('Quotation', backref='user', lazy=True)
|
||||
|
||||
def set_password(self, password):
|
||||
try:
|
||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
self.password_changed_at = datetime.now(timezone.utc)
|
||||
self.reset_token = None
|
||||
self.reset_token_expires = None
|
||||
except Exception as e:
|
||||
logger.error(f'Error setting password: {str(e)}', exc_info=True)
|
||||
raise
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def generate_reset_token(self):
|
||||
token = secrets.token_urlsafe(16) # Shorter token for display
|
||||
self.reset_token = token
|
||||
self.reset_token_expires = datetime.now(timezone.utc) + timedelta(minutes=15) # 15-minute expiry
|
||||
return token
|
||||
|
||||
def verify_reset_token(self, token):
|
||||
if not self.reset_token or not self.reset_token_expires:
|
||||
return False
|
||||
if datetime.now(timezone.utc) > self.reset_token_expires:
|
||||
return False
|
||||
return secrets.compare_digest(self.reset_token, token)
|
||||
|
||||
@classmethod
|
||||
def authenticate(cls, email, password):
|
||||
user = cls.query.filter(func.lower(cls.email) == email.lower()).first()
|
||||
if user and user.check_password(password):
|
||||
if not user.is_active:
|
||||
return None
|
||||
user.failed_login_attempts = 0
|
||||
user.last_failed_login = None
|
||||
db.session.commit()
|
||||
return user
|
||||
if user:
|
||||
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
|
||||
user.last_failed_login = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create(cls, username, email, company, password):
|
||||
try:
|
||||
logger.info(f'Checking if username {username} exists...')
|
||||
if cls.query.filter(func.lower(cls.username) == username.lower()).first():
|
||||
logger.warning(f'Username {username} already taken')
|
||||
raise ValueError('Username already taken')
|
||||
|
||||
logger.info(f'Checking if email {email} exists...')
|
||||
if cls.query.filter(func.lower(cls.email) == email.lower()).first():
|
||||
logger.warning(f'Email {email} already registered')
|
||||
raise ValueError('Email already registered')
|
||||
|
||||
logger.info('Creating new user...')
|
||||
user = cls(
|
||||
username=username,
|
||||
email=email,
|
||||
company=company,
|
||||
is_active=True,
|
||||
failed_login_attempts=0,
|
||||
is_admin=False
|
||||
)
|
||||
|
||||
logger.info('Setting password...')
|
||||
user.set_password(password)
|
||||
|
||||
logger.info('Adding user to session...')
|
||||
db.session.add(user)
|
||||
|
||||
logger.info('User created successfully')
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating user: {str(e)}', exc_info=True)
|
||||
raise
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
# Event listeners for model changes
|
||||
@event.listens_for(User, 'before_insert')
|
||||
def set_timestamps(mapper, connection, target):
|
||||
target.created_at = datetime.now(timezone.utc)
|
||||
target.password_changed_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,473 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, make_response
|
||||
from flask_login import login_required, current_user
|
||||
from app import create_app, db
|
||||
from ..forms import QuotationForm
|
||||
from ..models import User, Quotation, CostFactor, CostFactorHistory
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import desc, text
|
||||
import logging
|
||||
from app import limiter, cache
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@bp.before_request
|
||||
def before_request():
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
flash('Access denied. Admin privileges required.', 'danger')
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='admin_dashboard')
|
||||
def dashboard():
|
||||
try:
|
||||
# Get statistics
|
||||
total_users = User.query.count()
|
||||
total_quotations = Quotation.query.count()
|
||||
recent_quotations = Quotation.query.order_by(desc(Quotation.created_at)).limit(5).all()
|
||||
|
||||
# Get user statistics
|
||||
active_users = User.query.filter(
|
||||
User.last_login >= datetime.utcnow() - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
# Get quotation statistics by country (using mold_shop_country)
|
||||
country_stats = db.session.query(
|
||||
Quotation.mold_shop_country,
|
||||
db.func.count(Quotation.id)
|
||||
).group_by(Quotation.mold_shop_country).all()
|
||||
|
||||
return render_template('admin/dashboard.html',
|
||||
total_users=total_users,
|
||||
total_quotations=total_quotations,
|
||||
active_users=active_users,
|
||||
recent_quotations=recent_quotations,
|
||||
country_stats=country_stats
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin dashboard error: {str(e)}')
|
||||
flash('An error occurred while loading the dashboard.', 'error')
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
@bp.route('/users')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='admin_users')
|
||||
def users():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
users = User.query.order_by(User.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return render_template('admin/users.html', users=users)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin users error: {str(e)}')
|
||||
flash('An error occurred while loading users.', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@bp.route('/quotations')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def quotations():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
# Get filter parameters
|
||||
country = request.args.get('country')
|
||||
mold_type = request.args.get('mold_type')
|
||||
date_from_str = request.args.get('date_from')
|
||||
date_to_str = request.args.get('date_to')
|
||||
|
||||
# Convert date strings to datetime objects
|
||||
date_from = None
|
||||
date_to = None
|
||||
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
try:
|
||||
if date_from_str:
|
||||
date_from = datetime.strptime(date_from_str, '%Y-%m-%d')
|
||||
if date_to_str:
|
||||
date_to = datetime.strptime(date_to_str, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
||||
date_from = None
|
||||
date_to = None
|
||||
|
||||
# Build query
|
||||
query = Quotation.query
|
||||
|
||||
if country:
|
||||
query = query.filter(Quotation.mold_shop_country == country)
|
||||
if mold_type:
|
||||
query = query.filter(Quotation.mold_main_type == mold_type)
|
||||
if date_from:
|
||||
query = query.filter(Quotation.created_at >= date_from)
|
||||
if date_to:
|
||||
# Add one day to include the end date fully
|
||||
next_day = date_to + timedelta(days=1)
|
||||
query = query.filter(Quotation.created_at < next_day)
|
||||
|
||||
# Get unique countries and mold types for filters
|
||||
countries = db.session.query(Quotation.mold_shop_country).distinct().all()
|
||||
countries = [c[0] for c in countries if c[0]]
|
||||
|
||||
mold_types = db.session.query(Quotation.mold_main_type).distinct().all()
|
||||
mold_types = [t[0] for t in mold_types if t[0]]
|
||||
|
||||
quotations = query.order_by(desc(Quotation.created_at)).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return render_template('admin/quotations.html',
|
||||
quotations=quotations,
|
||||
countries=sorted(countries),
|
||||
mold_types=sorted(mold_types),
|
||||
today=today,
|
||||
filters={
|
||||
'country': country,
|
||||
'mold_type': mold_type,
|
||||
'date_from': date_from,
|
||||
'date_to': date_to
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin quotations error: {str(e)}')
|
||||
flash('An error occurred while loading quotations.', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@bp.route('/user/<int:user_id>')
|
||||
@login_required
|
||||
def user_detail(user_id):
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
quotations = Quotation.query.filter_by(user_id=user_id).order_by(
|
||||
desc(Quotation.created_at)
|
||||
).all()
|
||||
|
||||
return render_template('admin/user_detail.html',
|
||||
user=user,
|
||||
quotations=quotations
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin user detail error: {str(e)}')
|
||||
flash('An error occurred while loading user details.', 'error')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
@bp.route('/quotation/<int:id>')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='admin_quotation_detail')
|
||||
def quotation_detail(id):
|
||||
try:
|
||||
quotation = Quotation.query.get_or_404(id)
|
||||
return render_template('admin/quotation_detail.html',
|
||||
quotation=quotation
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin quotation detail error: {str(e)}')
|
||||
flash('An error occurred while loading quotation details.', 'error')
|
||||
return redirect(url_for('admin.quotations'))
|
||||
|
||||
@bp.route('/user/<int:user_id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_user(user_id):
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
user.is_active = not user.is_active
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'is_active': user.is_active
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin toggle user error: {str(e)}')
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred while updating user status'
|
||||
}), 500
|
||||
|
||||
@bp.route('/delete_user', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def delete_user():
|
||||
if not request.is_json:
|
||||
return jsonify({'error': 'Invalid request format'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'User ID is required'}), 400
|
||||
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.id == current_user.id:
|
||||
return jsonify({'error': 'Cannot delete your own account'}), 400
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(users)
|
||||
current_app.logger.info(f'User {user.email} deleted by admin {current_user.email}')
|
||||
return jsonify({'message': 'User deleted successfully'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Error deleting user: {str(e)}')
|
||||
return jsonify({'error': 'An error occurred while deleting the user'}), 500
|
||||
|
||||
@bp.route('/delete_quotation', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def delete_quotation():
|
||||
if not request.is_json:
|
||||
return jsonify({'error': 'Invalid request format'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
quotation_id = data.get('quotation_id')
|
||||
|
||||
if not quotation_id:
|
||||
return jsonify({'error': 'Quotation ID is required'}), 400
|
||||
|
||||
try:
|
||||
quotation = Quotation.query.get_or_404(quotation_id)
|
||||
db.session.delete(quotation)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(quotations)
|
||||
current_app.logger.info(f'Quotation {quotation.id} deleted by admin {current_user.email}')
|
||||
return jsonify({'message': 'Quotation deleted successfully'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Error deleting quotation: {str(e)}')
|
||||
return jsonify({'error': 'An error occurred while deleting the quotation'}), 500
|
||||
|
||||
@bp.route('/cost-factors')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def cost_factors():
|
||||
"""Display and manage cost factors"""
|
||||
try:
|
||||
from ..utils.cost_factor_loader import initialize_default_cost_factors
|
||||
from flask import current_app
|
||||
|
||||
# Add debug logging
|
||||
current_app.logger.info("Accessing cost_factors route")
|
||||
|
||||
# Verify database connection
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Database connection error: {str(e)}")
|
||||
flash('Database connection error', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Initialize default factors if none exist
|
||||
try:
|
||||
initialize_default_cost_factors()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error initializing default cost factors: {str(e)}")
|
||||
flash('Error initializing cost factors', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Get all cost factors grouped by category
|
||||
try:
|
||||
mold_factors = CostFactor.query.filter_by(category='mold', is_active=True).order_by(CostFactor.name).all()
|
||||
complexity_factors = CostFactor.query.filter_by(category='complexity', is_active=True).order_by(CostFactor.name).all()
|
||||
texture_factors = CostFactor.query.filter_by(category='texture', is_active=True).order_by(CostFactor.name).all()
|
||||
tax_factors = CostFactor.query.filter_by(category='tax', is_active=True).order_by(CostFactor.name).all()
|
||||
base_factors = CostFactor.query.filter_by(category='base', is_active=True).order_by(CostFactor.name).all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error querying cost factors: {str(e)}")
|
||||
flash('Error loading cost factors from database', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Log the number of factors found
|
||||
current_app.logger.info(f"Found factors - Mold: {len(mold_factors)}, Complexity: {len(complexity_factors)}, "
|
||||
f"Texture: {len(texture_factors)}, Tax: {len(tax_factors)}, Base: {len(base_factors)}")
|
||||
|
||||
# Log individual factors for debugging
|
||||
current_app.logger.debug("Base Factors: %s", [f"{f.name}: {f.factor_value}" for f in base_factors])
|
||||
current_app.logger.debug("Mold Factors: %s", [f"{f.name}: {f.factor_value}" for f in mold_factors])
|
||||
current_app.logger.debug("Complexity Factors: %s", [f"{f.name}: {f.factor_value}" for f in complexity_factors])
|
||||
current_app.logger.debug("Texture Factors: %s", [f"{f.name}: {f.factor_value}" for f in texture_factors])
|
||||
current_app.logger.debug("Tax Factors: %s", [f"{f.name}: {f.factor_value}" for f in tax_factors])
|
||||
|
||||
# Prepare template context
|
||||
template_context = {
|
||||
'mold_factors': mold_factors,
|
||||
'complexity_factors': complexity_factors,
|
||||
'texture_factors': texture_factors,
|
||||
'tax_factors': tax_factors,
|
||||
'base_factors': base_factors
|
||||
}
|
||||
|
||||
# Log template context
|
||||
current_app.logger.debug("Template context: %s", template_context)
|
||||
|
||||
try:
|
||||
response = make_response(render_template(
|
||||
'admin/cost_factors.html',
|
||||
**template_context
|
||||
))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Template rendering error: {str(e)}")
|
||||
flash('Error rendering the page', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Add headers to prevent caching
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Unhandled error in cost_factors route: {str(e)}")
|
||||
flash('An unexpected error occurred while loading cost factors.', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
@bp.route('/cost-factors/edit/<int:factor_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def edit_cost_factor(factor_id):
|
||||
"""Edit a specific cost factor"""
|
||||
try:
|
||||
factor = CostFactor.query.get_or_404(factor_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
old_value = factor.factor_value
|
||||
new_value = float(request.form.get('factor_value'))
|
||||
change_reason = request.form.get('change_reason', '')
|
||||
|
||||
# Update the factor
|
||||
factor.factor_value = new_value
|
||||
factor.updated_by = current_user.id
|
||||
|
||||
# Create history record
|
||||
history = CostFactorHistory(
|
||||
cost_factor_id=factor.id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_by=current_user.id,
|
||||
change_reason=change_reason
|
||||
)
|
||||
|
||||
db.session.add(history)
|
||||
db.session.commit()
|
||||
|
||||
# Clear the cache
|
||||
cache.delete_memoized(cost_factors)
|
||||
cache.delete('admin_cost_factors')
|
||||
|
||||
flash(f'Cost factor "{factor.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
return render_template('admin/edit_cost_factor.html', factor=factor)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin edit cost factor error: {str(e)}')
|
||||
flash('An error occurred while editing the cost factor.', 'error')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
@bp.route('/cost-factors/history/<int:factor_id>')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def cost_factor_history(factor_id):
|
||||
"""View history of changes for a cost factor"""
|
||||
try:
|
||||
factor = CostFactor.query.get_or_404(factor_id)
|
||||
history = CostFactorHistory.query.filter_by(cost_factor_id=factor_id)\
|
||||
.order_by(CostFactorHistory.changed_at.desc()).all()
|
||||
|
||||
return render_template('admin/cost_factor_history.html',
|
||||
factor=factor,
|
||||
history=history
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin cost factor history error: {str(e)}')
|
||||
flash('An error occurred while loading cost factor history.', 'error')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
@bp.route('/cost-factors/reset', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def reset_cost_factors():
|
||||
"""Reset cost factors to default values"""
|
||||
try:
|
||||
from ..utils.cost_factor_loader import get_default_cost_factors
|
||||
|
||||
# Get default values
|
||||
default_factors, default_tax_rates, default_base = get_default_cost_factors()
|
||||
|
||||
# Create a mapping of all default values
|
||||
default_values = {}
|
||||
|
||||
# Add mold factors
|
||||
for name, value in default_factors['mold'].items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add complexity factors
|
||||
for name, value in default_factors['complexity'].items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add texture factors
|
||||
for name, value in default_factors['texture'].items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add tax factors
|
||||
for name, value in default_tax_rates.items():
|
||||
default_values[name] = value
|
||||
|
||||
# Add base factor
|
||||
default_values['DEFAULT_NET_BASE'] = default_base
|
||||
|
||||
# Update existing cost factors to default values
|
||||
for factor in CostFactor.query.all():
|
||||
if factor.name in default_values:
|
||||
old_value = factor.factor_value
|
||||
new_value = default_values[factor.name]
|
||||
|
||||
if old_value != new_value: # Only update if value changed
|
||||
factor.factor_value = new_value
|
||||
factor.updated_by = current_user.id
|
||||
|
||||
# Create history record for the reset
|
||||
history = CostFactorHistory(
|
||||
cost_factor_id=factor.id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_by=current_user.id,
|
||||
change_reason='Reset to default value'
|
||||
)
|
||||
db.session.add(history)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear the cache
|
||||
cache.delete_memoized(cost_factors)
|
||||
cache.delete('admin_cost_factors')
|
||||
|
||||
flash('Cost factors have been reset to default values.', 'success')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Admin reset cost factors error: {str(e)}')
|
||||
db.session.rollback()
|
||||
flash('An error occurred while resetting cost factors.', 'error')
|
||||
return redirect(url_for('admin.cost_factors'))
|
||||
|
||||
@bp.route('/debug-endpoints')
|
||||
def debug_endpoints():
|
||||
from flask import current_app
|
||||
with open('logs/endpoints.log', 'w') as f:
|
||||
for rule in current_app.url_map.iter_rules():
|
||||
f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
|
||||
return 'Endpoints written to logs/endpoints.log', 200
|
||||
@@ -0,0 +1,171 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.forms import LoginForm, RegistrationForm, RequestPasswordResetForm, ResetPasswordForm
|
||||
from app import create_app, db
|
||||
from app import limiter
|
||||
from datetime import datetime, timezone
|
||||
from app.config import ALLOWED_EMAILS
|
||||
import logging
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.models import User
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("5 per minute")
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
email = form.email.data.strip().lower()
|
||||
password = form.password.data
|
||||
|
||||
# Log the login attempt
|
||||
current_app.logger.info(f'Login attempt for email: {email}')
|
||||
|
||||
user = User.authenticate(email, password)
|
||||
if user:
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f'User {user.email} logged in successfully')
|
||||
next_page = request.args.get('next') or url_for('main.calculator')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
current_app.logger.warning(f'Failed login attempt for email: {email}')
|
||||
flash('Invalid email or password', 'danger')
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Login error: {str(e)}', exc_info=True)
|
||||
flash('An error occurred during login. Please try again.', 'danger')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit(lambda: None if current_app.config.get('DEBUG', False) else "10 per hour")
|
||||
def register():
|
||||
try:
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
form = RegistrationForm()
|
||||
logger.info('Registration form initialized')
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
email = form.email.data.strip().lower()
|
||||
username = form.username.data.strip().lower()
|
||||
company = form.company.data.strip().upper()
|
||||
logger.info(f'Processing registration for email: {email}, username: {username}, company: {company}')
|
||||
|
||||
# Check if email is in allowed list or has adidas.com domain
|
||||
if email not in ALLOWED_EMAILS and not email.endswith('@adidas.com'):
|
||||
logger.warning(f'Registration attempt with non-allowed email: {email}')
|
||||
flash('Registration is not allowed for this email domain', 'danger')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
try:
|
||||
logger.info('Creating new user...')
|
||||
user = User.create(
|
||||
username=username,
|
||||
email=email,
|
||||
company=company,
|
||||
password=form.password.data
|
||||
)
|
||||
logger.info('User created, committing to database...')
|
||||
db.session.commit()
|
||||
logger.info(f'New user registered successfully: {user.email}')
|
||||
flash('Registration successful! Please login', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
except ValueError as e:
|
||||
logger.warning(f'Registration validation error: {str(e)}')
|
||||
flash(str(e), 'danger')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f'Registration error: {str(e)}', exc_info=True)
|
||||
flash('An error occurred during registration', 'danger')
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error during registration: {str(e)}', exc_info=True)
|
||||
flash('An unexpected error occurred', 'danger')
|
||||
elif form.errors:
|
||||
logger.warning(f'Form validation errors: {form.errors}')
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
flash(f'{field}: {error}', 'danger')
|
||||
logger.warning(f'Field {field} validation error: {error}')
|
||||
|
||||
logger.info('Rendering registration template')
|
||||
return render_template('auth/register.html', form=form)
|
||||
except Exception as e:
|
||||
logger.error(f'Critical error in registration route: {str(e)}', exc_info=True)
|
||||
flash('A critical error occurred', 'danger')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
current_app.logger.info(f'User {current_user.email} logged out')
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('main.home'))
|
||||
|
||||
@bp.route('/request-reset', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per hour")
|
||||
def request_reset():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
|
||||
form = RequestPasswordResetForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
email = form.email.data.strip().lower()
|
||||
user = User.query.filter_by(email=email).first()
|
||||
|
||||
if user:
|
||||
token = user.generate_reset_token()
|
||||
db.session.commit()
|
||||
|
||||
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
||||
flash(f'Your password reset link is: {reset_url}', 'info')
|
||||
flash('This link will expire in 15 minutes.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Always show success message to prevent email enumeration
|
||||
flash('If an account exists with that email, you will receive password reset instructions.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error generating reset token: {str(e)}', exc_info=True)
|
||||
flash('An error occurred while processing your request.', 'danger')
|
||||
|
||||
return render_template('auth/request_reset.html', form=form)
|
||||
|
||||
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per hour")
|
||||
def reset_password(token):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
|
||||
form = ResetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
if not user or not user.verify_reset_token(token):
|
||||
flash('The password reset link is invalid or has expired.', 'danger')
|
||||
return redirect(url_for('auth.request_reset'))
|
||||
|
||||
user.set_password(form.password.data)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f'Password reset successful for user {user.email}')
|
||||
flash('Your password has been reset. You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error resetting password: {str(e)}', exc_info=True)
|
||||
flash('An error occurred while resetting your password.', 'danger')
|
||||
|
||||
return render_template('auth/reset_password.html', form=form)
|
||||
@@ -0,0 +1,383 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, current_app, flash
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from app.models import Quotation, User
|
||||
from app import db
|
||||
from app.forms import LoginForm, QuotationForm
|
||||
from app.utils.cost_factor_loader import get_cost_factors
|
||||
from app.config import ALLOWED_EMAILS
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app import limiter, cache
|
||||
import json
|
||||
from ..utils.calculator import calculate_base_price
|
||||
from ..health import get_health_status
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/', methods=['GET', 'POST'])
|
||||
def home():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.calculator'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@bp.route('/calculator')
|
||||
@login_required
|
||||
def calculator():
|
||||
try:
|
||||
form = QuotationForm()
|
||||
|
||||
# Set today's date as default for issue_date
|
||||
if not form.issue_date.data:
|
||||
form.issue_date.data = datetime.now(timezone.utc).date()
|
||||
|
||||
# Get cost factors for the form
|
||||
cost_factors, tax_rates, default_net_base = get_cost_factors()
|
||||
|
||||
# Initialize form with default values
|
||||
if form.mold_main_type.data in form.MOLD_TYPE_MAPPING:
|
||||
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[form.mold_main_type.data]]
|
||||
|
||||
# Get quote history with error handling
|
||||
try:
|
||||
quote_history = Quotation.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Quotation.created_at.desc())\
|
||||
.limit(5)\
|
||||
.all()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching quote history: {str(e)}")
|
||||
quote_history = []
|
||||
|
||||
return render_template('calculator.html',
|
||||
form=form,
|
||||
quote_history=quote_history,
|
||||
tax_rates=tax_rates,
|
||||
quotation=None)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Calculator error: {str(e)}", exc_info=True)
|
||||
flash('Error loading calculator data', 'danger')
|
||||
# Create a new form with default values
|
||||
form = QuotationForm()
|
||||
if form.mold_main_type.data in form.MOLD_TYPE_MAPPING:
|
||||
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[form.mold_main_type.data]]
|
||||
# Set today's date as default even in error case
|
||||
form.issue_date.data = datetime.now(timezone.utc).date()
|
||||
return render_template('calculator.html',
|
||||
form=form,
|
||||
quote_history=[],
|
||||
tax_rates={},
|
||||
quotation=None)
|
||||
|
||||
@bp.route('/calculate', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=300, key_prefix='calculate_form')
|
||||
def calculate():
|
||||
"""Calculate mold cost based on form data"""
|
||||
try:
|
||||
form = QuotationForm()
|
||||
|
||||
# Log incoming request data
|
||||
current_app.logger.info('Incoming request data:')
|
||||
current_app.logger.info(f'Request method: {request.method}')
|
||||
current_app.logger.info(f'Request form data: {dict(request.form)}')
|
||||
current_app.logger.info(f'Request JSON data: {request.get_json(silent=True)}')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# Log form data before validation
|
||||
current_app.logger.info('Form data before validation:')
|
||||
current_app.logger.info(f'Form data: {form.data}')
|
||||
current_app.logger.info(f'Form errors: {form.errors}')
|
||||
|
||||
# Log the selected mold type
|
||||
current_app.logger.info(f'Selected mold_main_type: {form.mold_main_type.data}')
|
||||
current_app.logger.info(f'Selected mold_sub_type: {form.mold_sub_type.data}')
|
||||
current_app.logger.info(f'Selected mold_specific_type: {form.mold_specific_type.data}')
|
||||
|
||||
# Update mold_sub_type choices based on selected mold_main_type
|
||||
main_type = request.form.get('mold_main_type')
|
||||
current_app.logger.info(f'Updating sub_type choices for main_type: {main_type}')
|
||||
if main_type in form.MOLD_TYPE_MAPPING:
|
||||
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[main_type]]
|
||||
current_app.logger.info(f'Updated sub_type choices: {form.mold_sub_type.choices}')
|
||||
|
||||
# Update mold_specific_type choices based on selected mold_sub_type
|
||||
sub_type = request.form.get('mold_sub_type')
|
||||
if sub_type in form.MOLD_SPECIFIC_TYPE_OPTIONS:
|
||||
form.mold_specific_type.choices = [('', 'Select Specific Type')] + [(x, x) for x in form.MOLD_SPECIFIC_TYPE_OPTIONS[sub_type]]
|
||||
else:
|
||||
form.mold_specific_type.choices = [('', 'Select Specific Type')]
|
||||
|
||||
# Check if specific type is needed
|
||||
if sub_type and sub_type in form.MOLD_SPECIFIC_TYPE_OPTIONS:
|
||||
# If specific type is needed but not provided, set it to empty string
|
||||
if not request.form.get('mold_specific_type'):
|
||||
form.mold_specific_type.data = ''
|
||||
else:
|
||||
# If specific type is not needed, set it to empty string
|
||||
form.mold_specific_type.data = ''
|
||||
|
||||
if not form.validate_on_submit():
|
||||
current_app.logger.warning('Form validation failed:')
|
||||
current_app.logger.warning(f'Form errors: {form.errors}')
|
||||
current_app.logger.warning(f'Form data after validation: {form.data}')
|
||||
current_app.logger.warning(f'Request form data: {dict(request.form)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid form data',
|
||||
'message': 'Please check the form for errors',
|
||||
'errors': form.errors,
|
||||
'form_data': dict(request.form)
|
||||
}), 400
|
||||
|
||||
# Get form data
|
||||
form_data = form.data
|
||||
current_app.logger.info(f'Processing form data: {form_data}')
|
||||
|
||||
# Calculate costs
|
||||
try:
|
||||
result = calculate_base_price(
|
||||
mold_main_type=form.mold_main_type.data,
|
||||
mold_sub_type=form.mold_sub_type.data,
|
||||
mold_specific_type=form.mold_specific_type.data,
|
||||
sliders_count=int(form.sliders_count.data),
|
||||
cavity_count=float(form.cavity_count.data),
|
||||
digital_texture_type=form.digital_texture_type.data,
|
||||
complexity_high_sidewall=(form.complexity_high_sidewall.data == 'yes')
|
||||
)
|
||||
current_app.logger.info(f'Calculation result: {result}')
|
||||
|
||||
# Get cost factors for tax rate
|
||||
cost_factors, tax_rates, default_net_base = get_cost_factors()
|
||||
|
||||
# Get tax rate for the country
|
||||
tax_rate = tax_rates.get(form.mold_shop_country.data, 0.0)
|
||||
|
||||
# Calculate total cost (base price + tax)
|
||||
total_cost = result['total_cost'] * (1 + tax_rate)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'base_price': float(result['base_price']),
|
||||
'complexity_cost': float(result['complexity_cost']),
|
||||
'texture_cost': float(result['texture_cost']),
|
||||
'tax_rate': float(tax_rate),
|
||||
'total_cost': float(total_cost),
|
||||
'mold_shop_name': form.mold_shop_name.data,
|
||||
'mold_shop_country': form.mold_shop_country.data,
|
||||
't1_shoe_factory_name': form.t1_shoe_factory_name.data,
|
||||
't1_shoe_factory_country': form.t1_shoe_factory_country.data,
|
||||
't2_component_factory_name': form.t2_component_factory_name.data,
|
||||
't2_component_factory_country': form.t2_component_factory_country.data,
|
||||
'model_name': form.model_name.data,
|
||||
'tooling_id': form.tooling_id.data,
|
||||
'season': form.season.data,
|
||||
'stage': form.stage.data,
|
||||
'type': form.type.data,
|
||||
'issue_date': form.issue_date.data.strftime('%Y-%m-%d'),
|
||||
'approved_by_mold_shop': form.approved_by_mold_shop.data,
|
||||
'approved_by_t1_tooling': form.approved_by_t1_tooling.data,
|
||||
'approved_by_lo_tooling': form.approved_by_lo_tooling.data,
|
||||
'mold_main_type': form.mold_main_type.data,
|
||||
'mold_sub_type': form.mold_sub_type.data,
|
||||
'mold_specific_type': form.mold_specific_type.data,
|
||||
'complexity_high_sidewall': 'yes' if form.complexity_high_sidewall.data == 'yes' else 'no',
|
||||
'sliders_count': form.sliders_count.data,
|
||||
'cavity_count': form.cavity_count.data,
|
||||
'digital_texture_type': form.digital_texture_type.data
|
||||
})
|
||||
except Exception as calc_error:
|
||||
current_app.logger.error(f'Error calculating costs: {str(calc_error)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Calculation error',
|
||||
'message': str(calc_error)
|
||||
}), 500
|
||||
|
||||
except Exception as form_error:
|
||||
current_app.logger.error(f'Error processing form: {str(form_error)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Form processing error',
|
||||
'message': str(form_error)
|
||||
}), 500
|
||||
|
||||
# GET request - render the form
|
||||
return render_template('calculator.html', form=form)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Unexpected error in calculate route: {str(e)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Unexpected error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@bp.route('/quotations')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='user_quotations')
|
||||
def quotations():
|
||||
quotations = Quotation.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Quotation.created_at.desc()).all()
|
||||
return render_template('quotations.html', quotations=quotations)
|
||||
|
||||
@bp.route('/quotation/<int:id>')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
@cache.cached(timeout=60, key_prefix='quotation_detail')
|
||||
def quotation_detail(id):
|
||||
quotation = Quotation.query.get_or_404(id)
|
||||
if quotation.user_id != current_user.id and not current_user.is_admin:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('main.quotations'))
|
||||
return render_template('quotation_detail.html', quotation=quotation)
|
||||
|
||||
@bp.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint that returns the status of various system components."""
|
||||
# Get health status
|
||||
health_status = get_health_status()
|
||||
|
||||
# Set appropriate status code based on overall health
|
||||
status_code = 200 if health_status['status'] == 'healthy' else 503
|
||||
|
||||
# Add rate limiting headers
|
||||
response = jsonify(health_status)
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
|
||||
return response, status_code
|
||||
|
||||
@bp.route('/api/recent-quotes')
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def recent_quotes():
|
||||
try:
|
||||
recent_quotes = Quotation.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Quotation.created_at.desc())\
|
||||
.limit(5)\
|
||||
.all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'quotes': [{
|
||||
'id': quote.id,
|
||||
'model_name': quote.model_name,
|
||||
'tooling_id': quote.tooling_id,
|
||||
'mold_sub_type': quote.mold_sub_type,
|
||||
'cavity_count': float(quote.cavity_count),
|
||||
'sliders_count': quote.sliders_count,
|
||||
'created_at': quote.created_at.strftime('%Y-%m-%d %H:%M'),
|
||||
'mold_shop_country': quote.mold_shop_country,
|
||||
'digital_texture_type': quote.digital_texture_type,
|
||||
'season': quote.season,
|
||||
'stage': quote.stage,
|
||||
'base_price': float(quote.base_price),
|
||||
'tax_rate': float(quote.tax_rate) if quote.tax_rate is not None else 0.0,
|
||||
'total_cost': float(quote.total_cost)
|
||||
} for quote in recent_quotes]
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error fetching recent quotes: {str(e)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'An error occurred while fetching recent quotes',
|
||||
'quotes': []
|
||||
}), 500
|
||||
|
||||
@bp.route('/save-quotation', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def save_quotation():
|
||||
"""Save a quotation to the database"""
|
||||
try:
|
||||
# Get form data
|
||||
form_data = request.get_json()
|
||||
|
||||
if not form_data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No data provided'
|
||||
}), 400
|
||||
|
||||
# Check for duplicate submission (same user, same data within last 30 seconds)
|
||||
try:
|
||||
recent_duplicate = Quotation.query.filter(
|
||||
Quotation.user_id == current_user.id,
|
||||
Quotation.model_name == form_data.get('model_name'),
|
||||
Quotation.tooling_id == form_data.get('tooling_id'),
|
||||
Quotation.mold_main_type == form_data.get('mold_main_type'),
|
||||
Quotation.mold_sub_type == form_data.get('mold_sub_type'),
|
||||
Quotation.created_at >= datetime.now(timezone.utc) - timedelta(seconds=30)
|
||||
).first()
|
||||
|
||||
if recent_duplicate:
|
||||
current_app.logger.warning(f'Duplicate submission detected for user {current_user.id}, tooling_id {form_data.get("tooling_id")}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Duplicate submission',
|
||||
'message': 'This quotation was already submitted recently. Please wait a moment before trying again.',
|
||||
'quotation_id': recent_duplicate.id
|
||||
}), 409
|
||||
except Exception as duplicate_check_error:
|
||||
current_app.logger.error(f'Error checking for duplicates: {str(duplicate_check_error)}')
|
||||
# Continue with submission even if duplicate check fails
|
||||
|
||||
# Ensure mold_specific_type is not empty or missing
|
||||
mold_specific_type = form_data.get('mold_specific_type')
|
||||
if not mold_specific_type:
|
||||
mold_specific_type = 'N/A'
|
||||
|
||||
# Create new quotation
|
||||
quotation = Quotation(
|
||||
user_id=current_user.id,
|
||||
mold_shop_name=form_data.get('mold_shop_name'),
|
||||
mold_shop_country=form_data.get('mold_shop_country'),
|
||||
t1_shoe_factory_name=form_data.get('t1_shoe_factory_name'),
|
||||
t1_shoe_factory_country=form_data.get('t1_shoe_factory_country'),
|
||||
t2_component_factory_name=form_data.get('t2_component_factory_name'),
|
||||
t2_component_factory_country=form_data.get('t2_component_factory_country'),
|
||||
model_name=form_data.get('model_name'),
|
||||
tooling_id=form_data.get('tooling_id'),
|
||||
season=form_data.get('season'),
|
||||
stage=form_data.get('stage'),
|
||||
type=form_data.get('type'),
|
||||
issue_date=datetime.strptime(form_data.get('issue_date'), '%Y-%m-%d').date(),
|
||||
approved_by_mold_shop=form_data.get('approved_by_mold_shop'),
|
||||
approved_by_t1_tooling=form_data.get('approved_by_t1_tooling'),
|
||||
approved_by_lo_tooling=form_data.get('approved_by_lo_tooling'),
|
||||
mold_main_type=form_data.get('mold_main_type'),
|
||||
mold_sub_type=form_data.get('mold_sub_type'),
|
||||
mold_specific_type=mold_specific_type,
|
||||
complexity_high_sidewall=(form_data.get('complexity_high_sidewall') == 'yes'),
|
||||
sliders_count=int(form_data.get('sliders_count')),
|
||||
cavity_count=float(form_data.get('cavity_count')),
|
||||
digital_texture_type=form_data.get('digital_texture_type'),
|
||||
base_price=float(form_data.get('base_price')),
|
||||
tax_rate=float(form_data.get('tax_rate')),
|
||||
total_cost=float(form_data.get('total_cost'))
|
||||
)
|
||||
|
||||
# Save to database
|
||||
db.session.add(quotation)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Quotation saved with ID: {quotation.id}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'quotation_id': quotation.id,
|
||||
'message': 'Quotation saved successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Error saving quotation: {str(e)}', exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to save quotation',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
@@ -0,0 +1,197 @@
|
||||
/* Admin Panel Global Styles */
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--info-color: #17a2b8;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #343a40;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.admin-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
background-color: var(--light-color);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-table thead th {
|
||||
vertical-align: bottom;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
background-color: var(--light-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.admin-form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.admin-btn {
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
color: #fff;
|
||||
background-color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-btn-secondary {
|
||||
color: #fff;
|
||||
background-color: var(--secondary-color);
|
||||
border: 1px solid var(--secondary-color);
|
||||
}
|
||||
|
||||
.admin-btn-info {
|
||||
color: #fff;
|
||||
background-color: var(--info-color);
|
||||
border: 1px solid var(--info-color);
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.admin-stats-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-stats-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-stats-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-stats-label {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.admin-filters {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.admin-filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-pagination-link {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-pagination-link:hover {
|
||||
background-color: var(--light-color);
|
||||
}
|
||||
|
||||
.admin-pagination-info {
|
||||
color: var(--secondary-color);
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.admin-filter-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
+2078
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
/* Calculator Page Styles */
|
||||
.container-main {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.calculator-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid #0d6efd;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
background-color: #ffffff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-select, .form-control {
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
.form-select:focus, .form-control:focus {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0b5ed7;
|
||||
border-color: #0a58ca;
|
||||
}
|
||||
|
||||
/* Form Text */
|
||||
.form-text {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container-main {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.col-md-3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #3b82f6;
|
||||
--success-color: #22c55e;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--bg-light: #f8fafc;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-light);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container-main {
|
||||
max-width: 1280px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Responsive Navigation */
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
transition: all 0.2s ease;
|
||||
padding: 0.5rem 1rem !important;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Calculator Form */
|
||||
.calculator-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.input-group-3d {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-group-3d input,
|
||||
.input-group-3d select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-group-3d input:focus,
|
||||
.input-group-3d select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Result Panel */
|
||||
.result-card {
|
||||
border-left: 4px solid var(--success-color);
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Mobile Optimization */
|
||||
@media (max-width: 768px) {
|
||||
.calculator-card {
|
||||
margin: 1rem -0.5rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--primary-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
建议配合以下CSS样式(添加到base模板中):
|
||||
|
||||
|
||||
.input-group-text {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.progress-bar {
|
||||
transition: width 0.5s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Cost Breakdown Visual Enhancement */
|
||||
.cost-breakdown {
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 12px 0 rgba(37,99,235,0.08);
|
||||
padding: 2rem 1.5rem 1.5rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.cost-breakdown .cost-item {
|
||||
border: 1.5px solid #e0e7ef;
|
||||
box-shadow: 0 1px 4px 0 rgba(37,99,235,0.04);
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cost-breakdown .cost-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 16px 0 rgba(37,99,235,0.10);
|
||||
background: #f1f5fd;
|
||||
}
|
||||
.cost-breakdown .h4.text-primary {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.cost-breakdown .small.text-muted {
|
||||
font-size: 0.98rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.cost-breakdown .total-cost {
|
||||
background: linear-gradient(90deg, #e0e7ef 0%, #f8fafc 100%);
|
||||
border-width: 2.5px;
|
||||
box-shadow: 0 2px 8px 0 rgba(37,99,235,0.10);
|
||||
}
|
||||
.cost-breakdown .total-cost span {
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="16" cy="16" r="16" fill="#2C3E50"/>
|
||||
|
||||
<!-- Calculator body -->
|
||||
<rect x="8" y="6" width="16" height="20" rx="2" fill="#3498DB"/>
|
||||
|
||||
<!-- Calculator screen -->
|
||||
<rect x="10" y="8" width="12" height="4" rx="1" fill="#ECF0F1"/>
|
||||
|
||||
<!-- Calculator buttons -->
|
||||
<rect x="10" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="15" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="20" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
|
||||
<rect x="10" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="15" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="20" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
|
||||
<rect x="10" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="15" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
<rect x="20" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+7
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+2607
File diff suppressed because one or more lines are too long
Executable
+638
@@ -0,0 +1,638 @@
|
||||
// static/js/mold_calculator.js
|
||||
// Check if script is already initialized
|
||||
if (window.moldCalculatorInitialized) {
|
||||
console.log('Mold calculator already initialized, skipping...');
|
||||
} else {
|
||||
window.moldCalculatorInitialized = true;
|
||||
|
||||
// Wait for DOM to be fully loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeCalculator);
|
||||
} else {
|
||||
initializeCalculator();
|
||||
}
|
||||
}
|
||||
|
||||
function initializeCalculator() {
|
||||
console.log('DOM Content Loaded - Initializing mold calculator');
|
||||
|
||||
// Get form elements
|
||||
const form = document.getElementById('calcForm');
|
||||
const mainSelect = document.getElementById('mold_main_type');
|
||||
const subSelect = document.getElementById('mold_sub_type');
|
||||
const specificTypeSelect = document.getElementById('mold_specific_type');
|
||||
const stageSelect = document.getElementById('stage');
|
||||
const typeSelect = document.getElementById('type');
|
||||
const toolingIdInput = document.getElementById('tooling_id');
|
||||
const submitButton = document.getElementById('calculateBtn');
|
||||
|
||||
console.log('Main select element:', mainSelect);
|
||||
console.log('Sub select element:', subSelect);
|
||||
console.log('Specific type select element:', specificTypeSelect);
|
||||
console.log('Stage select element:', stageSelect);
|
||||
console.log('Type select element:', typeSelect);
|
||||
console.log('Tooling ID input:', toolingIdInput);
|
||||
console.log('Submit button:', submitButton);
|
||||
|
||||
// Check if submit button exists
|
||||
if (!submitButton) {
|
||||
console.error('Submit button not found! Looking for element with ID "calculateBtn"');
|
||||
// Try alternative selectors
|
||||
const alternativeButton = form.querySelector('button[type="submit"]');
|
||||
if (alternativeButton) {
|
||||
console.log('Found submit button using alternative selector:', alternativeButton);
|
||||
} else {
|
||||
console.error('No submit button found with any selector!');
|
||||
return; // Exit initialization if we can't find the submit button
|
||||
}
|
||||
}
|
||||
|
||||
// Define mappings
|
||||
const moldTypeMapping = {
|
||||
'Rubber': ['Flat Outsole', 'Cupsole', 'Rubber Sole'],
|
||||
'CMEVA': ['2 Plates Mold', '3 Plates Mold', '2 Plates with in-mold channel', 'Breathable + in-mold channel'],
|
||||
'IMEVA': ['2 Plates Mold', '3 Plates Mold'],
|
||||
'Sandal': ['Sandal with Upper Bandage', '2 Plate without Upper Bandage'],
|
||||
'Co-shot mold': ['Co-shot mold', 'Foaming mold'],
|
||||
'PU': ['2 Plates Mold', '3 Plates Mold']
|
||||
};
|
||||
|
||||
const specificTypeOptions = {
|
||||
'Flat Outsole': ['Flat', 'Flat with Top/Heel Tip'],
|
||||
'Cupsole': ['Top PL', 'Visible PL'],
|
||||
'Rubber Sole': ['Top PL', 'Short PL (Heel/Forefoot)', 'Visible PL', 'Wave PL']
|
||||
};
|
||||
|
||||
const stageTypeMapping = {
|
||||
'CR0': ['Sample'],
|
||||
'CR1': ['Sample'],
|
||||
'CR2': ['Sample'],
|
||||
'SMS': ['A Set'],
|
||||
'commercialization': ['A Set'],
|
||||
'production': ['Additional']
|
||||
};
|
||||
|
||||
// Function to update sub-type options
|
||||
function updateSubTypeOptions() {
|
||||
console.log('Populating sub-types');
|
||||
const selectedType = mainSelect.value;
|
||||
console.log('Selected type:', selectedType);
|
||||
|
||||
// Clear current options
|
||||
subSelect.innerHTML = '<option value="">Select Sub Type</option>';
|
||||
|
||||
if (selectedType && moldTypeMapping[selectedType]) {
|
||||
console.log('Found mapping for type:', selectedType);
|
||||
const options = moldTypeMapping[selectedType];
|
||||
console.log('Adding options:', options);
|
||||
|
||||
options.forEach(option => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option;
|
||||
opt.textContent = option;
|
||||
subSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset specific type
|
||||
specificTypeSelect.innerHTML = '<option value="">Select Specific Type</option>';
|
||||
specificTypeSelect.disabled = true;
|
||||
}
|
||||
|
||||
// Function to update specific type options
|
||||
function updateSpecificTypeOptions() {
|
||||
const selectedSubType = subSelect.value;
|
||||
console.log('Selected sub-type:', selectedSubType);
|
||||
|
||||
// Clear current options
|
||||
specificTypeSelect.innerHTML = '<option value="">Select Specific Type</option>';
|
||||
|
||||
if (selectedSubType && specificTypeOptions[selectedSubType]) {
|
||||
console.log('Found specific type options for:', selectedSubType);
|
||||
const options = specificTypeOptions[selectedSubType];
|
||||
console.log('Adding specific type options:', options);
|
||||
|
||||
options.forEach(option => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option;
|
||||
opt.textContent = option;
|
||||
specificTypeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
specificTypeSelect.disabled = false;
|
||||
} else {
|
||||
specificTypeSelect.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update type options based on stage
|
||||
function updateTypeOptions() {
|
||||
const selectedStage = stageSelect.value;
|
||||
console.log('Selected stage:', selectedStage);
|
||||
|
||||
// Clear current options
|
||||
typeSelect.innerHTML = '<option value="">Select Type</option>';
|
||||
|
||||
if (selectedStage && stageTypeMapping[selectedStage]) {
|
||||
const options = stageTypeMapping[selectedStage];
|
||||
console.log('Adding type options:', options);
|
||||
|
||||
options.forEach(option => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option;
|
||||
opt.textContent = option;
|
||||
typeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Enable the type select
|
||||
typeSelect.disabled = false;
|
||||
} else {
|
||||
// Disable the type select if no stage is selected
|
||||
typeSelect.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
mainSelect.addEventListener('change', function() {
|
||||
console.log('Main type changed:', this.value);
|
||||
updateSubTypeOptions();
|
||||
});
|
||||
|
||||
subSelect.addEventListener('change', function() {
|
||||
console.log('Sub type changed:', this.value);
|
||||
updateSpecificTypeOptions();
|
||||
});
|
||||
|
||||
stageSelect.addEventListener('change', function() {
|
||||
console.log('Stage changed:', this.value);
|
||||
updateTypeOptions();
|
||||
});
|
||||
|
||||
// Add tooling ID validation
|
||||
if (toolingIdInput) {
|
||||
console.log('Tooling ID input found, adding validation');
|
||||
toolingIdInput.addEventListener('input', function(e) {
|
||||
console.log('Tooling ID input event triggered, value:', this.value);
|
||||
// Remove any non-numeric characters
|
||||
let value = this.value.replace(/\D/g, '');
|
||||
|
||||
// Limit to 5 digits
|
||||
if (value.length > 5) {
|
||||
value = value.substring(0, 5);
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
console.log('Tooling ID value after validation:', this.value);
|
||||
});
|
||||
|
||||
toolingIdInput.addEventListener('paste', function(e) {
|
||||
console.log('Tooling ID paste event triggered');
|
||||
e.preventDefault();
|
||||
const pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
const numericOnly = pastedText.replace(/\D/g, '').substring(0, 5);
|
||||
this.value = numericOnly;
|
||||
console.log('Tooling ID value after paste validation:', this.value);
|
||||
});
|
||||
} else {
|
||||
console.error('Tooling ID input element not found!');
|
||||
// Try alternative selectors
|
||||
const alternativeSelectors = [
|
||||
'input[name="tooling_id"]',
|
||||
'input[placeholder*="tooling"]',
|
||||
'input[placeholder*="Tooling"]'
|
||||
];
|
||||
|
||||
for (let selector of alternativeSelectors) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
console.log('Found tooling ID input using selector:', selector, element);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize options
|
||||
updateSubTypeOptions();
|
||||
updateTypeOptions();
|
||||
|
||||
// Function to reset form
|
||||
function resetForm(form) {
|
||||
console.log('Resetting form...');
|
||||
|
||||
// Reset all select elements to their first option
|
||||
const selects = form.querySelectorAll('select');
|
||||
selects.forEach(select => {
|
||||
select.selectedIndex = 0;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
// Reset all input elements
|
||||
const inputs = form.querySelectorAll('input');
|
||||
inputs.forEach(input => {
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
// Reset all checkboxes and radio buttons
|
||||
const checkboxes = form.querySelectorAll('input[type="checkbox"], input[type="radio"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
|
||||
// Disable dependent dropdowns
|
||||
if (subSelect) subSelect.disabled = true;
|
||||
if (specificTypeSelect) specificTypeSelect.disabled = true;
|
||||
if (typeSelect) typeSelect.disabled = true;
|
||||
|
||||
// Show reset notification
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'alert alert-info alert-dismissible fade show mt-3';
|
||||
notification.innerHTML = `
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Form has been reset. You can now create a new quotation.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
form.insertBefore(notification, form.firstChild);
|
||||
|
||||
console.log('Form reset complete');
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
console.log('Form submitted');
|
||||
|
||||
// Get submit button robustly
|
||||
let submitButton = document.getElementById('calculateBtn') || form.querySelector('button[type="submit"]');
|
||||
|
||||
if (!submitButton) {
|
||||
console.error('Submit button not found during form submission!');
|
||||
alert('Error: Submit button not found. Please refresh the page and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if submission is already in progress
|
||||
if (submitButton.disabled || form.dataset.submitting === 'true') {
|
||||
console.log('Form submission already in progress or cooldown active, ignoring duplicate click');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set submission state
|
||||
form.dataset.submitting = 'true';
|
||||
|
||||
// Disable submit button and show loading state
|
||||
submitButton.disabled = true;
|
||||
const originalButtonText = submitButton.innerHTML;
|
||||
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Calculating...';
|
||||
submitButton.classList.add('btn-secondary');
|
||||
submitButton.classList.remove('btn-primary');
|
||||
submitButton.title = 'Please wait while calculating...';
|
||||
|
||||
try {
|
||||
// Log all form fields
|
||||
console.log('Form fields:');
|
||||
for (let [key, value] of new FormData(form).entries()) {
|
||||
console.log(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const mainType = mainSelect.value;
|
||||
const subType = subSelect.value;
|
||||
const specificType = specificTypeSelect.value;
|
||||
const stage = stageSelect.value;
|
||||
const type = typeSelect.value;
|
||||
const toolingId = toolingIdInput ? toolingIdInput.value : '';
|
||||
|
||||
console.log('Required fields:', {
|
||||
mainType,
|
||||
subType,
|
||||
specificType,
|
||||
stage,
|
||||
type,
|
||||
toolingId
|
||||
});
|
||||
|
||||
if (!mainType) {
|
||||
alert('Please select a main type');
|
||||
return;
|
||||
}
|
||||
if (!subType) {
|
||||
alert('Please select a sub type');
|
||||
return;
|
||||
}
|
||||
if (!stage) {
|
||||
alert('Please select a stage');
|
||||
return;
|
||||
}
|
||||
if (!type) {
|
||||
alert('Please select a type');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tooling ID
|
||||
if (!toolingId) {
|
||||
alert('Please enter a Tooling ID');
|
||||
return;
|
||||
}
|
||||
if (!/^\d{5}$/.test(toolingId)) {
|
||||
alert('Tooling ID must be exactly 5 digits');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if specific type is required
|
||||
const validSpecificTypes = specificTypeOptions[subType] || [];
|
||||
if (validSpecificTypes.length > 0 && !specificType) {
|
||||
alert('Please select a specific type');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add CSRF token to form data
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
formData.append('csrf_token', csrfToken);
|
||||
|
||||
// Remove specific type if it's not needed
|
||||
if (validSpecificTypes.length === 0) {
|
||||
formData.delete('mold_specific_type');
|
||||
}
|
||||
|
||||
// Log the final form data being sent
|
||||
console.log('Final form data being sent:');
|
||||
for (let [key, value] of formData.entries()) {
|
||||
console.log(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
const response = await fetch('/calculate', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
const responseText = await response.text();
|
||||
console.log('Response text:', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}, body: ${responseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText);
|
||||
console.log('Calculation result:', data);
|
||||
|
||||
// Get the result element
|
||||
const resultElement = document.getElementById('result');
|
||||
|
||||
// Update the result display
|
||||
updateResultsDisplay(data);
|
||||
|
||||
// Save the quotation to the database
|
||||
try {
|
||||
console.log('Saving quotation to database...');
|
||||
const saveResponse = await fetch('/save-quotation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const saveResult = await saveResponse.json();
|
||||
console.log('Save result:', saveResult);
|
||||
|
||||
if (saveResult.success) {
|
||||
console.log('Quotation saved successfully with ID:', saveResult.quotation_id);
|
||||
|
||||
// Show success message
|
||||
const successMessage = document.createElement('div');
|
||||
successMessage.className = 'alert alert-success alert-dismissible fade show';
|
||||
successMessage.innerHTML = `
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
Quotation saved successfully! ID: ${saveResult.quotation_id}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
resultElement.insertBefore(successMessage, resultElement.firstChild);
|
||||
|
||||
// Reset form after successful save
|
||||
resetForm(form);
|
||||
|
||||
// Refresh quote history
|
||||
setTimeout(() => {
|
||||
refreshQuoteHistory();
|
||||
}, 1000);
|
||||
} else if (saveResult.duplicate) {
|
||||
console.log('Duplicate submission detected:', saveResult.message);
|
||||
const duplicateMessage = document.createElement('div');
|
||||
duplicateMessage.className = 'alert alert-warning alert-dismissible fade show';
|
||||
duplicateMessage.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
${saveResult.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
resultElement.insertBefore(duplicateMessage, resultElement.firstChild);
|
||||
|
||||
// Reset form even on duplicate to prevent repeated attempts
|
||||
resetForm(form);
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error('Error saving quotation:', saveError);
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.className = 'alert alert-danger alert-dismissible fade show';
|
||||
errorMessage.innerHTML = `
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
Error saving quotation. Please try again later.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
resultElement.insertBefore(errorMessage, resultElement.firstChild);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while calculating. Please check the console for details.');
|
||||
} finally {
|
||||
// Re-enable submit button and restore original state after a delay
|
||||
setTimeout(() => {
|
||||
let submitButton = document.getElementById('calculateBtn') || form.querySelector('button[type="submit"]');
|
||||
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.innerHTML = originalButtonText;
|
||||
submitButton.classList.remove('btn-secondary');
|
||||
submitButton.classList.add('btn-primary');
|
||||
submitButton.title = 'Calculate mold cost';
|
||||
}
|
||||
|
||||
// Reset submission state
|
||||
form.dataset.submitting = 'false';
|
||||
}, 2000); // 2 second cooldown before allowing new submission
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to update results display
|
||||
function updateResultsDisplay(data) {
|
||||
const resultElement = document.getElementById('result');
|
||||
if (!resultElement) {
|
||||
console.error('Result element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format the results to match the provided screenshot
|
||||
const formattedResults = `
|
||||
<div class="card bg-white shadow-sm p-4 mb-4" style="border-radius: 1rem; border: 1px solid #e3e6ea;">
|
||||
<div class="mb-4 pb-2 border-bottom d-flex align-items-center" style="font-size:1.4rem;font-weight:600;color:#1976d2;">
|
||||
<i class="fas fa-clipboard-list me-2"></i>Calculation Results
|
||||
</div>
|
||||
<!-- Factory Information -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-industry me-2"></i>Factory Information
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100 bg-light border-0">
|
||||
<div class="fw-bold">Mold Shop</div>
|
||||
<div>${data.mold_shop_name || '-'}</div>
|
||||
<div class="text-muted small">${data.mold_shop_country || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100 bg-light border-0">
|
||||
<div class="fw-bold">T1 Factory</div>
|
||||
<div>${data.t1_shoe_factory_name || '-'}</div>
|
||||
<div class="text-muted small">${data.t1_shoe_factory_country || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100 bg-light border-0">
|
||||
<div class="fw-bold">T2 Factory</div>
|
||||
<div>${data.t2_component_factory_name || '-'}</div>
|
||||
<div class="text-muted small">${data.t2_component_factory_country || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Information -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-cube me-2"></i>Mold Information
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Model</div>
|
||||
<div>${data.model_name || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Tooling</div>
|
||||
<div>${data.tooling_id || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Season & Stage</div>
|
||||
<div>${(data.season || '-') + '/' + (data.stage || '-')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Specifications -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-wrench me-2"></i>Mold Specifications
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold">Mold Type</div>
|
||||
<div>${data.mold_main_type || '-'}</div>
|
||||
<div class="text-muted small">${data.mold_sub_type || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="fw-bold mb-2">Complexity Factors</div>
|
||||
<div class="row">
|
||||
<div class="col-6 small">High Sidewall</div>
|
||||
<div class="col-6 small text-end">${data.complexity_high_sidewall === 'yes' ? '<b>Yes</b>' : '-'}</div>
|
||||
<div class="col-6 small">Sliders</div>
|
||||
<div class="col-6 small text-end">${data.sliders_count ? `<b>${data.sliders_count} slider${data.sliders_count > 1 ? 's' : ''}</b>` : '-'}</div>
|
||||
<div class="col-6 small">Cavities</div>
|
||||
<div class="col-6 small text-end">${data.cavity_count ? `<b>${data.cavity_count} pair</b>` : '-'}</div>
|
||||
<div class="col-6 small">Digital Texture</div>
|
||||
<div class="col-6 small text-end">${data.digital_texture_type ? `<b>${data.digital_texture_type}</b>` : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Breakdown & Approvals -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-table me-2"></i>Cost Breakdown
|
||||
</div>
|
||||
<div class="card p-0">
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
<tr><td>Base Price</td><td class="text-end">$${Number(data.base_price).toFixed(2)}</td></tr>
|
||||
<tr><td>Complexity Adjustment</td><td class="text-end">$${Number(data.complexity_cost).toFixed(2)}</td></tr>
|
||||
<tr><td>Digital Texture Cost</td><td class="text-end">$${Number(data.texture_cost).toFixed(2)}</td></tr>
|
||||
<tr class="table-primary fw-bold"><td>Total Cost</td><td class="text-end">$${Number(data.total_cost).toFixed(2)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
|
||||
<i class="fas fa-check-circle me-2"></i>Approvals
|
||||
</div>
|
||||
<div class="card p-3">
|
||||
<div class="mb-3 text-muted small"><i class="far fa-calendar-alt me-1"></i>Issue Date: <b>${data.issue_date || '-'}</b></div>
|
||||
<div class="mb-2">Mold Shop<br><b>${data.approved_by_mold_shop || '-'}</b></div>
|
||||
<div class="mb-2">T1 Tooling<br><b>${data.approved_by_t1_tooling || '-'}</b></div>
|
||||
<div>LO Tooling<br><b>${data.approved_by_lo_tooling || '-'}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultElement.innerHTML = formattedResults;
|
||||
}
|
||||
|
||||
// Helper function to format currency
|
||||
function formatCurrency(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'N/A';
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// Refresh quote history
|
||||
async function refreshQuoteHistory() {
|
||||
try {
|
||||
const response = await fetch('/quote-history');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Update quote history display
|
||||
const historyContainer = document.getElementById('quote-history');
|
||||
if (historyContainer) {
|
||||
historyContainer.innerHTML = data.html;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing quote history:', error);
|
||||
}
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
<div class="list-group">
|
||||
{% for quote in quote_history %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="badge bg-primary me-2">#{{ quote.id }}</span>
|
||||
{{ quote.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
<div class="fw-bold">¥{{ "{:,.0f}".format(quote.total_cost) }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item text-muted">
|
||||
No calculations yet
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,298 @@
|
||||
{% macro render_quotation_details(quotation, show_actions=true) %}
|
||||
<div class="quotation-details">
|
||||
<div class="detail-card">
|
||||
<h3>Basic Information</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>ID</label>
|
||||
<span>{{ quotation.id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>User</label>
|
||||
<span>{{ quotation.user.username }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Created</label>
|
||||
<span>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Status</label>
|
||||
<span class="badge badge-success">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Project Details</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Model Name</label>
|
||||
<span>{{ quotation.model_name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Tooling ID</label>
|
||||
<span>{{ quotation.tooling_id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Season</label>
|
||||
<span>{{ quotation.season }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Stage</label>
|
||||
<span>{{ quotation.stage }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Country</label>
|
||||
<span>{{ quotation.country }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Issue Date</label>
|
||||
<span>{{ quotation.issue_date.strftime('%Y-%m-%d') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Mold Specifications</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Mold Main Type</label>
|
||||
<span>{{ quotation.mold_main_type }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Mold Sub Type</label>
|
||||
<span>{{ quotation.mold_sub_type }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>High Sidewall Complexity</label>
|
||||
<span>{{ 'Yes' if quotation.complexity_high_sidewall else 'No' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Number of Sliders</label>
|
||||
<span>{{ quotation.sliders_count }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Number of Cavities</label>
|
||||
<span>{{ quotation.cavity_count }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Digital Texture Type</label>
|
||||
<span>{{ quotation.digital_texture_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Cost Breakdown</h3>
|
||||
<div class="cost-breakdown">
|
||||
<div class="cost-item">
|
||||
<label>Net Base Price</label>
|
||||
<span>${{ "%.2f"|format(quotation.net_base_price) }}</span>
|
||||
</div>
|
||||
<div class="cost-item">
|
||||
<label>Tax Rate</label>
|
||||
<span>{{ "%.1f"|format(quotation.tax_rate * 100) }}%</span>
|
||||
</div>
|
||||
<div class="cost-item total">
|
||||
<label>Total Cost</label>
|
||||
<span>${{ "%.2f"|format(quotation.total_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>Factory Information</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Mold Shop Name</label>
|
||||
<span>{{ quotation.mold_shop_name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>T1 Factory Name</label>
|
||||
<span>{{ quotation.t1_factory_name or 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>T2 Component Factory</label>
|
||||
<span>{{ quotation.t2_component_factory or 'N/A' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_actions %}
|
||||
<div class="detail-actions">
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Quotations
|
||||
</a>
|
||||
<button class="btn btn-primary" onclick="window.print()">
|
||||
<i class="fas fa-print"></i> Print Quotation
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.quotation-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0 0 20px;
|
||||
color: #2c3e50;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cost-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cost-item label {
|
||||
color: #6c757d;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cost-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cost-item.total {
|
||||
margin-top: 10px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item.total label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-item.total span {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.detail-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
break-inside: avoid;
|
||||
box-shadow: none;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel{% endblock %}</title>
|
||||
{% block styles %}
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.css') }}">
|
||||
<!-- Admin Styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #198754;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #212529;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
|
||||
transition: box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="bi bi-gear-fill me-2"></i>Admin Panel
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="bi bi-graph-up me-1"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.users' %}active{% endif %}"
|
||||
href="{{ url_for('admin.users') }}">
|
||||
<i class="bi bi-people me-1"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.quotations' %}active{% endif %}"
|
||||
href="{{ url_for('admin.quotations') }}">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Quotations
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.cost_factors' %}active{% endif %}"
|
||||
href="{{ url_for('admin.cost_factors') }}">
|
||||
<i class="bi bi-calculator me-1"></i>Cost Factors
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block admin_title %}Cost Factor History{% endblock %}
|
||||
|
||||
{% block admin_header %}Change History: {{ factor.name }}{% endblock %}
|
||||
|
||||
{% block admin_actions %}
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Cost Factors
|
||||
</a>
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit Factor
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Factor Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Name</dt>
|
||||
<dd class="col-sm-8">{{ factor.name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Category</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-primary">{{ factor.category }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Current Value</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-success">{{ "%.4f"|format(factor.factor_value) }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Status</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if factor.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Created</dt>
|
||||
<dd class="col-sm-8">{{ factor.created_at.strftime('%Y-%m-%d %H:%M') if factor.created_at else 'Unknown' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Last Updated</dt>
|
||||
<dd class="col-sm-8">{{ factor.updated_at.strftime('%Y-%m-%d %H:%M') if factor.updated_at else 'Never' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Change History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if history %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date & Time</th>
|
||||
<th>Changed By</th>
|
||||
<th>Old Value</th>
|
||||
<th>New Value</th>
|
||||
<th>Change</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for change in history %}
|
||||
<tr>
|
||||
<td>{{ change.changed_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if change.user %}
|
||||
<span class="badge bg-info">{{ change.user.username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if change.old_value is not none %}
|
||||
<span class="badge bg-secondary">{{ "%.4f"|format(change.old_value) }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ "%.4f"|format(change.new_value) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if change.old_value is not none %}
|
||||
{% set diff = change.new_value - change.old_value %}
|
||||
{% if diff > 0 %}
|
||||
<span class="badge bg-danger">+{{ "%.4f"|format(diff) }}</span>
|
||||
{% elif diff < 0 %}
|
||||
<span class="badge bg-warning">{{ "%.4f"|format(diff) }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">No change</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-info">Initial</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if change.change_reason %}
|
||||
<small class="text-muted">{{ change.change_reason }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">No reason provided</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-history fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No Changes Yet</h5>
|
||||
<p class="text-muted">This factor hasn't been modified since it was created.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if history %}
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary">{{ history|length }}</h4>
|
||||
<small class="text-muted">Total Changes</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success">{{ history|selectattr('change_reason')|list|length }}</h4>
|
||||
<small class="text-muted">With Reasons</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info">{{ history|selectattr('user')|list|length }}</h4>
|
||||
<small class="text-muted">By Known Users</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning">{{ (history|last).changed_at.strftime('%Y-%m-%d') if history else 'N/A' }}</h4>
|
||||
<small class="text-muted">Last Modified</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.badge {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
dl.row dt {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
dl.row dd {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-center h4 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,558 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Cost Factors Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-gear-fill me-2 text-primary"></i>Cost Factors Management
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Configure calculation parameters and factors</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button onclick="resetFactors()" class="btn btn-warning">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Reset to Defaults
|
||||
</button>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Calculation Logic Diagram -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-diagram-3 me-2 text-dark"></i>Calculation Logic
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mermaid">
|
||||
graph TD
|
||||
A[Base Cost] --> B[Base Factors]
|
||||
B --> C[Mold Factors]
|
||||
C --> D[Complexity Factors]
|
||||
D --> E[Texture Factors]
|
||||
E --> F[Tax Factors]
|
||||
F --> G[Final Cost]
|
||||
|
||||
B --> B1[Base Factor 1]
|
||||
B --> B2[Base Factor 2]
|
||||
C --> C1[Mold Factor 1]
|
||||
C --> C2[Mold Factor 2]
|
||||
D --> D1[Complexity Factor 1]
|
||||
D --> D2[Complexity Factor 2]
|
||||
E --> E1[Texture Factor 1]
|
||||
E --> E2[Texture Factor 2]
|
||||
F --> F1[Tax Rate]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-primary bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-calculator text-primary fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ base_factors|length if base_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Base Factors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-success bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-building text-success fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ mold_factors|length if mold_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Mold Factors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-warning bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-puzzle text-warning fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ complexity_factors|length if complexity_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Complexity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-info bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-palette text-info fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ texture_factors|length if texture_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Texture</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
|
||||
<div class="card statistics-card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="icon-wrapper bg-danger bg-opacity-10 mx-auto mb-3">
|
||||
<i class="bi bi-percent text-danger fs-1"></i>
|
||||
</div>
|
||||
<h4 class="mb-1">{{ tax_factors|length if tax_factors else 0 }}</h4>
|
||||
<p class="text-muted mb-0 small">Tax Factors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Factors Sections -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- Base Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-primary bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-layers me-2 text-primary"></i>Base Factors
|
||||
<span class="badge bg-primary ms-2">{{ base_factors|length if base_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if base_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in base_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-calculator text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Base calculation factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No base factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-success bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-building me-2 text-success"></i>Mold Factors
|
||||
<span class="badge bg-success ms-2">{{ mold_factors|length if mold_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if mold_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in mold_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-box text-success"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Mold-related factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No mold factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complexity Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-warning bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-puzzle me-2 text-warning"></i>Complexity Factors
|
||||
<span class="badge bg-warning ms-2">{{ complexity_factors|length if complexity_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if complexity_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in complexity_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-gear text-warning"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-warning fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Complexity-related factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-warning">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No complexity factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texture Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-info bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-brush me-2 text-info"></i>Texture Factors
|
||||
<span class="badge bg-info ms-2">{{ texture_factors|length if texture_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if texture_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Factor Name</th>
|
||||
<th class="border-0">Value</th>
|
||||
<th class="border-0">Description</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in texture_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-palette text-info"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
|
||||
</td>
|
||||
<td>{{ factor.description or 'Texture-related factor' }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No texture factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax Factors -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-danger bg-opacity-10 border-0">
|
||||
<h3 class="card-title mb-0">
|
||||
<i class="bi bi-percent me-2 text-danger"></i>Tax Factors
|
||||
<span class="badge bg-danger ms-2">{{ tax_factors|length if tax_factors else 0 }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if tax_factors %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Country/Region</th>
|
||||
<th class="border-0">Tax Rate</th>
|
||||
<th class="border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for factor in tax_factors %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-flag text-danger"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ factor.name }}</div>
|
||||
<small class="text-muted">{{ factor.description or 'Tax rate' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-danger fs-6">{{ "%.1f"|format(factor.factor_value * 100) }}%</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-clock-history me-1"></i>History
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
|
||||
<p class="text-muted mb-0">No tax factors found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Mermaid.js for diagram rendering -->
|
||||
<script src="{{ url_for('static', filename='js/mermaid.min.js') }}"></script>
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'default',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: '"Helvetica Neue", Arial, sans-serif'
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Cost Factors Template Loaded');
|
||||
try {
|
||||
const debugInfo = {
|
||||
baseFactors: {{ base_factors|length if base_factors else 0 }},
|
||||
moldFactors: {{ mold_factors|length if mold_factors else 0 }},
|
||||
complexityFactors: {{ complexity_factors|length if complexity_factors else 0 }},
|
||||
textureFactors: {{ texture_factors|length if texture_factors else 0 }},
|
||||
taxFactors: {{ tax_factors|length if tax_factors else 0 }}
|
||||
};
|
||||
console.log('Debug Info:', debugInfo);
|
||||
} catch (error) {
|
||||
console.error('Error in debug logging:', error);
|
||||
}
|
||||
});
|
||||
|
||||
function resetFactors() {
|
||||
if (confirm('Are you sure you want to reset all cost factors to their default values? This action cannot be undone.')) {
|
||||
fetch('{{ url_for("admin.reset_cost_factors") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error resetting factors. Please try again.');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error resetting factors. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
/* Card Styles */
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
padding: 0.5em 0.8em;
|
||||
}
|
||||
|
||||
.badge.fs-6 {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
/* Icon Styles */
|
||||
.avatar-sm i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.card {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Statistics Cards */
|
||||
.statistics-card {
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statistics-card .icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Mermaid Diagram */
|
||||
.mermaid {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.statistics-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,245 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-speedometer2 me-2 text-primary"></i>Admin Dashboard
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Overview of system statistics and recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Total Users</h6>
|
||||
<h3 class="mb-0">{{ total_users }}</h3>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-people text-primary fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>{{ active_users }} active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Total Quotations</h6>
|
||||
<h3 class="mb-0">{{ total_quotations }}</h3>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-file-earmark-text text-success fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-info">
|
||||
<i class="bi bi-graph-up me-1"></i>All time
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Active Regions</h6>
|
||||
<h3 class="mb-0">{{ active_regions }}</h3>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-globe text-warning fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-warning">
|
||||
<i class="bi bi-flag me-1"></i>Active regions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Recent Quotes</h6>
|
||||
<h3 class="mb-0">{{ recent_quotations|length }}</h3>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-clock text-info fs-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-info">
|
||||
<i class="bi bi-calendar me-1"></i>Last 5 quotes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Recent Activity -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-graph-up me-2 text-primary"></i>Recent Activity
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_quotations %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Project</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in recent_quotations %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-person text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-medium">{{ quotation.user.username }}</div>
|
||||
<small class="text-muted">{{ quotation.user.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ quotation.project_name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">${{ "%.2f"|format(quotation.total_cost) }}</span>
|
||||
</td>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">Completed</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-eye me-1"></i>View All Quotations
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-file-earmark-text fs-1 text-muted mb-3"></i>
|
||||
<p class="text-muted mb-0">No recent quotations found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-lightning me-2 text-warning"></i>Quick Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-people me-2"></i>Manage Users
|
||||
</a>
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>View Quotations
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-warning">
|
||||
<i class="bi bi-gear me-2"></i>Cost Factors
|
||||
</a>
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calculator me-2"></i>Calculator Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit Cost Factor: {{ factor.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-pencil-square me-2 text-primary"></i>Edit Cost Factor
|
||||
</h1>
|
||||
<p class="text-muted mb-0">{{ factor.name }} ({{ factor.category }})</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to Cost Factors
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-gear me-2"></i>Edit Factor Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label fw-semibold">Category</label>
|
||||
<input type="text" class="form-control bg-light" id="category" value="{{ factor.category }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">Name</label>
|
||||
<input type="text" class="form-control bg-light" id="name" value="{{ factor.name }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="factor_value" class="form-label fw-semibold">
|
||||
Factor Value <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="number" class="form-control form-control-lg" id="factor_value" name="factor_value"
|
||||
value="{{ "%.4f"|format(factor.factor_value) }}" step="0.0001" min="0" required>
|
||||
<div class="form-text">
|
||||
{% if factor.category == 'tax' %}
|
||||
<i class="bi bi-info-circle me-1"></i>Enter as decimal (e.g., 0.06 for 6%)
|
||||
{% elif factor.category == 'base' %}
|
||||
<i class="bi bi-info-circle me-1"></i>Enter the base price value
|
||||
{% else %}
|
||||
<i class="bi bi-info-circle me-1"></i>Enter the multiplier factor
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if factor.calculation_formula %}
|
||||
<div class="mb-3">
|
||||
<label for="calculation_formula" class="form-label fw-semibold">Calculation Formula</label>
|
||||
<input type="text" class="form-control bg-light" id="calculation_formula"
|
||||
value="{{ factor.calculation_formula }}" readonly>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if factor.percentage %}
|
||||
<div class="mb-3">
|
||||
<label for="percentage" class="form-label fw-semibold">Percentage</label>
|
||||
<input type="text" class="form-control bg-light" id="percentage"
|
||||
value="{{ factor.percentage }}" readonly>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label fw-semibold">Description</label>
|
||||
<textarea class="form-control bg-light" id="description" rows="3" readonly>{{ factor.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="change_reason" class="form-label fw-semibold">
|
||||
<i class="bi bi-chat-text me-1"></i>Reason for Change
|
||||
</label>
|
||||
<textarea class="form-control" id="change_reason" name="change_reason" rows="3"
|
||||
placeholder="Please provide a reason for this change (optional but recommended)"></textarea>
|
||||
<div class="form-text">
|
||||
<i class="bi bi-clock-history me-1"></i>This will be recorded in the change history for audit purposes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-1"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>Current Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4 fw-semibold">Category</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-primary">{{ factor.category }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Name</dt>
|
||||
<dd class="col-sm-8">{{ factor.name }}</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Current Value</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-success fs-6">{{ "%.4f"|format(factor.factor_value) }}</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Status</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if factor.is_active %}
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>Inactive
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Created</dt>
|
||||
<dd class="col-sm-8">{{ factor.created_at.strftime('%Y-%m-%d %H:%M') if factor.created_at else 'Unknown' }}</dd>
|
||||
|
||||
<dt class="col-sm-4 fw-semibold">Last Updated</dt>
|
||||
<dd class="col-sm-8">{{ factor.updated_at.strftime('%Y-%m-%d %H:%M') if factor.updated_at else 'Never' }}</dd>
|
||||
|
||||
{% if factor.updater %}
|
||||
<dt class="col-sm-4 fw-semibold">Updated By</dt>
|
||||
<dd class="col-sm-8">{{ factor.updater.username }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-lightning me-2"></i>Quick Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}"
|
||||
class="btn btn-outline-info btn-sm w-100 mb-2">
|
||||
<i class="bi bi-clock-history me-1"></i> View Change History
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.cost_factors') }}"
|
||||
class="btn btn-outline-secondary btn-sm w-100">
|
||||
<i class="bi bi-list me-1"></i> Back to All Factors
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-control[readonly] {
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #dee2e6;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
dl.row dt {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
dl.row dd {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.form-control-lg {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,146 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block admin_title %}Quotation Details{% endblock %}
|
||||
|
||||
{% block admin_header %}Quotation Details{% endblock %}
|
||||
|
||||
{% block admin_actions %}
|
||||
<a href="{{ url_for('admin.quotations') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Quotations
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
{% from "_quotation_details.html" import render_quotation_details %}
|
||||
{{ render_quotation_details(quotation) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.quotation-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0 0 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cost-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cost-item label {
|
||||
color: #6c757d;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cost-item span {
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cost-item.total {
|
||||
margin-top: 10px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cost-item.total label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.cost-item.total span {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,174 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Quotations - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h2 class="mb-0">Quotations Management</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-card-body">
|
||||
<div class="admin-filters">
|
||||
<form method="get" class="admin-filter-form" id="quotationFilterForm">
|
||||
<div class="admin-form-group">
|
||||
<select name="country" class="admin-form-control">
|
||||
<option value="">All Countries</option>
|
||||
{% for country in countries %}
|
||||
<option value="{{ country }}" {% if filters.country == country %}selected{% endif %}>
|
||||
{{ country }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<select name="mold_type" class="admin-form-control">
|
||||
<option value="">All Mold Types</option>
|
||||
{% for type in mold_types %}
|
||||
<option value="{{ type }}" {% if filters.mold_type == type %}selected{% endif %}>
|
||||
{{ type }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<input type="date" name="date_from" id="date_from" class="admin-form-control"
|
||||
value="{{ filters.date_from.strftime('%Y-%m-%d') if filters.date_from else '' }}"
|
||||
placeholder="From Date">
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<input type="date" name="date_to" id="date_to" class="admin-form-control"
|
||||
value="{{ filters.date_to.strftime('%Y-%m-%d') if filters.date_to else '' }}"
|
||||
placeholder="To Date">
|
||||
</div>
|
||||
|
||||
<div class="admin-form-group">
|
||||
<button type="submit" class="admin-btn admin-btn-primary">Filter</button>
|
||||
<a href="{{ url_for('admin.quotations') }}" class="admin-btn admin-btn-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if quotations.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Model</th>
|
||||
<th>Country</th>
|
||||
<th>Mold Type</th>
|
||||
<th>Base Price</th>
|
||||
<th>Total Cost</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in quotations.items %}
|
||||
<tr>
|
||||
<td>{{ quotation.id }}</td>
|
||||
<td>{{ quotation.user.username }}</td>
|
||||
<td>{{ quotation.model_name }}</td>
|
||||
<td>{{ quotation.mold_shop_country }}</td>
|
||||
<td>{{ quotation.mold_main_type }}</td>
|
||||
<td>${{ "%.2f"|format(quotation.base_price) }}</td>
|
||||
<td>${{ "%.2f"|format(quotation.total_cost) }}</td>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.quotation_detail', id=quotation.id) }}"
|
||||
class="admin-btn admin-btn-info admin-btn-sm">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if quotations.pages > 1 %}
|
||||
<div class="admin-pagination">
|
||||
{% if quotations.has_prev %}
|
||||
<a href="{{ url_for('admin.quotations', page=quotations.prev_num, **filters) }}"
|
||||
class="admin-pagination-link">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="admin-pagination-info">
|
||||
Page {{ quotations.page }} of {{ quotations.pages }}
|
||||
</span>
|
||||
|
||||
{% if quotations.has_next %}
|
||||
<a href="{{ url_for('admin.quotations', page=quotations.next_num, **filters) }}"
|
||||
class="admin-pagination-link">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-0">No quotations found matching your criteria.</p>
|
||||
{% if filters.country or filters.mold_type or filters.date_from or filters.date_to %}
|
||||
<a href="{{ url_for('admin.quotations') }}" class="admin-btn admin-btn-primary mt-3">Clear Filters</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Function to get today's date in YYYY-MM-DD format
|
||||
function getTodayDate() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Get date input elements
|
||||
const dateFromInput = document.getElementById('date_from');
|
||||
const dateToInput = document.getElementById('date_to');
|
||||
const form = document.getElementById('quotationFilterForm');
|
||||
|
||||
// Set default values if empty
|
||||
if (!dateFromInput.value) {
|
||||
dateFromInput.value = getTodayDate();
|
||||
}
|
||||
if (!dateToInput.value) {
|
||||
dateToInput.value = getTodayDate();
|
||||
}
|
||||
|
||||
// Prevent form submission if dates are invalid
|
||||
form.addEventListener('submit', function(event) {
|
||||
if (!dateFromInput.value || !dateToInput.value) {
|
||||
event.preventDefault();
|
||||
if (!dateFromInput.value) dateFromInput.value = getTodayDate();
|
||||
if (!dateToInput.value) dateToInput.value = getTodayDate();
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners to handle invalid dates
|
||||
dateFromInput.addEventListener('change', function() {
|
||||
if (!this.value) this.value = getTodayDate();
|
||||
});
|
||||
|
||||
dateToInput.addEventListener('change', function() {
|
||||
if (!this.value) this.value = getTodayDate();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Users - Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label for="search" class="form-label">Search Users</label>
|
||||
<input type="text" id="search" name="search" placeholder="Search by username or email..."
|
||||
value="{{ request.args.get('search', '') }}" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-search me-2"></i>Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent border-0">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-users me-2 text-primary"></i>
|
||||
User Management
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">ID</th>
|
||||
<th class="border-0">Username</th>
|
||||
<th class="border-0">Email</th>
|
||||
<th class="border-0">Status</th>
|
||||
<th class="border-0">Role</th>
|
||||
<th class="border-0">Last Login</th>
|
||||
<th class="border-0">Created</th>
|
||||
<th class="border-0 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-secondary">#{{ user.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i class="fas fa-user text-primary"></i>
|
||||
</div>
|
||||
<span class="fw-medium">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted">{{ user.email }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if user.is_active else 'danger' }}">
|
||||
<i class="fas fa-{{ 'check-circle' if user.is_active else 'times-circle' }} me-1"></i>
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'primary' if user.is_admin else 'secondary' }}">
|
||||
<i class="fas fa-{{ 'crown' if user.is_admin else 'user' }} me-1"></i>
|
||||
{{ 'Admin' if user.is_admin else 'User' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ user.last_login.strftime('%m/%d/%Y %H:%M') if user.last_login else 'Never' }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ user.created_at.strftime('%m/%d/%Y') }}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}"
|
||||
class="btn btn-outline-info btn-sm"
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button type="button"
|
||||
class="btn btn-{{ 'warning' if user.is_active else 'success' }} btn-sm"
|
||||
onclick="toggleUserStatus({{ user.id }}, '{{ 'warning' if user.is_active else 'success' }}')"
|
||||
title="{{ 'Deactivate' if user.is_active else 'Activate' }} User">
|
||||
<i class="fas fa-{{ 'ban' if user.is_active else 'check' }}"></i>
|
||||
</button>
|
||||
{% if user.id != current_user.id %}
|
||||
<button type="button"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick="deleteUser({{ user.id }})"
|
||||
title="Delete User">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if users.pages > 1 %}
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<div class="text-muted">
|
||||
Showing {{ users.items|length }} of {{ users.total }} users
|
||||
</div>
|
||||
<nav aria-label="User pagination">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if users.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=users.prev_num, search=request.args.get('search', '')) }}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in users.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != users.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=page_num, search=request.args.get('search', '')) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if users.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=users.next_num, search=request.args.get('search', '')) }}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
function toggleUserStatus(userId, status) {
|
||||
const button = event.target.closest('button');
|
||||
const isActive = status === 'warning';
|
||||
const action = isActive ? 'deactivate' : 'activate';
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} this user?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/admin/user/${userId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating user status');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/admin/delete_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.message) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the user');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.avatar-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-4">Admin Management</h2>
|
||||
|
||||
<!-- User Management Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="mb-0">User Management</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Company</th>
|
||||
<th>Registration Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.company }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-primary">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not user.is_admin %}
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteUser({{ user.id }})"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal">
|
||||
Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quotation Management Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="mb-0">Quotation Management</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Model</th>
|
||||
<th>Tooling ID</th>
|
||||
<th>Total Cost</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in quotations %}
|
||||
<tr>
|
||||
<td>{{ quotation.id }}</td>
|
||||
<td>{{ quotation.user.username }}</td>
|
||||
<td>{{ quotation.model_name }}</td>
|
||||
<td>{{ quotation.tooling_id }}</td>
|
||||
<td>${{ "%.2f"|format(quotation.total_cost) }}</td>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteQuotation({{ quotation.id }})"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this item?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let itemToDelete = null;
|
||||
let deleteType = null;
|
||||
|
||||
function deleteUser(userId) {
|
||||
itemToDelete = userId;
|
||||
deleteType = 'user';
|
||||
}
|
||||
|
||||
function deleteQuotation(quoteId) {
|
||||
itemToDelete = quoteId;
|
||||
deleteType = 'quotation';
|
||||
}
|
||||
|
||||
document.getElementById('confirmDelete').addEventListener('click', function() {
|
||||
if (!itemToDelete || !deleteType) return;
|
||||
|
||||
const url = deleteType === 'user' ? '/admin/delete_user' : '/admin/delete_quotation';
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ id: itemToDelete })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.message) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error deleting item');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting item');
|
||||
})
|
||||
.finally(() => {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
|
||||
modal.hide();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Portal - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-lock me-2"></i>Quotation Records</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('calculator') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-calculator me-2"></i>Calculator
|
||||
</a>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-danger">
|
||||
<i class="fas fa-sign-out-alt me-2"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Company</th>
|
||||
<th>User</th>
|
||||
<th class="text-end">Model</th>
|
||||
<th class="text-end">Tooling ID</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quotation in quotations %}
|
||||
<tr>
|
||||
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ quotation.user.company }}</td>
|
||||
<td>{{ quotation.user.username }}</td>
|
||||
<td class="text-end">{{ quotation.model_name }}</td>
|
||||
<td class="text-end">{{ quotation.tooling_id }}</td>
|
||||
<td class="text-end fw-bold">${{ "{:,.2f}".format(quotation.total_cost) }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#detailModal{{ quotation.id }}">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for quotation in quotations %}
|
||||
<div class="modal fade" id="detailModal{{ quotation.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Quotation Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-3">Basic Information</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Date</dt>
|
||||
<dd class="col-sm-8">{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Company</dt>
|
||||
<dd class="col-sm-8">{{ quotation.user.company }}</dd>
|
||||
|
||||
<dt class="col-sm-4">User</dt>
|
||||
<dd class="col-sm-8">{{ quotation.user.username }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Model</dt>
|
||||
<dd class="col-sm-8">{{ quotation.model_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Tooling ID</dt>
|
||||
<dd class="col-sm-8">{{ quotation.tooling_id }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-3">Mold Specifications</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Mold Type</dt>
|
||||
<dd class="col-sm-8">{{ quotation.mold_main_type }} - {{ quotation.mold_sub_type }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Cavities</dt>
|
||||
<dd class="col-sm-8">{{ quotation.cavity_count }} cavity</dd>
|
||||
|
||||
<dt class="col-sm-4">Sliders</dt>
|
||||
<dd class="col-sm-8">{{ quotation.sliders_count }} slider</dd>
|
||||
|
||||
<dt class="col-sm-4">Texture</dt>
|
||||
<dd class="col-sm-8">{{ quotation.digital_texture_type }}</dd>
|
||||
|
||||
<dt class="col-sm-4">High Sidewall</dt>
|
||||
<dd class="col-sm-8">{{ 'Yes' if quotation.complexity_high_sidewall else 'No' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="text-muted mb-3">Cost Breakdown</h6>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Base Price</dt>
|
||||
<dd class="col-sm-8">${{ "{:,.2f}".format(quotation.net_base_price) }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Tax Rate</dt>
|
||||
<dd class="col-sm-8">{{ "{:.1%}".format(quotation.tax_rate) }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Total Cost</dt>
|
||||
<dd class="col-sm-8 fw-bold">${{ "{:,.2f}".format(quotation.total_cost) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
{{ form.remember_me.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
|
||||
<p class="mb-0 mt-2"><a href="{{ url_for('auth.request_reset') }}">Forgot your password?</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Register</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
|
||||
{% for error in form.username.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.company.label(class="form-label") }}
|
||||
{{ form.company(class="form-control" + (" is-invalid" if form.company.errors else "")) }}
|
||||
{% for error in form.company.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password_confirm.label(class="form-label") }}
|
||||
{{ form.password_confirm(class="form-control" + (" is-invalid" if form.password_confirm.errors else "")) }}
|
||||
{% for error in form.password_confirm.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.terms(class="form-check-input" + (" is-invalid" if form.terms.errors else "")) }}
|
||||
{{ form.terms.label(class="form-check-label") }}
|
||||
{% for error in form.terms.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Request Password Reset - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Request Password Reset</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" action="{{ url_for('auth.request_reset') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password - Mold Cost Calculator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Reset Password</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password_confirm.label(class="form-label") }}
|
||||
{{ form.password_confirm(class="form-control" + (" is-invalid" if form.password_confirm.errors else "")) }}
|
||||
{% for error in form.password_confirm.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}{% endblock %} - Mold Cost Calculator</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧮</text></svg>">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.css') }}">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--secondary-color: #6c757d;
|
||||
--accent-color: #0d6efd;
|
||||
--success-color: #198754;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #212529;
|
||||
--text-color: #212529;
|
||||
--text-muted: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--primary-color);
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
{% block styles %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('main.home') }}">
|
||||
<i class="bi bi-calculator me-2"></i>Mold Cost Calculator
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'main.calculator' %}active{% endif %}"
|
||||
href="{{ url_for('main.calculator') }}">
|
||||
<i class="bi bi-calculator me-1"></i>Calculator
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-white me-3">
|
||||
<i class="bi bi-person me-2"></i>{{ current_user.username }}
|
||||
</span>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p class="mb-0">PCT & Tooling. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Mold Cost Calculator</h2>
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-cog me-2"></i>Admin Portal
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form id="calcForm" class="row g-3" method="POST" action="/calculate">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<!-- Factory Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Factory Information</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.mold_shop_name.label(class="form-label") }}
|
||||
{{ form.mold_shop_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.mold_shop_country.label(class="form-label") }}
|
||||
{{ form.mold_shop_country(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t1_shoe_factory_name.label(class="form-label") }}
|
||||
{{ form.t1_shoe_factory_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t1_shoe_factory_country.label(class="form-label") }}
|
||||
{{ form.t1_shoe_factory_country(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t2_component_factory_name.label(class="form-label") }}
|
||||
{{ form.t2_component_factory_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.t2_component_factory_country.label(class="form-label") }}
|
||||
{{ form.t2_component_factory_country(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Mold Information</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.model_name.label(class="form-label") }}
|
||||
{{ form.model_name(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.tooling_id.label(class="form-label") }}
|
||||
{{ form.tooling_id(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.season.label(class="form-label") }}
|
||||
{{ form.season(class="form-control", id="season") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.stage.label(class="form-label") }}
|
||||
{{ form.stage(class="form-control", id="stage") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.type.label(class="form-label") }}
|
||||
{{ form.type(class="form-control", id="type") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mold Specifications -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Mold Specifications</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.mold_main_type.label(class="form-label") }}
|
||||
{{ form.mold_main_type(class="form-control", id="mold_main_type") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.mold_sub_type.label(class="form-label") }}
|
||||
{{ form.mold_sub_type(class="form-control", id="mold_sub_type") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.mold_specific_type.label(class="form-label") }}
|
||||
{{ form.mold_specific_type(class="form-control", id="mold_specific_type") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.sliders_count.label(class="form-label") }}
|
||||
{{ form.sliders_count(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.cavity_count.label(class="form-label") }}
|
||||
{{ form.cavity_count(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.digital_texture_type.label(class="form-label") }}
|
||||
{{ form.digital_texture_type(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.complexity_high_sidewall.label(class="form-label") }}
|
||||
{{ form.complexity_high_sidewall(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates & Approvals -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Dates & Approvals</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
{{ form.issue_date.label(class="form-label") }}
|
||||
{{ form.issue_date(class="form-control", type="date") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.approved_by_mold_shop.label(class="form-label") }}
|
||||
{{ form.approved_by_mold_shop(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.approved_by_t1_tooling.label(class="form-label") }}
|
||||
{{ form.approved_by_t1_tooling(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
{{ form.approved_by_lo_tooling.label(class="form-label") }}
|
||||
{{ form.approved_by_lo_tooling(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="text-center">
|
||||
<button type="submit" id="calculateBtn" class="btn btn-primary btn-lg">Calculate</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="result" class="mt-4"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="mold-calculator-script" src="{{ url_for('static', filename='js/mold_calculator.js', v='1.4') }}"></script>
|
||||
{% endblock %}
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0">Error {{ code }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ message }}</h5>
|
||||
<p class="card-text">
|
||||
{% if code == 404 %}
|
||||
The page you're looking for doesn't exist.
|
||||
{% elif code == 403 %}
|
||||
You don't have permission to access this resource.
|
||||
{% elif code == 400 %}
|
||||
The request was invalid.
|
||||
{% else %}
|
||||
An unexpected error occurred. Please try again later.
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bad Request - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">400</h1>
|
||||
<h2 class="mb-4">Bad Request</h2>
|
||||
<p class="lead">The request was invalid or cannot be served.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Forbidden - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">403</h1>
|
||||
<h2 class="mb-4">Access Forbidden</h2>
|
||||
<p class="lead">You don't have permission to access this resource.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not Found - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2 class="mb-4">Page Not Found</h2>
|
||||
<p class="lead">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h4 class="mb-0">Conflict</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Data Constraint Error</h5>
|
||||
<p class="card-text">{{ message }}</p>
|
||||
<p class="card-text">This error typically occurs when trying to create a duplicate record or violate a data constraint.</p>
|
||||
<p class="card-text">Please check your input and try again.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Server Error - MoldCost Pro{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 class="display-1">500</h1>
|
||||
<h2 class="mb-4">Server Error</h2>
|
||||
<p class="lead">Something went wrong on our end. Please try again later.</p>
|
||||
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h4 class="mb-0">Service Temporarily Unavailable</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Database Connection Error</h5>
|
||||
<p class="card-text">{{ message }}</p>
|
||||
<p class="card-text">Our technical team has been notified and is working to resolve this issue.</p>
|
||||
<p class="card-text">Please try again in a few minutes.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-main py-5">
|
||||
<div class="hero-section bg-gradient-primary text-white rounded-3 p-5 shadow-lg">
|
||||
<div class="text-center">
|
||||
<h1 class="display-4 fw-bold mb-4">
|
||||
<i class="bi bi-gear-connected"></i>
|
||||
MoldCost Pro
|
||||
</h1>
|
||||
<p class="lead mb-4">Precision Mold Cost Calculations</p>
|
||||
|
||||
<div class="d-grid gap-3 d-md-flex justify-content-md-center">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('calculator') }}" class="btn btn-light btn-lg px-4">
|
||||
<i class="bi bi-calculator me-2"></i>
|
||||
Go to Calculator
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-light btn-lg px-4">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
Get Started
|
||||
</a>
|
||||
<a href="{{ url_for('register') }}" class="btn btn-outline-light btn-lg px-4">
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
Register
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="row g-4 mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-speedometer2 display-4 text-primary"></i>
|
||||
<h3 class="mt-3">Fast Calculation</h3>
|
||||
<p class="text-muted">Instant mold cost estimates with industry-standard algorithms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-clock-history display-4 text-primary"></i>
|
||||
<h3 class="mt-3">History Tracking</h3>
|
||||
<p class="text-muted">Review your previous quotes and calculations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-shield-lock display-4 text-primary"></i>
|
||||
<h3 class="mt-3">Secure Data</h3>
|
||||
<p class="text-muted">Enterprise-grade security for your sensitive data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
bottom: -50%;
|
||||
background: linear-gradient(45deg,
|
||||
rgba(255,255,255,0.1) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255,255,255,0.1) 50%,
|
||||
rgba(255,255,255,0.1) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 40px 40px;
|
||||
animation: animateStripes 4s linear infinite;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
@keyframes animateStripes {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-40px); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Commercial Invoice - {{ quote.id }} | MoldCost Pro</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1a365d;
|
||||
--secondary-color: #2c5282;
|
||||
--accent-color: #c53030;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #1a202c;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.letterhead {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
height: 60px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.legal-disclaimer {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.page-break { page-break-before: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="letterhead">
|
||||
<img src="https://cdn.example.com/logo.svg"
|
||||
alt="MoldCost Pro"
|
||||
class="company-logo">
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<div>
|
||||
<p>MoldCost Pro Limited</p>
|
||||
<p>123 Manufacturing Blvd, Shenzhen, CN</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p>T: +86 (755) 1234-5678</p>
|
||||
<p>E: sales@moldcostpro.com</p>
|
||||
<p>W: www.moldcostpro.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-section">
|
||||
<h1 class="text-2xl font-bold">Commercial Invoice</h1>
|
||||
<div class="flex justify-between text-sm text-gray-600 mt-2">
|
||||
<div>
|
||||
<p><strong>Quote ID:</strong> MC-{{ quote.id|string|replace(' ', '0') }}</p>
|
||||
<p><strong>Date:</strong> {{ quote.created_at.strftime('%d-%b-%Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Currency:</strong> USD</p>
|
||||
<p><strong>Valid Until:</strong> {{ (quote.created_at + datetime.timedelta(days=30)).strftime('%d-%b-%Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% from "_quotation_details.html" import render_quotation_details %}
|
||||
{{ render_quotation_details(quote, show_actions=false) }}
|
||||
|
||||
<div class="legal-disclaimer">
|
||||
<p>This quotation is subject to our standard terms and conditions. Prices valid for 30 days from quotation date. All measurements tolerances ±0.5% unless otherwise specified. Export documentation extra.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terms of Service - Mold Cost Online Tool{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="mb-4 text-center">Terms of Service</h3>
|
||||
|
||||
<div class="terms-content">
|
||||
<h4 class="mb-3">1. Acceptance of Terms</h4>
|
||||
<p>By accessing and using the Mold Cost Online Tool, you agree to be bound by these Terms of Service.</p>
|
||||
|
||||
<h4 class="mb-3 mt-4">2. Use of Service</h4>
|
||||
<p>The Mold Cost Online Tool is provided for professional use in the footwear industry. Users must:</p>
|
||||
<ul>
|
||||
<li>Provide accurate and complete information</li>
|
||||
<li>Maintain the confidentiality of their account</li>
|
||||
<li>Use the service in compliance with all applicable laws</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">3. Data Privacy</h4>
|
||||
<p>We are committed to protecting your data:</p>
|
||||
<ul>
|
||||
<li>All calculations and quotations are stored securely</li>
|
||||
<li>Personal information is used only for service provision</li>
|
||||
<li>Data is not shared with third parties without consent</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">4. Intellectual Property</h4>
|
||||
<p>The Mold Cost Online Tool and its contents are protected by intellectual property rights. Users may not:</p>
|
||||
<ul>
|
||||
<li>Copy or reproduce the tool's algorithms</li>
|
||||
<li>Reverse engineer the service</li>
|
||||
<li>Use the service for unauthorized commercial purposes</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">5. Disclaimer</h4>
|
||||
<p>The tool provides estimates based on industry standards. While we strive for accuracy:</p>
|
||||
<ul>
|
||||
<li>Results are estimates only</li>
|
||||
<li>Actual costs may vary</li>
|
||||
<li>Users should verify calculations independently</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="mb-3 mt-4">6. Account Termination</h4>
|
||||
<p>We reserve the right to terminate accounts that:</p>
|
||||
<ul>
|
||||
<li>Violate these terms</li>
|
||||
<li>Engage in fraudulent activity</li>
|
||||
<li>Misuse the service</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<a href="{{ url_for('register') }}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Registration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
from ..utils.cost_factor_loader import get_cost_factors
|
||||
|
||||
def calculate_base_price(mold_main_type, mold_sub_type, mold_specific_type, sliders_count, cavity_count, digital_texture_type, complexity_high_sidewall=False):
|
||||
"""
|
||||
Calculate the base price for a mold based on various factors.
|
||||
|
||||
Args:
|
||||
mold_main_type (str): The main type of the mold
|
||||
mold_sub_type (str): The sub type of the mold
|
||||
mold_specific_type (str): The specific type of the mold
|
||||
sliders_count (int): Number of sliders
|
||||
cavity_count (int): Number of cavities
|
||||
digital_texture_type (str): Type of digital texture
|
||||
complexity_high_sidewall (bool): Whether the mold has high sidewall complexity
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all cost components
|
||||
"""
|
||||
# Get cost factors from database (or fallback to defaults)
|
||||
cost_factors, tax_rates, default_net_base = get_cost_factors()
|
||||
|
||||
# Start with the default net base
|
||||
base_price = default_net_base
|
||||
|
||||
# Apply mold type factor
|
||||
if mold_specific_type in cost_factors['mold']:
|
||||
base_price *= cost_factors['mold'][mold_specific_type]
|
||||
|
||||
# Calculate complexity cost
|
||||
complexity_cost = 0
|
||||
# Add slider complexity
|
||||
if sliders_count > 0:
|
||||
complexity_cost += base_price * (sliders_count * cost_factors['complexity']['slider'])
|
||||
|
||||
# Add cavity complexity
|
||||
if cavity_count > 1:
|
||||
complexity_cost += base_price * ((cavity_count - 1) * cost_factors['complexity']['cavity'])
|
||||
|
||||
# Add high sidewall complexity
|
||||
if complexity_high_sidewall:
|
||||
complexity_cost += base_price * cost_factors['complexity']['high_sidewall']
|
||||
|
||||
# Calculate texture cost
|
||||
texture_cost = 0
|
||||
if digital_texture_type in cost_factors['texture']:
|
||||
texture_cost = base_price * cost_factors['texture'][digital_texture_type]
|
||||
|
||||
# Calculate total cost
|
||||
total_cost = base_price + complexity_cost + texture_cost
|
||||
|
||||
return {
|
||||
'base_price': round(base_price, 2),
|
||||
'complexity_cost': round(complexity_cost, 2),
|
||||
'texture_cost': round(texture_cost, 2),
|
||||
'total_cost': round(total_cost, 2)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
from ..models import CostFactor
|
||||
from .. import db
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Single source of truth for default cost factors
|
||||
DEFAULT_COST_FACTORS = [
|
||||
# Mold Type Factors
|
||||
{'category': 'mold', 'name': 'Flat', 'factor_value': 1.0, 'description': 'Standard flat outsole'},
|
||||
{'category': 'mold', 'name': 'Flat with Top/Heel Tip', 'factor_value': 1.25, 'description': 'Flat with additional tip features'},
|
||||
{'category': 'mold', 'name': 'Top PL', 'factor_value': 1.05, 'description': 'Top part line mold'},
|
||||
{'category': 'mold', 'name': 'Visible PL', 'factor_value': 1.28, 'description': 'Visible part line mold'},
|
||||
{'category': 'mold', 'name': 'Short PL (Heel/Forefoot)', 'factor_value': 1.18, 'description': 'Short part line for specific areas'},
|
||||
{'category': 'mold', 'name': 'Wave PL', 'factor_value': 1.32, 'description': 'Wave pattern part line'},
|
||||
{'category': 'mold', 'name': 'Multi-color PL', 'factor_value': 1.35, 'description': 'Multi-color part line mold'},
|
||||
{'category': 'mold', 'name': '2 Plates Mold', 'factor_value': 1.0, 'description': 'Standard 2-plate configuration'},
|
||||
{'category': 'mold', 'name': '3 Plates Mold', 'factor_value': 1.35, 'description': 'Complex 3-plate configuration'},
|
||||
{'category': 'mold', 'name': '2 Plates with in-mold channel', 'factor_value': 1.25, 'description': '2-plate with channel features'},
|
||||
{'category': 'mold', 'name': 'Breathable + in-mold channel', 'factor_value': 1.45, 'description': 'Breathable with channel features'},
|
||||
{'category': 'mold', 'name': 'Sandal with Upper Bandage', 'factor_value': 1.15, 'description': 'Sandal with bandage features'},
|
||||
{'category': 'mold', 'name': '2 Plate without Upper Bandage', 'factor_value': 1.05, 'description': 'Standard 2-plate sandal'},
|
||||
{'category': 'mold', 'name': 'Co-shot mold', 'factor_value': 1.25, 'description': 'Multi-material injection'},
|
||||
{'category': 'mold', 'name': 'Foaming mold', 'factor_value': 1.4, 'description': 'Foam injection process'},
|
||||
|
||||
# Complexity Factors
|
||||
{'category': 'complexity', 'name': 'high_sidewall', 'factor_value': 0.18, 'calculation_formula': 'Base Price × 0.18', 'description': 'Additional cost for high sidewall molds'},
|
||||
{'category': 'complexity', 'name': 'slider', 'factor_value': 0.1, 'calculation_formula': 'Base Price × (Number of Sliders × 0.10)', 'description': 'Cost per slider added to mold'},
|
||||
{'category': 'complexity', 'name': 'cavity', 'factor_value': 0.12, 'calculation_formula': 'Base Price × ((Number of Cavities - 1) × 0.12)', 'description': 'Cost for additional cavities beyond 1'},
|
||||
|
||||
# Texture Factors
|
||||
{'category': 'texture', 'name': 'adidas_digital', 'factor_value': 0.0, 'percentage': '0%', 'description': 'No additional cost for standard adidas texture'},
|
||||
{'category': 'texture', 'name': 'customized texture', 'factor_value': 0.15, 'percentage': '15%', 'description': '15% of base price for custom texture'},
|
||||
{'category': 'texture', 'name': 'laser', 'factor_value': 0.2, 'percentage': '20%', 'description': '20% of base price for laser texture'},
|
||||
|
||||
# Tax Rates
|
||||
{'category': 'tax', 'name': 'China', 'factor_value': 0.06, 'percentage': '6%', 'description': '6% VAT for China'},
|
||||
{'category': 'tax', 'name': 'Vietnam', 'factor_value': 0.10, 'percentage': '10%', 'description': '10% VAT for Vietnam'},
|
||||
{'category': 'tax', 'name': 'Indonesia', 'factor_value': 0.07, 'percentage': '7%', 'description': '7% VAT for Indonesia'},
|
||||
|
||||
# Base Price
|
||||
{'category': 'base', 'name': 'DEFAULT_NET_BASE', 'factor_value': 12.5, 'description': 'Default base price for calculations'}
|
||||
]
|
||||
|
||||
def load_cost_factors_from_db():
|
||||
"""
|
||||
Load cost factors from database and return them in the same format as constants.
|
||||
Falls back to hardcoded values if database is not available.
|
||||
"""
|
||||
try:
|
||||
# Get all active cost factors
|
||||
factors = CostFactor.query.filter_by(is_active=True).all()
|
||||
|
||||
if not factors:
|
||||
logger.warning("No cost factors found in database, using hardcoded defaults")
|
||||
return get_default_cost_factors()
|
||||
|
||||
# Build the cost factors structure
|
||||
cost_factors = {
|
||||
'mold': {},
|
||||
'complexity': {},
|
||||
'texture': {}
|
||||
}
|
||||
|
||||
tax_rates = {}
|
||||
default_net_base = 12.5 # Default value
|
||||
|
||||
for factor in factors:
|
||||
if factor.category == 'mold':
|
||||
cost_factors['mold'][factor.name] = factor.factor_value
|
||||
elif factor.category == 'complexity':
|
||||
cost_factors['complexity'][factor.name] = factor.factor_value
|
||||
elif factor.category == 'texture':
|
||||
cost_factors['texture'][factor.name] = factor.factor_value
|
||||
elif factor.category == 'tax':
|
||||
tax_rates[factor.name] = factor.factor_value
|
||||
elif factor.category == 'base':
|
||||
default_net_base = factor.factor_value
|
||||
|
||||
logger.info(f"Loaded {len(factors)} cost factors from database")
|
||||
return cost_factors, tax_rates, default_net_base
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading cost factors from database: {str(e)}")
|
||||
logger.info("Falling back to hardcoded cost factors")
|
||||
return get_default_cost_factors()
|
||||
|
||||
def get_default_cost_factors():
|
||||
"""Return hardcoded cost factors as fallback - derived from single source of truth"""
|
||||
cost_factors = {
|
||||
'mold': {},
|
||||
'complexity': {},
|
||||
'texture': {}
|
||||
}
|
||||
|
||||
tax_rates = {}
|
||||
default_net_base = 12.5
|
||||
|
||||
# Build the structure from the single source of truth
|
||||
for factor_data in DEFAULT_COST_FACTORS:
|
||||
category = factor_data['category']
|
||||
name = factor_data['name']
|
||||
value = factor_data['factor_value']
|
||||
|
||||
if category == 'mold':
|
||||
cost_factors['mold'][name] = value
|
||||
elif category == 'complexity':
|
||||
cost_factors['complexity'][name] = value
|
||||
elif category == 'texture':
|
||||
cost_factors['texture'][name] = value
|
||||
elif category == 'tax':
|
||||
tax_rates[name] = value
|
||||
elif category == 'base':
|
||||
default_net_base = value
|
||||
|
||||
return cost_factors, tax_rates, default_net_base
|
||||
|
||||
def get_cost_factors():
|
||||
"""Get current cost factors (from database or defaults)"""
|
||||
return load_cost_factors_from_db()
|
||||
|
||||
def initialize_default_cost_factors():
|
||||
"""Initialize the database with default cost factors if none exist"""
|
||||
try:
|
||||
if CostFactor.query.count() == 0:
|
||||
logger.info("Initializing database with default cost factors")
|
||||
|
||||
# Use the single source of truth
|
||||
for factor_data in DEFAULT_COST_FACTORS:
|
||||
factor = CostFactor(**factor_data)
|
||||
db.session.add(factor)
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"Initialized {len(DEFAULT_COST_FACTORS)} default cost factors")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing default cost factors: {str(e)}")
|
||||
db.session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import sys
|
||||
import ctypes
|
||||
from ctypes.util import find_library
|
||||
from pathlib import Path
|
||||
|
||||
class DLLLoaderError(Exception):
|
||||
"""自定义DLL加载异常"""
|
||||
pass
|
||||
|
||||
def setup_dependencies(dll_paths=None):
|
||||
"""配置DLL搜索路径"""
|
||||
if dll_paths is None:
|
||||
dll_paths = [
|
||||
Path(__file__).parent / 'libs',
|
||||
Path(os.getenv('LOCALAPPDATA')) / 'MSYS2' / 'mingw64' / 'bin'
|
||||
]
|
||||
|
||||
os.environ['PATH'] = os.pathsep.join([
|
||||
str(p.resolve()) for p in dll_paths if p.exists()
|
||||
]) + os.pathsep + os.environ.get('PATH', '')
|
||||
|
||||
def load_dll(dll_name, mode=ctypes.DEFAULT_MODE, handle_errors=True):
|
||||
"""增强版DLL加载函数"""
|
||||
try:
|
||||
# 尝试标准加载方式
|
||||
dll = ctypes.CDLL(dll_name, mode=mode)
|
||||
print(f"✅ 成功加载 {dll_name}")
|
||||
return dll
|
||||
except OSError as e:
|
||||
if handle_errors:
|
||||
# 尝试通过PATH查找
|
||||
lib_path = find_library(dll_name)
|
||||
if lib_path:
|
||||
return ctypes.CDLL(lib_path, mode=mode)
|
||||
|
||||
# 尝试MSYS2路径
|
||||
msys_path = Path(os.getenv('LOCALAPPDATA')) / 'MSYS2' / 'mingw64' / 'bin' / dll_name
|
||||
if msys_path.exists():
|
||||
return ctypes.CDLL(str(msys_path), mode=mode)
|
||||
|
||||
# 生成详细错误报告
|
||||
path_list = os.environ.get('PATH', '').split(os.pathsep)
|
||||
raise DLLLoaderError(
|
||||
f"无法加载 {dll_name}\n"
|
||||
f"错误详情: {str(e)}\n"
|
||||
f"搜索路径:\n• " + "\n• ".join(path_list)
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
# 自动配置默认路径
|
||||
try:
|
||||
setup_dependencies()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 初始化依赖失败: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 自测试代码
|
||||
try:
|
||||
test_dll = load_dll('gtk-3-0.dll')
|
||||
print(f"测试DLL句柄: {test_dll._handle}")
|
||||
except DLLLoaderError as e:
|
||||
print(f"❌ 自测试失败: {e}")
|
||||
Reference in New Issue
Block a user