Initial commit: Masina-Dock Vehicle Management System
This commit is contained in:
commit
ae923e2c41
4999 changed files with 1607266 additions and 0 deletions
293
deb-build/opt/masina-dock/backend/app.py
Normal file
293
deb-build/opt/masina-dock/backend/app.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
from flask import Flask, jsonify, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Vehicle, ServiceRecord, FuelRecord, Reminder, Todo
|
||||
from auth import auth_bp, login_manager
|
||||
from config import Config
|
||||
import os
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
app = Flask(__name__, static_folder='../frontend/static', template_folder='../frontend/templates')
|
||||
app.config.from_object(Config)
|
||||
|
||||
CORS(app, supports_credentials=True)
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'csv'}
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory(app.template_folder, 'index.html')
|
||||
|
||||
@app.route('/api/vehicles', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def vehicles():
|
||||
if request.method == 'GET':
|
||||
vehicles = Vehicle.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify([{
|
||||
'id': v.id,
|
||||
'year': v.year,
|
||||
'make': v.make,
|
||||
'model': v.model,
|
||||
'vin': v.vin,
|
||||
'license_plate': v.license_plate,
|
||||
'odometer': v.odometer,
|
||||
'photo': v.photo,
|
||||
'status': v.status
|
||||
} for v in vehicles]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
vehicle = Vehicle(
|
||||
user_id=current_user.id,
|
||||
year=data['year'],
|
||||
make=data['make'],
|
||||
model=data['model'],
|
||||
vin=data.get('vin'),
|
||||
license_plate=data.get('license_plate'),
|
||||
odometer=data.get('odometer', 0)
|
||||
)
|
||||
db.session.add(vehicle)
|
||||
db.session.commit()
|
||||
return jsonify({'id': vehicle.id, 'message': 'Vehicle added successfully'}), 201
|
||||
|
||||
@app.route('/api/vehicles/<int:vehicle_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@login_required
|
||||
def vehicle_detail(vehicle_id):
|
||||
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if request.method == 'GET':
|
||||
return jsonify({
|
||||
'id': vehicle.id,
|
||||
'year': vehicle.year,
|
||||
'make': vehicle.make,
|
||||
'model': vehicle.model,
|
||||
'vin': vehicle.vin,
|
||||
'license_plate': vehicle.license_plate,
|
||||
'odometer': vehicle.odometer,
|
||||
'photo': vehicle.photo,
|
||||
'status': vehicle.status
|
||||
}), 200
|
||||
|
||||
elif request.method == 'PUT':
|
||||
data = request.get_json()
|
||||
vehicle.year = data.get('year', vehicle.year)
|
||||
vehicle.make = data.get('make', vehicle.make)
|
||||
vehicle.model = data.get('model', vehicle.model)
|
||||
vehicle.vin = data.get('vin', vehicle.vin)
|
||||
vehicle.license_plate = data.get('license_plate', vehicle.license_plate)
|
||||
vehicle.odometer = data.get('odometer', vehicle.odometer)
|
||||
vehicle.status = data.get('status', vehicle.status)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Vehicle updated successfully'}), 200
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
db.session.delete(vehicle)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Vehicle deleted successfully'}), 200
|
||||
|
||||
@app.route('/api/vehicles/<int:vehicle_id>/service-records', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def service_records(vehicle_id):
|
||||
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if request.method == 'GET':
|
||||
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).order_by(ServiceRecord.date.desc()).all()
|
||||
return jsonify([{
|
||||
'id': r.id,
|
||||
'date': r.date.isoformat(),
|
||||
'odometer': r.odometer,
|
||||
'description': r.description,
|
||||
'cost': r.cost,
|
||||
'notes': r.notes,
|
||||
'category': r.category,
|
||||
'document_path': r.document_path
|
||||
} for r in records]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
record = ServiceRecord(
|
||||
vehicle_id=vehicle_id,
|
||||
date=datetime.fromisoformat(data['date']),
|
||||
odometer=data['odometer'],
|
||||
description=data['description'],
|
||||
cost=data.get('cost', 0.0),
|
||||
notes=data.get('notes'),
|
||||
category=data.get('category')
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
return jsonify({'id': record.id, 'message': 'Service record added successfully'}), 201
|
||||
|
||||
@app.route('/api/vehicles/<int:vehicle_id>/fuel-records', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def fuel_records(vehicle_id):
|
||||
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if request.method == 'GET':
|
||||
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.date.desc()).all()
|
||||
return jsonify([{
|
||||
'id': r.id,
|
||||
'date': r.date.isoformat(),
|
||||
'odometer': r.odometer,
|
||||
'fuel_amount': r.fuel_amount,
|
||||
'cost': r.cost,
|
||||
'unit_cost': r.unit_cost,
|
||||
'distance': r.distance,
|
||||
'fuel_economy': r.fuel_economy,
|
||||
'unit': r.unit,
|
||||
'notes': r.notes
|
||||
} for r in records]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
|
||||
last_record = FuelRecord.query.filter_by(vehicle_id=vehicle_id).order_by(FuelRecord.odometer.desc()).first()
|
||||
distance = data['odometer'] - last_record.odometer if last_record else 0
|
||||
|
||||
fuel_economy = None
|
||||
if distance > 0 and data['fuel_amount'] > 0:
|
||||
if data.get('unit', 'MPG') == 'MPG':
|
||||
fuel_economy = distance / data['fuel_amount']
|
||||
elif data.get('unit') == 'L/100KM':
|
||||
fuel_economy = (data['fuel_amount'] / distance) * 100
|
||||
elif data.get('unit') == 'KM/L':
|
||||
fuel_economy = distance / data['fuel_amount']
|
||||
|
||||
record = FuelRecord(
|
||||
vehicle_id=vehicle_id,
|
||||
date=datetime.fromisoformat(data['date']),
|
||||
odometer=data['odometer'],
|
||||
fuel_amount=data['fuel_amount'],
|
||||
cost=data.get('cost', 0.0),
|
||||
unit_cost=data.get('unit_cost'),
|
||||
distance=distance,
|
||||
fuel_economy=fuel_economy,
|
||||
unit=data.get('unit', 'MPG'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
return jsonify({'id': record.id, 'message': 'Fuel record added successfully', 'fuel_economy': fuel_economy}), 201
|
||||
|
||||
@app.route('/api/vehicles/<int:vehicle_id>/reminders', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def reminders(vehicle_id):
|
||||
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if request.method == 'GET':
|
||||
reminders = Reminder.query.filter_by(vehicle_id=vehicle_id, completed=False).all()
|
||||
return jsonify([{
|
||||
'id': r.id,
|
||||
'description': r.description,
|
||||
'urgency': r.urgency,
|
||||
'due_date': r.due_date.isoformat() if r.due_date else None,
|
||||
'due_odometer': r.due_odometer,
|
||||
'metric': r.metric,
|
||||
'recurring': r.recurring,
|
||||
'interval_type': r.interval_type,
|
||||
'interval_value': r.interval_value,
|
||||
'notes': r.notes
|
||||
} for r in reminders]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
reminder = Reminder(
|
||||
vehicle_id=vehicle_id,
|
||||
description=data['description'],
|
||||
urgency=data.get('urgency', 'not_urgent'),
|
||||
due_date=datetime.fromisoformat(data['due_date']) if data.get('due_date') else None,
|
||||
due_odometer=data.get('due_odometer'),
|
||||
metric=data.get('metric'),
|
||||
recurring=data.get('recurring', False),
|
||||
interval_type=data.get('interval_type'),
|
||||
interval_value=data.get('interval_value'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
db.session.add(reminder)
|
||||
db.session.commit()
|
||||
return jsonify({'id': reminder.id, 'message': 'Reminder added successfully'}), 201
|
||||
|
||||
@app.route('/api/vehicles/<int:vehicle_id>/todos', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def todos(vehicle_id):
|
||||
vehicle = Vehicle.query.filter_by(id=vehicle_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
if request.method == 'GET':
|
||||
todos = Todo.query.filter_by(vehicle_id=vehicle_id).all()
|
||||
return jsonify([{
|
||||
'id': t.id,
|
||||
'description': t.description,
|
||||
'cost': t.cost,
|
||||
'priority': t.priority,
|
||||
'status': t.status,
|
||||
'type': t.type,
|
||||
'notes': t.notes
|
||||
} for t in todos]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
todo = Todo(
|
||||
vehicle_id=vehicle_id,
|
||||
description=data['description'],
|
||||
cost=data.get('cost', 0.0),
|
||||
priority=data.get('priority', 'medium'),
|
||||
status=data.get('status', 'planned'),
|
||||
type=data.get('type'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
db.session.add(todo)
|
||||
db.session.commit()
|
||||
return jsonify({'id': todo.id, 'message': 'Todo added successfully'}), 201
|
||||
|
||||
@app.route('/api/export/<data_type>', methods=['GET'])
|
||||
@login_required
|
||||
def export_data(data_type):
|
||||
vehicle_id = request.args.get('vehicle_id')
|
||||
|
||||
if data_type == 'service_records':
|
||||
records = ServiceRecord.query.filter_by(vehicle_id=vehicle_id).all()
|
||||
df = pd.DataFrame([{
|
||||
'Date': r.date,
|
||||
'Odometer': r.odometer,
|
||||
'Description': r.description,
|
||||
'Cost': r.cost,
|
||||
'Category': r.category,
|
||||
'Notes': r.notes
|
||||
} for r in records])
|
||||
elif data_type == 'fuel_records':
|
||||
records = FuelRecord.query.filter_by(vehicle_id=vehicle_id).all()
|
||||
df = pd.DataFrame([{
|
||||
'Date': r.date,
|
||||
'Odometer': r.odometer,
|
||||
'Fuel Amount': r.fuel_amount,
|
||||
'Cost': r.cost,
|
||||
'Fuel Economy': r.fuel_economy,
|
||||
'Unit': r.unit
|
||||
} for r in records])
|
||||
else:
|
||||
return jsonify({'error': 'Invalid data type'}), 400
|
||||
|
||||
output_file = f'/tmp/{data_type}_{vehicle_id}.csv'
|
||||
df.to_csv(output_file, index=False)
|
||||
return send_from_directory('/tmp', f'{data_type}_{vehicle_id}.csv', as_attachment=True)
|
||||
|
||||
@app.route('/api/settings/theme', methods=['POST'])
|
||||
@login_required
|
||||
def update_theme():
|
||||
data = request.get_json()
|
||||
current_user.theme = data['theme']
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Theme updated successfully'}), 200
|
||||
|
||||
if __name__ == '__main__':
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
107
deb-build/opt/masina-dock/backend/auth.py
Normal file
107
deb-build/opt/masina-dock/backend/auth.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||
from models import db, User
|
||||
from functools import wraps
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
login_manager = LoginManager()
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
@login_required
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@auth_bp.route('/api/auth/register', methods=['POST'])
|
||||
def register():
|
||||
data = request.get_json()
|
||||
|
||||
if User.query.filter_by(username=data['username']).first():
|
||||
return jsonify({'error': 'Username already exists'}), 400
|
||||
|
||||
if User.query.filter_by(email=data['email']).first():
|
||||
return jsonify({'error': 'Email already exists'}), 400
|
||||
|
||||
user = User(
|
||||
username=data['username'],
|
||||
email=data['email'],
|
||||
is_admin=User.query.count() == 0
|
||||
)
|
||||
user.set_password(data['password'])
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'User created successfully', 'user_id': user.id}), 201
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
user = User.query.filter_by(username=data['username']).first()
|
||||
|
||||
if user and user.check_password(data['password']):
|
||||
login_user(user, remember=True)
|
||||
return jsonify({
|
||||
'message': 'Login successful',
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'is_admin': user.is_admin,
|
||||
'theme': user.theme,
|
||||
'first_login': user.first_login
|
||||
}
|
||||
}), 200
|
||||
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
@auth_bp.route('/api/auth/logout', methods=['POST'])
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return jsonify({'message': 'Logged out successfully'}), 200
|
||||
|
||||
@auth_bp.route('/api/auth/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
data = request.get_json()
|
||||
|
||||
if not current_user.check_password(data['current_password']):
|
||||
return jsonify({'error': 'Current password is incorrect'}), 400
|
||||
|
||||
current_user.set_password(data['new_password'])
|
||||
current_user.first_login = False
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Password changed successfully'}), 200
|
||||
|
||||
@auth_bp.route('/api/auth/users', methods=['GET'])
|
||||
@admin_required
|
||||
def get_users():
|
||||
users = User.query.all()
|
||||
return jsonify([{
|
||||
'id': u.id,
|
||||
'username': u.username,
|
||||
'email': u.email,
|
||||
'is_admin': u.is_admin,
|
||||
'created_at': u.created_at.isoformat()
|
||||
} for u in users]), 200
|
||||
|
||||
@auth_bp.route('/api/auth/users/<int:user_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'error': 'Cannot delete your own account'}), 400
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'User deleted successfully'}), 200
|
||||
13
deb-build/opt/masina-dock/backend/config.py
Normal file
13
deb-build/opt/masina-dock/backend/config.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'masina-dock-secret-key-change-in-production'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///masina_dock.db'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
92
deb-build/opt/masina-dock/backend/models.py
Normal file
92
deb-build/opt/masina-dock/backend/models.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
theme = db.Column(db.String(20), default='dark')
|
||||
first_login = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
vehicles = db.relationship('Vehicle', backref='owner', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
class Vehicle(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
make = db.Column(db.String(100), nullable=False)
|
||||
model = db.Column(db.String(100), nullable=False)
|
||||
vin = db.Column(db.String(17))
|
||||
license_plate = db.Column(db.String(20))
|
||||
odometer = db.Column(db.Integer, default=0)
|
||||
photo = db.Column(db.String(255))
|
||||
status = db.Column(db.String(20), default='active')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
service_records = db.relationship('ServiceRecord', backref='vehicle', lazy=True, cascade='all, delete-orphan')
|
||||
fuel_records = db.relationship('FuelRecord', backref='vehicle', lazy=True, cascade='all, delete-orphan')
|
||||
reminders = db.relationship('Reminder', backref='vehicle', lazy=True, cascade='all, delete-orphan')
|
||||
todos = db.relationship('Todo', backref='vehicle', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
class ServiceRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
date = db.Column(db.DateTime, nullable=False)
|
||||
odometer = db.Column(db.Integer, nullable=False)
|
||||
description = db.Column(db.Text, nullable=False)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
notes = db.Column(db.Text)
|
||||
category = db.Column(db.String(50))
|
||||
document_path = db.Column(db.String(255))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
class FuelRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
date = db.Column(db.DateTime, nullable=False)
|
||||
odometer = db.Column(db.Integer, nullable=False)
|
||||
fuel_amount = db.Column(db.Float, nullable=False)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
unit_cost = db.Column(db.Float)
|
||||
distance = db.Column(db.Integer)
|
||||
fuel_economy = db.Column(db.Float)
|
||||
unit = db.Column(db.String(20), default='MPG')
|
||||
notes = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
class Reminder(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
urgency = db.Column(db.String(20), default='not_urgent')
|
||||
due_date = db.Column(db.DateTime)
|
||||
due_odometer = db.Column(db.Integer)
|
||||
metric = db.Column(db.Integer)
|
||||
recurring = db.Column(db.Boolean, default=False)
|
||||
interval_type = db.Column(db.String(20))
|
||||
interval_value = db.Column(db.Integer)
|
||||
notes = db.Column(db.Text)
|
||||
completed = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
class Todo(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
priority = db.Column(db.String(20), default='medium')
|
||||
status = db.Column(db.String(20), default='planned')
|
||||
type = db.Column(db.String(50))
|
||||
notes = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
9
deb-build/opt/masina-dock/backend/requirements.txt
Normal file
9
deb-build/opt/masina-dock/backend/requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Flask==3.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-CORS==4.0.0
|
||||
Werkzeug==3.0.1
|
||||
python-dotenv==1.0.0
|
||||
gunicorn==21.2.0
|
||||
pandas==2.1.4
|
||||
openpyxl==3.1.2
|
||||
371
deb-build/opt/masina-dock/frontend/static/css/style.css
Normal file
371
deb-build/opt/masina-dock/frontend/static/css/style.css
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
:root {
|
||||
--bg-primary: #1a1d2e;
|
||||
--bg-secondary: #2d3250;
|
||||
--bg-tertiary: #424769;
|
||||
--text-primary: #f6f6f6;
|
||||
--text-secondary: #b8b8b8;
|
||||
--accent: #676f9d;
|
||||
--accent-hover: #7d87ab;
|
||||
--success: #4caf50;
|
||||
--warning: #ff9800;
|
||||
--error: #f44336;
|
||||
--urgent: #e91e63;
|
||||
--border: #3d4059;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e8e8e8;
|
||||
--text-primary: #2d3250;
|
||||
--text-secondary: #666666;
|
||||
--accent: #5865f2;
|
||||
--accent-hover: #4752c4;
|
||||
--border: #d0d0d0;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 15px 30px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
nav a:hover, nav a.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.vehicle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.vehicle-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vehicle-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.vehicle-card.sold::after {
|
||||
content: 'SOLD';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-15deg);
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: var(--error);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.vehicle-photo {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.kanban-column h3 {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.kanban-item {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
border-left: 4px solid var(--warning);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.urgency-very-urgent {
|
||||
background: var(--urgent);
|
||||
}
|
||||
|
||||
.urgency-urgent {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.urgency-not-urgent {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 5px 0;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vehicle-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
29
deb-build/opt/masina-dock/frontend/static/images/logo.svg
Normal file
29
deb-build/opt/masina-dock/frontend/static/images/logo.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<defs>
|
||||
<linearGradient id="carGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#676f9d;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#5865f2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="100" cy="100" r="95" fill="url(#carGradient)" opacity="0.1"/>
|
||||
|
||||
<path d="M 50 120 L 60 90 L 140 90 L 150 120 Z" fill="url(#carGradient)" stroke="#ffffff" stroke-width="3"/>
|
||||
|
||||
<rect x="65" y="95" width="25" height="20" fill="#ffffff" opacity="0.7" rx="2"/>
|
||||
<rect x="110" y="95" width="25" height="20" fill="#ffffff" opacity="0.7" rx="2"/>
|
||||
|
||||
<circle cx="70" cy="125" r="12" fill="#2d3250" stroke="#ffffff" stroke-width="3"/>
|
||||
<circle cx="70" cy="125" r="6" fill="#676f9d"/>
|
||||
|
||||
<circle cx="130" cy="125" r="12" fill="#2d3250" stroke="#ffffff" stroke-width="3"/>
|
||||
<circle cx="130" cy="125" r="6" fill="#676f9d"/>
|
||||
|
||||
<path d="M 50 120 L 45 120 L 45 110 L 50 110 Z" fill="url(#carGradient)"/>
|
||||
<path d="M 150 120 L 155 120 L 155 110 L 150 110 Z" fill="url(#carGradient)"/>
|
||||
|
||||
<g transform="translate(100, 155)">
|
||||
<path d="M -25 -5 L -15 5 L -5 -5" stroke="#676f9d" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M 5 -5 L 15 5 L 25 -5" stroke="#676f9d" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
247
deb-build/opt/masina-dock/frontend/static/js/app.js
Normal file
247
deb-build/opt/masina-dock/frontend/static/js/app.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
const API_URL = window.location.origin;
|
||||
let currentUser = null;
|
||||
let currentTheme = 'dark';
|
||||
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
localStorage.setItem('theme', currentTheme);
|
||||
|
||||
if (currentUser) {
|
||||
apiRequest('/api/settings/theme', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ theme: currentTheme })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
try {
|
||||
const data = await apiRequest('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
currentUser = data.user;
|
||||
currentTheme = data.user.theme;
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
|
||||
if (data.user.first_login) {
|
||||
showChangePasswordModal();
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function register(username, email, password) {
|
||||
try {
|
||||
await apiRequest('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password })
|
||||
});
|
||||
|
||||
alert('Registration successful! Please login.');
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiRequest('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVehicles() {
|
||||
try {
|
||||
const vehicles = await apiRequest('/api/vehicles');
|
||||
displayVehicles(vehicles);
|
||||
} catch (error) {
|
||||
console.error('Failed to load vehicles:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayVehicles(vehicles) {
|
||||
const container = document.getElementById('vehicles-container');
|
||||
container.innerHTML = vehicles.map(v => `
|
||||
<div class="vehicle-card ${v.status === 'sold' ? 'sold' : ''}" onclick="viewVehicle(${v.id})">
|
||||
${v.photo ? `<img src="${v.photo}" class="vehicle-photo" alt="${v.make} ${v.model}">` : ''}
|
||||
<h3>${v.year} ${v.make} ${v.model}</h3>
|
||||
<p>${v.vin || 'No VIN'}</p>
|
||||
<p>Odometer: ${v.odometer.toLocaleString()} miles</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function addVehicle(vehicleData) {
|
||||
try {
|
||||
await apiRequest('/api/vehicles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(vehicleData)
|
||||
});
|
||||
|
||||
loadVehicles();
|
||||
closeModal('vehicle-modal');
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServiceRecords(vehicleId) {
|
||||
try {
|
||||
const records = await apiRequest(`/api/vehicles/${vehicleId}/service-records`);
|
||||
displayServiceRecords(records);
|
||||
} catch (error) {
|
||||
console.error('Failed to load service records:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayServiceRecords(records) {
|
||||
const tbody = document.getElementById('service-records-tbody');
|
||||
tbody.innerHTML = records.map(r => `
|
||||
<tr oncontextmenu="showContextMenu(event, 'service', ${r.id})">
|
||||
<td>${new Date(r.date).toLocaleDateString()}</td>
|
||||
<td>${r.odometer.toLocaleString()}</td>
|
||||
<td>${r.description}</td>
|
||||
<td>$${r.cost.toFixed(2)}</td>
|
||||
<td>${r.category || 'N/A'}</td>
|
||||
<td>${r.notes || ''}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadFuelRecords(vehicleId) {
|
||||
try {
|
||||
const records = await apiRequest(`/api/vehicles/${vehicleId}/fuel-records`);
|
||||
displayFuelRecords(records);
|
||||
calculateFuelStats(records);
|
||||
} catch (error) {
|
||||
console.error('Failed to load fuel records:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayFuelRecords(records) {
|
||||
const tbody = document.getElementById('fuel-records-tbody');
|
||||
tbody.innerHTML = records.map(r => `
|
||||
<tr>
|
||||
<td>${new Date(r.date).toLocaleDateString()}</td>
|
||||
<td>${r.odometer.toLocaleString()}</td>
|
||||
<td>${r.fuel_amount.toFixed(2)}</td>
|
||||
<td>$${r.cost.toFixed(2)}</td>
|
||||
<td>${r.distance || 'N/A'}</td>
|
||||
<td>${r.fuel_economy ? r.fuel_economy.toFixed(2) : 'N/A'} ${r.unit}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function calculateFuelStats(records) {
|
||||
const totalCost = records.reduce((sum, r) => sum + r.cost, 0);
|
||||
const avgEconomy = records.filter(r => r.fuel_economy).reduce((sum, r, _, arr) =>
|
||||
sum + r.fuel_economy / arr.length, 0);
|
||||
|
||||
document.getElementById('total-fuel-cost').textContent = `$${totalCost.toFixed(2)}`;
|
||||
document.getElementById('avg-fuel-economy').textContent = avgEconomy.toFixed(2);
|
||||
}
|
||||
|
||||
async function loadReminders(vehicleId) {
|
||||
try {
|
||||
const reminders = await apiRequest(`/api/vehicles/${vehicleId}/reminders`);
|
||||
displayReminders(reminders);
|
||||
} catch (error) {
|
||||
console.error('Failed to load reminders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayReminders(reminders) {
|
||||
const container = document.getElementById('reminders-container');
|
||||
container.innerHTML = reminders.map(r => `
|
||||
<div class="kanban-item urgency-${r.urgency}">
|
||||
<h4>${r.description}</h4>
|
||||
<p>${r.due_date ? `Due: ${new Date(r.due_date).toLocaleDateString()}` : ''}</p>
|
||||
<p>${r.due_odometer ? `At: ${r.due_odometer.toLocaleString()} miles` : ''}</p>
|
||||
<p>${r.notes || ''}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadTodos(vehicleId) {
|
||||
try {
|
||||
const todos = await apiRequest(`/api/vehicles/${vehicleId}/todos`);
|
||||
displayTodos(todos);
|
||||
} catch (error) {
|
||||
console.error('Failed to load todos:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayTodos(todos) {
|
||||
const statuses = ['planned', 'doing', 'testing', 'done'];
|
||||
statuses.forEach(status => {
|
||||
const column = document.getElementById(`todos-${status}`);
|
||||
const filtered = todos.filter(t => t.status === status);
|
||||
column.innerHTML = filtered.map(t => `
|
||||
<div class="kanban-item priority-${t.priority}" draggable="true" data-id="${t.id}">
|
||||
<h4>${t.description}</h4>
|
||||
<p>Cost: $${t.cost.toFixed(2)}</p>
|
||||
<p>${t.type || ''}</p>
|
||||
<small>${t.notes || ''}</small>
|
||||
</div>
|
||||
`).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function showContextMenu(event, type, id) {
|
||||
event.preventDefault();
|
||||
const menu = document.getElementById('context-menu');
|
||||
menu.style.left = `${event.pageX}px`;
|
||||
menu.style.top = `${event.pageY}px`;
|
||||
menu.classList.add('active');
|
||||
menu.dataset.type = type;
|
||||
menu.dataset.id = id;
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
document.getElementById('context-menu')?.classList.remove('active');
|
||||
});
|
||||
|
||||
function showModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('active');
|
||||
}
|
||||
|
||||
async function exportData(type, vehicleId) {
|
||||
window.location.href = `${API_URL}/api/export/${type}?vehicle_id=${vehicleId}`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const theme = localStorage.getItem('theme') || 'dark';
|
||||
currentTheme = theme;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue