Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,38 @@ def to_dict(self):
}


class TripTemplate(db.Model):
"""Reusable trip templates for common routes"""
__tablename__ = 'trip_templates'

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=True)

name = db.Column(db.String(100), nullable=False)
purpose = db.Column(db.String(20), nullable=False)
start_location = db.Column(db.String(200))
end_location = db.Column(db.String(200))
description = db.Column(db.String(200))
notes = db.Column(db.Text)

created_at = db.Column(db.DateTime, default=datetime.utcnow)

user = db.relationship('User', backref=db.backref('trip_templates', lazy='dynamic'))

def to_dict(self):
return {
'id': self.id,
'vehicle_id': self.vehicle_id,
'name': self.name,
'purpose': self.purpose,
'start_location': self.start_location,
'end_location': self.end_location,
'description': self.description,
'notes': self.notes,
}


class ChargingSession(db.Model):
"""EV charging session logging"""
__tablename__ = 'charging_sessions'
Expand Down
134 changes: 131 additions & 3 deletions app/routes/trips.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models import Vehicle, Trip, TRIP_PURPOSES
from flask import jsonify
from app.models import Vehicle, Trip, TripTemplate, TRIP_PURPOSES

bp = Blueprint('trips', __name__, url_prefix='/trips')

Expand Down Expand Up @@ -98,6 +99,13 @@ def new():
# Pre-select vehicle if provided
selected_vehicle_id = request.args.get('vehicle_id', type=int) or current_user.default_vehicle_id

# Pre-fill from template if requested
preload_template_id = request.args.get('template_id', type=int)
if preload_template_id:
tmpl = TripTemplate.query.get(preload_template_id)
if tmpl and tmpl.user_id == current_user.id and tmpl.vehicle_id:
selected_vehicle_id = tmpl.vehicle_id

# Get last odometer for selected vehicle
last_odometer = 0
if selected_vehicle_id:
Expand All @@ -107,12 +115,16 @@ def new():
elif len(vehicles) == 1:
last_odometer = vehicles[0].get_last_odometer()

templates = TripTemplate.query.filter_by(user_id=current_user.id).order_by(TripTemplate.name).all()

return render_template('trips/form.html',
trip=None,
vehicles=vehicles,
purposes=TRIP_PURPOSES,
selected_vehicle_id=selected_vehicle_id,
last_odometer=last_odometer)
last_odometer=last_odometer,
templates=templates,
preload_template_id=preload_template_id)


@bp.route('/<int:trip_id>/edit', methods=['GET', 'POST'])
Expand Down Expand Up @@ -146,7 +158,8 @@ def edit(trip_id):
vehicles=vehicles,
purposes=TRIP_PURPOSES,
selected_vehicle_id=trip.vehicle_id,
last_odometer=trip.vehicle.get_last_odometer())
last_odometer=trip.vehicle.get_last_odometer(),
templates=[])


@bp.route('/<int:trip_id>/delete', methods=['POST'])
Expand All @@ -166,6 +179,121 @@ def delete(trip_id):
return redirect(url_for('trips.index'))


@bp.route('/templates')
@login_required
def templates_index():
"""List trip templates"""
templates = TripTemplate.query.filter_by(user_id=current_user.id).order_by(TripTemplate.name).all()
vehicles = current_user.get_all_vehicles()
return render_template('trips/templates_index.html',
templates=templates,
vehicles=vehicles,
purposes=TRIP_PURPOSES)


@bp.route('/templates/new', methods=['GET', 'POST'])
@login_required
def templates_new():
"""Create a trip template"""
vehicles = current_user.get_all_vehicles()

if request.method == 'POST':
vehicle_id = request.form.get('vehicle_id')
if vehicle_id:
vehicle_id = int(vehicle_id)
vehicle = Vehicle.query.get_or_404(vehicle_id)
if vehicle not in vehicles:
flash(_('Access denied'), 'error')
return redirect(url_for('trips.templates_index'))
else:
vehicle_id = None

template = TripTemplate(
user_id=current_user.id,
vehicle_id=vehicle_id,
name=request.form.get('name'),
purpose=request.form.get('purpose'),
start_location=request.form.get('start_location'),
end_location=request.form.get('end_location'),
description=request.form.get('description'),
notes=request.form.get('notes'),
)
db.session.add(template)
db.session.commit()
flash(_('Template saved'), 'success')
return redirect(url_for('trips.templates_index'))

return render_template('trips/template_form.html',
template=None,
vehicles=vehicles,
purposes=TRIP_PURPOSES)


@bp.route('/templates/<int:template_id>/edit', methods=['GET', 'POST'])
@login_required
def templates_edit(template_id):
"""Edit a trip template"""
template = TripTemplate.query.get_or_404(template_id)
if template.user_id != current_user.id:
flash(_('Access denied'), 'error')
return redirect(url_for('trips.templates_index'))

vehicles = current_user.get_all_vehicles()

if request.method == 'POST':
vehicle_id = request.form.get('vehicle_id')
if vehicle_id:
vehicle_id = int(vehicle_id)
vehicle = Vehicle.query.get_or_404(vehicle_id)
if vehicle not in vehicles:
flash(_('Access denied'), 'error')
return redirect(url_for('trips.templates_index'))
else:
vehicle_id = None

template.vehicle_id = vehicle_id
template.name = request.form.get('name')
template.purpose = request.form.get('purpose')
template.start_location = request.form.get('start_location')
template.end_location = request.form.get('end_location')
template.description = request.form.get('description')
template.notes = request.form.get('notes')

db.session.commit()
flash(_('Template updated'), 'success')
return redirect(url_for('trips.templates_index'))

return render_template('trips/template_form.html',
template=template,
vehicles=vehicles,
purposes=TRIP_PURPOSES)


@bp.route('/templates/<int:template_id>/delete', methods=['POST'])
@login_required
def templates_delete(template_id):
"""Delete a trip template"""
template = TripTemplate.query.get_or_404(template_id)
if template.user_id != current_user.id:
flash(_('Access denied'), 'error')
return redirect(url_for('trips.templates_index'))

db.session.delete(template)
db.session.commit()
flash(_('Template deleted'), 'success')
return redirect(url_for('trips.templates_index'))


@bp.route('/templates/<int:template_id>/data')
@login_required
def templates_data(template_id):
"""Return template data as JSON for pre-filling the trip form"""
template = TripTemplate.query.get_or_404(template_id)
if template.user_id != current_user.id:
return jsonify({'error': 'Access denied'}), 403
return jsonify(template.to_dict())


@bp.route('/report')
@login_required
def report():
Expand Down
47 changes: 44 additions & 3 deletions app/templates/trips/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if trip %}{{ _('Edit Trip') }}{% else %}{{ _('Log Trip') }}{% endif %}</h1>
</div>

{% if not trip and templates %}
<div class="mb-4 bg-white dark:bg-gray-800 shadow rounded-lg p-4">
<div class="flex items-center gap-3">
<label for="template_select" class="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">{{ _('Load template') }}</label>
<select id="template_select" onchange="loadTemplate(this.value)"
class="block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
<option value="">{{ _('— choose a template —') }}</option>
{% for tmpl in templates %}
<option value="{{ tmpl.id }}">{{ tmpl.name }}</option>
{% endfor %}
</select>
<a href="{{ url_for('trips.templates_index') }}" class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 whitespace-nowrap">{{ _('Manage') }}</a>
</div>
</div>
{% endif %}

<form method="POST" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
Expand Down Expand Up @@ -118,6 +134,25 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if trip %}{
</div>

<script>
function loadTemplate(templateId) {
if (!templateId) return;
fetch('{{ url_for("trips.templates_data", template_id=0) }}'.replace('/0/', '/' + templateId + '/'))
.then(r => r.json())
.then(data => {
if (data.vehicle_id) {
const vehicleSelect = document.getElementById('vehicle_id');
vehicleSelect.value = data.vehicle_id;
updateVehicleOdometer(data.vehicle_id);
}
const purpose = document.getElementById('purpose');
if (data.purpose) purpose.value = data.purpose;
if (data.start_location !== null) document.getElementById('start_location').value = data.start_location || '';
if (data.end_location !== null) document.getElementById('end_location').value = data.end_location || '';
if (data.description !== null) document.getElementById('description').value = data.description || '';
if (data.notes !== null) document.getElementById('notes').value = data.notes || '';
});
}

function updateVehicleOdometer(vehicleId) {
const select = document.getElementById('vehicle_id');
const selectedOption = select.options[select.selectedIndex];
Expand Down Expand Up @@ -152,12 +187,18 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if trip %}{
}

document.addEventListener('DOMContentLoaded', function() {
// Initialize distance calculation
calculateDistance();

// Initialize vehicle odometer
const vehicleSelect = document.getElementById('vehicle_id');
updateVehicleOdometer(vehicleSelect.value);

// Auto-load template if specified in URL
{% if preload_template_id %}
const templateSelect = document.getElementById('template_select');
if (templateSelect) {
templateSelect.value = '{{ preload_template_id }}';
loadTemplate('{{ preload_template_id }}');
}
{% endif %}
});
</script>
{% endblock %}
7 changes: 7 additions & 0 deletions app/templates/trips/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _('Trips') }}</h
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _('Track your trips for mileage and tax deductions') }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('trips.templates_index') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"/>
</svg>
{{ _('Templates') }}
</a>
<a href="{{ url_for('trips.report') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
Expand Down
95 changes: 95 additions & 0 deletions app/templates/trips/template_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}{% if template %}{{ _('Edit Template') }}{% else %}{{ _('New Template') }}{% endif %}{% endblock %}

{% block content %}
<div class="max-w-2xl mx-auto">
<div class="mb-6">
<a href="{{ url_for('trips.templates_index') }}" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">&larr; {{ _('Back to templates') }}</a>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{% if template %}{{ _('Edit Template') }}{% else %}{{ _('New Template') }}{% endif %}
</h1>
</div>

<form method="POST" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">

<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Template Name') }} *</label>
<input type="text" name="name" id="name" required
value="{{ template.name if template else '' }}"
placeholder="{{ _('e.g., Office Commute, Client Visit') }}"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
</div>

<div>
<label for="vehicle_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Default Vehicle') }}</label>
<select name="vehicle_id" id="vehicle_id"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
<option value="">{{ _('Any vehicle') }}</option>
{% for vehicle in vehicles %}
<option value="{{ vehicle.id }}"
{% if template and template.vehicle_id == vehicle.id %}selected{% endif %}>
{{ vehicle.name }}
</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _('Pre-selects this vehicle when using the template') }}</p>
</div>

<div>
<label for="purpose" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Purpose') }} *</label>
<select name="purpose" id="purpose" required
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{% for value, label in purposes %}
<option value="{{ value }}" {% if template and template.purpose == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>

<div>
<label for="start_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Location') }}</label>
<input type="text" name="start_location" id="start_location"
value="{{ template.start_location if template else '' }}"
placeholder="{{ _('e.g., Home, Office') }}"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
</div>

<div>
<label for="end_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('End Location') }}</label>
<input type="text" name="end_location" id="end_location"
value="{{ template.end_location if template else '' }}"
placeholder="{{ _('e.g., Client Site, Airport') }}"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
</div>

<div class="sm:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
<input type="text" name="description" id="description"
value="{{ template.description if template else '' }}"
placeholder="{{ _('e.g., Weekly client meeting') }}"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
</div>

<div class="sm:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Notes') }}</label>
<textarea name="notes" id="notes" rows="2"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">{{ template.notes if template else '' }}</textarea>
</div>
</div>
</div>

<div class="flex justify-end space-x-3">
<a href="{{ url_for('trips.templates_index') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-500 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
{{ _('Cancel') }}
</a>
<button type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
{% if template %}{{ _('Save Changes') }}{% else %}{{ _('Save Template') }}{% endif %}
</button>
</div>
</form>
</div>
{% endblock %}
Loading
Loading