1.1
This commit is contained in:
parent
6ff85c8af9
commit
46ee143ab1
BIN
ai/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
ai/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
ai/__pycache__/local_ai_api.cpython-311.pyc
Normal file
BIN
ai/__pycache__/local_ai_api.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -150,9 +150,11 @@ STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
path for path in [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
] if path.exists()
|
||||
]
|
||||
|
||||
# Email
|
||||
|
||||
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/utils.cpython-311.pyc
Normal file
BIN
core/__pycache__/utils.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,3 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import Trip
|
||||
|
||||
|
||||
@admin.register(Trip)
|
||||
class TripAdmin(admin.ModelAdmin):
|
||||
list_display = ("date", "trip_type", "start_location", "end_location", "distance_miles", "distance_source", "updated_at")
|
||||
list_filter = ("trip_type", "distance_source", "date")
|
||||
search_fields = ("start_location", "end_location", "business_purpose", "notes")
|
||||
ordering = ("-date", "-start_time", "-created_at")
|
||||
|
||||
142
core/forms.py
Normal file
142
core/forms.py
Normal file
@ -0,0 +1,142 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Trip
|
||||
|
||||
|
||||
DATETIME_LOCAL_FORMAT = "%Y-%m-%dT%H:%M"
|
||||
|
||||
|
||||
class TripForm(forms.ModelForm):
|
||||
created_at_override = forms.DateTimeField(
|
||||
required=False,
|
||||
label="Custom created timestamp",
|
||||
input_formats=[DATETIME_LOCAL_FORMAT],
|
||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to use now"}, format=DATETIME_LOCAL_FORMAT),
|
||||
help_text="Optional for retroactive entries. Leave blank to use the current timestamp.",
|
||||
)
|
||||
updated_at_override = forms.DateTimeField(
|
||||
required=False,
|
||||
label="Custom updated timestamp",
|
||||
input_formats=[DATETIME_LOCAL_FORMAT],
|
||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to auto-update"}, format=DATETIME_LOCAL_FORMAT),
|
||||
help_text="Optional. If empty while editing, the app will stamp the current time.",
|
||||
)
|
||||
update_end_odometer_from_map = forms.BooleanField(
|
||||
required=False,
|
||||
label="Use Google Maps miles to prefill ending odometer",
|
||||
help_text="If a starting odometer and route miles are available, this suggests an ending odometer.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Trip
|
||||
fields = ["date", "start_time", "end_time", "start_location", "end_location", "business_purpose", "trip_type", "start_odometer", "end_odometer", "distance_miles", "notes"]
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date", "class": "form-control"}),
|
||||
"start_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}),
|
||||
"end_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}),
|
||||
"start_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "123 Market St, San Francisco"}),
|
||||
"end_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Client office, warehouse, property, etc."}),
|
||||
"business_purpose": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Meeting with Client A at Property X"}),
|
||||
"trip_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"start_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}),
|
||||
"end_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}),
|
||||
"distance_miles": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Auto-filled from Google Maps or manual"}),
|
||||
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Client, project, repair details, or extra notes"}),
|
||||
}
|
||||
help_texts = {
|
||||
"business_purpose": "Be specific so the trip is audit-friendly.",
|
||||
"distance_miles": "Preferred source is odometer when both readings are present; otherwise Google Maps mileage is used.",
|
||||
}
|
||||
|
||||
def __init__(self, *args, latest_end_odometer=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.pk and latest_end_odometer is not None and self.initial.get("start_odometer") in (None, ""):
|
||||
self.initial["start_odometer"] = latest_end_odometer
|
||||
|
||||
|
||||
def clean_business_purpose(self):
|
||||
purpose = (self.cleaned_data.get("business_purpose") or "").strip()
|
||||
if len(purpose) < 8:
|
||||
raise forms.ValidationError("Please add a more specific business purpose for this trip.")
|
||||
return purpose
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
start_odometer = cleaned_data.get("start_odometer")
|
||||
end_odometer = cleaned_data.get("end_odometer")
|
||||
distance_miles = cleaned_data.get("distance_miles")
|
||||
should_prefill_end = cleaned_data.get("update_end_odometer_from_map")
|
||||
|
||||
if start_odometer is not None and end_odometer is not None and end_odometer < start_odometer:
|
||||
self.add_error("end_odometer", "Ending odometer must be greater than or equal to starting odometer.")
|
||||
|
||||
if should_prefill_end and start_odometer is not None and distance_miles is not None and not end_odometer:
|
||||
cleaned_data["end_odometer"] = Decimal(start_odometer) + Decimal(distance_miles)
|
||||
self.cleaned_data["end_odometer"] = cleaned_data["end_odometer"]
|
||||
|
||||
if start_odometer is None and end_odometer is not None:
|
||||
self.add_error("start_odometer", "Add a starting odometer before storing an ending odometer.")
|
||||
|
||||
if start_odometer is None and end_odometer is None and distance_miles is None:
|
||||
self.add_error("distance_miles", "Calculate Google Maps mileage or enter odometer readings.")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
trip = super().save(commit=False)
|
||||
now = timezone.now()
|
||||
created_override = self.cleaned_data.get("created_at_override")
|
||||
updated_override = self.cleaned_data.get("updated_at_override")
|
||||
|
||||
if trip.pk:
|
||||
trip.created_at = created_override or trip.created_at
|
||||
trip.updated_at = updated_override or now
|
||||
else:
|
||||
trip.created_at = created_override or now
|
||||
trip.updated_at = updated_override or trip.created_at
|
||||
|
||||
if commit:
|
||||
trip.save()
|
||||
return trip
|
||||
|
||||
|
||||
class TripFilterForm(forms.Form):
|
||||
start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}))
|
||||
end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}))
|
||||
month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Month"}))
|
||||
year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Year"}))
|
||||
trip_type = forms.ChoiceField(required=False, choices=[("", "All trip types"), *Trip.TripType.choices], widget=forms.Select(attrs={"class": "form-select"}))
|
||||
|
||||
|
||||
class ReportFilterForm(forms.Form):
|
||||
REPORT_CHOICES = [("month", "Monthly report"), ("range", "Custom date range"), ("year", "Annual summary")]
|
||||
|
||||
report_type = forms.ChoiceField(choices=REPORT_CHOICES, required=False, initial="month", widget=forms.Select(attrs={"class": "form-select"}))
|
||||
month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control"}))
|
||||
year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control"}))
|
||||
start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}))
|
||||
end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}))
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
report_type = cleaned.get("report_type") or "month"
|
||||
year = cleaned.get("year")
|
||||
month = cleaned.get("month")
|
||||
start_date = cleaned.get("start_date")
|
||||
end_date = cleaned.get("end_date")
|
||||
|
||||
if report_type == "month":
|
||||
if not month or not year:
|
||||
raise forms.ValidationError("Choose both month and year for the monthly report.")
|
||||
elif report_type == "range":
|
||||
if not start_date or not end_date:
|
||||
raise forms.ValidationError("Choose both start and end dates for a custom range.")
|
||||
if end_date < start_date:
|
||||
raise forms.ValidationError("End date must be on or after the start date.")
|
||||
elif report_type == "year":
|
||||
if not year:
|
||||
raise forms.ValidationError("Choose a year for the annual summary.")
|
||||
return cleaned
|
||||
38
core/migrations/0001_initial.py
Normal file
38
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-24 11:31
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Trip',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField()),
|
||||
('start_time', models.TimeField()),
|
||||
('end_time', models.TimeField()),
|
||||
('start_location', models.CharField(max_length=255)),
|
||||
('end_location', models.CharField(max_length=255)),
|
||||
('business_purpose', models.TextField()),
|
||||
('trip_type', models.CharField(choices=[('business', 'Business'), ('personal', 'Personal'), ('commuting', 'Commuting'), ('repair', 'Repair / Maintenance')], max_length=20)),
|
||||
('start_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)),
|
||||
('end_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)),
|
||||
('distance_miles', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True)),
|
||||
('distance_source', models.CharField(choices=[('odometer', 'Odometer'), ('map', 'Google Maps')], default='map', max_length=20)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date', '-start_time', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
@ -1,3 +1,72 @@
|
||||
from django.db import models
|
||||
from decimal import Decimal
|
||||
|
||||
# Create your models here.
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Trip(models.Model):
|
||||
class TripType(models.TextChoices):
|
||||
BUSINESS = "business", "Business"
|
||||
PERSONAL = "personal", "Personal"
|
||||
COMMUTING = "commuting", "Commuting"
|
||||
REPAIR = "repair", "Repair / Maintenance"
|
||||
|
||||
class DistanceSource(models.TextChoices):
|
||||
ODOMETER = "odometer", "Odometer"
|
||||
MAP = "map", "Google Maps"
|
||||
|
||||
date = models.DateField()
|
||||
start_time = models.TimeField()
|
||||
end_time = models.TimeField()
|
||||
start_location = models.CharField(max_length=255)
|
||||
end_location = models.CharField(max_length=255)
|
||||
business_purpose = models.TextField()
|
||||
trip_type = models.CharField(max_length=20, choices=TripType.choices)
|
||||
start_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True)
|
||||
end_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True)
|
||||
distance_miles = models.DecimalField(max_digits=8, decimal_places=1, null=True, blank=True)
|
||||
distance_source = models.CharField(max_length=20, choices=DistanceSource.choices, default=DistanceSource.MAP)
|
||||
notes = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(default=timezone.now)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date", "-start_time", "-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date:%Y-%m-%d} · {self.start_location} → {self.end_location}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("trip_detail", args=[self.pk])
|
||||
|
||||
@property
|
||||
def miles_display(self):
|
||||
return self.distance_miles or Decimal("0.0")
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.end_time and self.start_time and self.end_time < self.start_time:
|
||||
errors["end_time"] = "End time must be after the start time."
|
||||
|
||||
if self.start_odometer is not None and self.end_odometer is not None:
|
||||
if self.end_odometer < self.start_odometer:
|
||||
errors["end_odometer"] = "Ending odometer must be greater than or equal to starting odometer."
|
||||
else:
|
||||
self.distance_miles = self.end_odometer - self.start_odometer
|
||||
self.distance_source = self.DistanceSource.ODOMETER
|
||||
elif self.distance_miles is not None:
|
||||
if self.distance_miles < 0:
|
||||
errors["distance_miles"] = "Distance must be zero or greater."
|
||||
else:
|
||||
self.distance_source = self.DistanceSource.MAP
|
||||
else:
|
||||
errors["distance_miles"] = "Add odometer readings or calculate mileage from Google Maps."
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -1,25 +1,66 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ meta_title|default:"MileLedger" }}{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'IRS-friendly mileage tracking and reporting.' }}{% endblock %}">
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
<div class="site-shell">
|
||||
<div class="hero-orb orb-1"></div>
|
||||
<div class="hero-orb orb-2"></div>
|
||||
<div class="hero-grid"></div>
|
||||
<header class="site-header sticky-top">
|
||||
<nav class="navbar navbar-expand-lg app-navbar">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<a class="navbar-brand brand-lockup" href="{% url 'home' %}">
|
||||
<span class="brand-badge">ML</span>
|
||||
<span>
|
||||
<span class="brand-name">MileLedger</span>
|
||||
<span class="brand-tag">IRS-friendly vehicle log</span>
|
||||
</span>
|
||||
</a>
|
||||
<button class="navbar-toggler nav-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'trip_list' %}">Trips</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'report_view' %}">Reports</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
|
||||
<li class="nav-item ms-lg-2"><a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log trip</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% if messages %}
|
||||
<div class="container-xxl px-3 px-lg-4 pt-4">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags|default:'info' }} app-alert mb-3" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,145 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.runtime code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}{{ meta_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero-section">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="row align-items-center g-4 g-xl-5">
|
||||
<div class="col-lg-7">
|
||||
<div class="eyebrow">Secure mobile mileage journal</div>
|
||||
<h1 class="hero-title">Mileage logging that feels ready for tax season, even from your iPhone.</h1>
|
||||
<p class="hero-copy">Capture retroactive business trips, reuse the last odometer reading, and keep an IRS-style log that is easy to review, export, and print.</p>
|
||||
<div class="hero-actions d-flex flex-wrap gap-3">
|
||||
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log a trip</a>
|
||||
<a class="btn btn-outline-light app-btn-secondary" href="{% url 'report_view' %}">Open reports</a>
|
||||
</div>
|
||||
<div class="hero-note">Default next odometer{% if latest_end_odometer %}: <strong>{{ latest_end_odometer }} mi</strong>{% else %}: <strong>not set yet</strong>{% endif %}</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="glass-panel showcase-card">
|
||||
<div class="showcase-top">
|
||||
<span class="status-pill">Current month</span>
|
||||
<span class="text-body-secondary small">{{ report_month }}/{{ report_year }}</span>
|
||||
</div>
|
||||
<div class="metric-stack">
|
||||
<article class="metric-card">
|
||||
<p>Business miles</p>
|
||||
<h2>{{ month_business|floatformat:1 }}</h2>
|
||||
<span>Captured this month</span>
|
||||
</article>
|
||||
<article class="metric-card accent-card">
|
||||
<p>Business miles YTD</p>
|
||||
<h2>{{ ytd_business|floatformat:1 }}</h2>
|
||||
<span>Ready for annual review</span>
|
||||
</article>
|
||||
<article class="metric-card compact-card">
|
||||
<p>Business trips this month</p>
|
||||
<h2>{{ business_trip_count }}</h2>
|
||||
<span>{% if last_trip %}Last trip {{ last_trip.date|date:"M j" }}{% else %}Add your first trip to start tracking{% endif %}</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
<section class="section-shell section-tight">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="glass-panel info-panel h-100">
|
||||
<div class="section-heading-wrap">
|
||||
<div>
|
||||
<p class="section-kicker">Workflow</p>
|
||||
<h2 class="section-title">Fast entry, odometer continuity, and report-ready detail.</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">01</div>
|
||||
<h3>Trip input built for thumbs</h3>
|
||||
<p>Large fields, stacked layout, clear inline validation, and past-date support for retroactive mileage.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">02</div>
|
||||
<h3>Google Maps assist</h3>
|
||||
<p>Estimate driving miles from your start and destination, then keep the distance editable if the real trip differed.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon">03</div>
|
||||
<h3>IRS-style reporting</h3>
|
||||
<p>Review recent trips, generate report totals, and export CSV for your CPA or year-end archive.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="glass-panel quick-action-panel h-100">
|
||||
<p class="section-kicker">Quick actions</p>
|
||||
<div class="action-list">
|
||||
<a href="{% url 'trip_create' %}" class="action-card">
|
||||
<strong>Start a new trip log</strong>
|
||||
<span>Prefills the next starting odometer when possible.</span>
|
||||
</a>
|
||||
<a href="{% url 'trip_list' %}" class="action-card">
|
||||
<strong>Review trip history</strong>
|
||||
<span>Filter by month, year, trip type, or custom date range.</span>
|
||||
</a>
|
||||
<a href="{% url 'report_view' %}" class="action-card">
|
||||
<strong>Generate a mileage report</strong>
|
||||
<span>Monthly, annual, printable, and CSV export included.</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-shell">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="section-heading-wrap mb-4">
|
||||
<div>
|
||||
<p class="section-kicker">Recent entries</p>
|
||||
<h2 class="section-title">Your latest mileage activity</h2>
|
||||
</div>
|
||||
<a class="section-link" href="{% url 'trip_list' %}">View all trips</a>
|
||||
</div>
|
||||
<div class="glass-panel trip-stream-panel">
|
||||
{% if empty_state %}
|
||||
<div class="empty-state-card">
|
||||
<div class="empty-orb"></div>
|
||||
<h3>No trips logged yet</h3>
|
||||
<p>Start with a business trip and MileLedger will begin carrying your odometer history forward.</p>
|
||||
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log your first trip</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="trip-stream">
|
||||
{% for trip in recent_trips %}
|
||||
<article class="trip-stream-item">
|
||||
<div class="trip-stream-meta">
|
||||
<span class="trip-badge">{{ trip.get_trip_type_display }}</span>
|
||||
<span>{{ trip.date|date:"M j, Y" }} · {{ trip.start_time|time:"g:i A" }}–{{ trip.end_time|time:"g:i A" }}</span>
|
||||
</div>
|
||||
<div class="trip-stream-route">
|
||||
<strong>{{ trip.start_location }}</strong>
|
||||
<span class="route-arrow">→</span>
|
||||
<strong>{{ trip.end_location }}</strong>
|
||||
</div>
|
||||
<div class="trip-stream-footer">
|
||||
<span>{{ trip.distance_miles|floatformat:1 }} miles · {{ trip.get_distance_source_display }}</span>
|
||||
<a href="{% url 'trip_detail' trip.pk %}">Open details</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
67
core/templates/core/report.html
Normal file
67
core/templates/core/report.html
Normal file
@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ meta_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero-sm report-print-header">
|
||||
<div class="container-xxl px-3 px-lg-4 d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
|
||||
<div>
|
||||
<p class="section-kicker">IRS-style mileage log</p>
|
||||
<h1 class="section-title mb-2">Reports</h1>
|
||||
<p class="section-subtitle">Monthly, custom-range, and annual summaries with CSV export and print-friendly tables.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap no-print">
|
||||
<a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_create' %}">Log trip</a>
|
||||
<button class="btn btn-primary app-btn-primary" onclick="window.print()" type="button">Print / Save PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section-shell section-tight pt-0">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="glass-panel mb-4 no-print">
|
||||
<form method="get" class="row g-3 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label" for="{{ form.report_type.id_for_label }}">Report type</label>{{ form.report_type }}</div>
|
||||
<div class="col-md-2"><label class="form-label" for="{{ form.month.id_for_label }}">Month</label>{{ form.month }}</div>
|
||||
<div class="col-md-2"><label class="form-label" for="{{ form.year.id_for_label }}">Year</label>{{ form.year }}</div>
|
||||
<div class="col-md-2"><label class="form-label" for="{{ form.start_date.id_for_label }}">Start date</label>{{ form.start_date }}</div>
|
||||
<div class="col-md-2"><label class="form-label" for="{{ form.end_date.id_for_label }}">End date</label>{{ form.end_date }}</div>
|
||||
<div class="col-md-1 d-grid"><button class="btn btn-primary app-btn-primary" type="submit">Run</button></div>
|
||||
{% if form.non_field_errors %}<div class="col-12"><div class="alert alert-danger app-alert mb-0">{{ form.non_field_errors }}</div></div>{% endif %}
|
||||
<div class="col-12 d-flex flex-wrap gap-2"><a class="btn btn-outline-light app-btn-secondary" href="{% url 'report_export_csv' %}?{{ request.GET.urlencode }}">Export CSV</a></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total business miles</p><h2>{{ summary.business_miles|floatformat:1 }}</h2><span>Primary number for deduction review</span></div></div>
|
||||
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Personal / commuting / repair miles</p><h2>{{ summary.non_business_miles|floatformat:1 }}</h2><span>Useful context for year-end records</span></div></div>
|
||||
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total trips</p><h2>{{ summary.trip_count }}</h2><span>Entries in this report period</span></div></div>
|
||||
</div>
|
||||
<div class="glass-panel table-panel printable-panel">
|
||||
{% if trips %}
|
||||
<div class="table-responsive">
|
||||
<table class="table app-table report-table align-middle mb-0">
|
||||
<thead><tr><th>Date</th><th>Start</th><th>End</th><th>From</th><th>To</th><th>Purpose</th><th>Type</th><th>Start odo</th><th>End odo</th><th>Miles</th></tr></thead>
|
||||
<tbody>
|
||||
{% for trip in trips %}
|
||||
<tr>
|
||||
<td>{{ trip.date|date:"Y-m-d" }}</td>
|
||||
<td>{{ trip.start_time|time:"H:i" }}</td>
|
||||
<td>{{ trip.end_time|time:"H:i" }}</td>
|
||||
<td>{{ trip.start_location }}</td>
|
||||
<td>{{ trip.end_location }}</td>
|
||||
<td>{{ trip.business_purpose }}</td>
|
||||
<td>{{ trip.get_trip_type_display }}</td>
|
||||
<td>{{ trip.start_odometer|default:"" }}</td>
|
||||
<td>{{ trip.end_odometer|default:"" }}</td>
|
||||
<td>{{ trip.distance_miles|floatformat:1 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state-card compact-empty"><h3>No trips in this report period</h3><p>Run a different date range or add a new mileage entry.</p></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
59
core/templates/core/trip_detail.html
Normal file
59
core/templates/core/trip_detail.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ meta_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero-sm">
|
||||
<div class="container-xxl px-3 px-lg-4 d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
|
||||
<div>
|
||||
<p class="section-kicker">Trip detail</p>
|
||||
<h1 class="section-title mb-2">{{ trip.date|date:"F j, Y" }}</h1>
|
||||
<p class="section-subtitle">{{ trip.start_location }} → {{ trip.end_location }}</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_list' %}">Back to list</a>
|
||||
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_update' trip.pk %}">Edit trip</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section-shell section-tight pt-0">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="glass-panel detail-panel h-100">
|
||||
<div class="detail-grid">
|
||||
<article><span>Time</span><strong>{{ trip.start_time|time:"g:i A" }} – {{ trip.end_time|time:"g:i A" }}</strong></article>
|
||||
<article><span>Trip type</span><strong>{{ trip.get_trip_type_display }}</strong></article>
|
||||
<article><span>Miles</span><strong>{{ trip.distance_miles|floatformat:1 }} ({{ trip.get_distance_source_display }})</strong></article>
|
||||
<article><span>Odometer</span><strong>{% if trip.start_odometer or trip.end_odometer %}{{ trip.start_odometer|default:'—' }} → {{ trip.end_odometer|default:'—' }}{% else %}Not recorded{% endif %}</strong></article>
|
||||
</div>
|
||||
<div class="detail-copy mt-4">
|
||||
<h2 class="h5">Business purpose</h2>
|
||||
<p>{{ trip.business_purpose }}</p>
|
||||
<h2 class="h5 mt-4">Notes</h2>
|
||||
<p>{{ trip.notes|default:"No additional notes recorded." }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="glass-panel side-panel h-100">
|
||||
<p class="section-kicker">Audit trail</p>
|
||||
<div class="side-stack mb-4">
|
||||
<article class="side-card"><h3>Created</h3><p>{{ trip.created_at|date:"M j, Y g:i A" }}</p></article>
|
||||
<article class="side-card"><h3>Updated</h3><p>{{ trip.updated_at|date:"M j, Y g:i A" }}</p></article>
|
||||
</div>
|
||||
{% if confirm_delete %}
|
||||
<div class="delete-box">
|
||||
<h3>Delete this trip?</h3>
|
||||
<p>This removes the mileage entry from your log.</p>
|
||||
<form method="post" action="{% url 'trip_delete' trip.pk %}">{% csrf_token %}<div class="d-flex flex-wrap gap-2"><button class="btn btn-danger" type="submit">Confirm delete</button><a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_detail' trip.pk %}">Cancel</a></div></form>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="btn btn-outline-danger w-100" href="{% url 'trip_delete' trip.pk %}">Delete trip</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
148
core/templates/core/trip_form.html
Normal file
148
core/templates/core/trip_form.html
Normal file
@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ meta_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero-sm">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="page-hero-copy">
|
||||
<p class="section-kicker">Trip workflow</p>
|
||||
<h1 class="section-title mb-2">{{ page_heading }}</h1>
|
||||
<p class="section-subtitle">Capture the route, odometer readings, and audit-ready timestamps in one place.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-shell section-tight pt-0">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-8">
|
||||
<div class="glass-panel form-shell">
|
||||
<form method="post" novalidate id="trip-form" data-distance-endpoint="{% url 'distance_estimate' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger app-alert">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="{{ form.date.id_for_label }}">Date of trip</label>
|
||||
{{ form.date }}
|
||||
<div class="field-help">Past dates are supported for retroactive entries.</div>
|
||||
{% for error in form.date.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="{{ form.start_time.id_for_label }}">Start time</label>
|
||||
{{ form.start_time }}
|
||||
{% for error in form.start_time.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="{{ form.end_time.id_for_label }}">End time</label>
|
||||
{{ form.end_time }}
|
||||
{% for error in form.end_time.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="{{ form.start_location.id_for_label }}">Starting location</label>
|
||||
{{ form.start_location }}
|
||||
{% for error in form.start_location.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="{{ form.end_location.id_for_label }}">Destination / ending location</label>
|
||||
{{ form.end_location }}
|
||||
{% for error in form.end_location.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<label class="form-label" for="{{ form.business_purpose.id_for_label }}">Business purpose</label>
|
||||
{{ form.business_purpose }}
|
||||
<div class="field-help">{{ form.business_purpose.help_text }}</div>
|
||||
{% for error in form.business_purpose.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="{{ form.trip_type.id_for_label }}">Trip type</label>
|
||||
{{ form.trip_type }}
|
||||
{% for error in form.trip_type.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="{{ form.start_odometer.id_for_label }}">Starting odometer</label>
|
||||
{{ form.start_odometer }}
|
||||
<div class="field-help">{% if latest_end_odometer %}Suggested from your most recent ending odometer: {{ latest_end_odometer }} miles.{% else %}Optional, but recommended for audit-ready logs.{% endif %}</div>
|
||||
{% for error in form.start_odometer.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="{{ form.end_odometer.id_for_label }}">Ending odometer</label>
|
||||
{{ form.end_odometer }}
|
||||
{% for error in form.end_odometer.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" for="{{ form.distance_miles.id_for_label }}">Distance miles</label>
|
||||
{{ form.distance_miles }}
|
||||
<div class="field-help">{{ form.distance_miles.help_text }}</div>
|
||||
{% for error in form.distance_miles.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="distance-action-bar">
|
||||
<button type="button" class="btn btn-outline-light app-btn-secondary" id="calculate-distance-btn">Calculate with Google Maps</button>
|
||||
<div id="distance-status" class="distance-status">Enter both locations to calculate route mileage.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check app-check">
|
||||
{{ form.update_end_odometer_from_map }}
|
||||
<label class="form-check-label" for="{{ form.update_end_odometer_from_map.id_for_label }}">{{ form.update_end_odometer_from_map.label }}</label>
|
||||
</div>
|
||||
<div class="field-help">{{ form.update_end_odometer_from_map.help_text }}</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="{{ form.notes.id_for_label }}">Notes</label>
|
||||
{{ form.notes }}
|
||||
{% for error in form.notes.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timestamp-panel mt-4">
|
||||
<div class="section-heading-wrap mb-3">
|
||||
<div>
|
||||
<p class="section-kicker">Audit trail</p>
|
||||
<h2 class="section-title h4 mb-0">Timestamp controls</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.created_at_override.id_for_label }}">{{ form.created_at_override.label }}</label>
|
||||
{{ form.created_at_override }}
|
||||
<div class="field-help">{{ form.created_at_override.help_text }}</div>
|
||||
{% for error in form.created_at_override.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="{{ form.updated_at_override.id_for_label }}">{{ form.updated_at_override.label }}</label>
|
||||
{{ form.updated_at_override }}
|
||||
<div class="field-help">{{ form.updated_at_override.help_text }}</div>
|
||||
{% for error in form.updated_at_override.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% if trip %}<p class="audit-caption mt-3 mb-0">Current stored timestamps: created {{ trip.created_at|date:"M j, Y g:i A" }} · updated {{ trip.updated_at|date:"M j, Y g:i A" }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="form-actions d-flex flex-wrap gap-3 mt-4">
|
||||
<button type="submit" class="btn btn-primary app-btn-primary">{{ submit_label }}</button>
|
||||
<a href="{% url 'trip_list' %}" class="btn btn-outline-light app-btn-secondary">Back to trip list</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="glass-panel side-panel h-100">
|
||||
<p class="section-kicker">How mileage is chosen</p>
|
||||
<div class="side-stack">
|
||||
<article class="side-card"><h3>1. Odometer first</h3><p>If both start and end odometer readings are present, MileLedger saves that difference as the primary trip distance.</p></article>
|
||||
<article class="side-card"><h3>2. Google Maps fallback</h3><p>If odometer readings are incomplete, the distance field stores the Google Maps estimate instead.</p></article>
|
||||
<article class="side-card"><h3>3. Human review stays in control</h3><p>You can edit route miles, adjust timestamps, and confirm the ending odometer before saving.</p></article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'js/mileage_app.js' %}?v={{ deployment_timestamp }}"></script>
|
||||
{% endblock %}
|
||||
58
core/templates/core/trip_list.html
Normal file
58
core/templates/core/trip_list.html
Normal file
@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ meta_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero-sm">
|
||||
<div class="container-xxl px-3 px-lg-4 d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
|
||||
<div>
|
||||
<p class="section-kicker">History</p>
|
||||
<h1 class="section-title mb-2">Trip log</h1>
|
||||
<p class="section-subtitle">Filter past mileage entries by date range, tax period, or trip type.</p>
|
||||
</div>
|
||||
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Add another trip</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section-shell section-tight pt-0">
|
||||
<div class="container-xxl px-3 px-lg-4">
|
||||
<div class="glass-panel mb-4">
|
||||
<form method="get" class="row g-3 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label" for="{{ form.start_date.id_for_label }}">Start date</label>{{ form.start_date }}</div>
|
||||
<div class="col-md-3"><label class="form-label" for="{{ form.end_date.id_for_label }}">End date</label>{{ form.end_date }}</div>
|
||||
<div class="col-6 col-md-2"><label class="form-label" for="{{ form.month.id_for_label }}">Month</label>{{ form.month }}</div>
|
||||
<div class="col-6 col-md-2"><label class="form-label" for="{{ form.year.id_for_label }}">Year</label>{{ form.year }}</div>
|
||||
<div class="col-md-2"><label class="form-label" for="{{ form.trip_type.id_for_label }}">Trip type</label>{{ form.trip_type }}</div>
|
||||
<div class="col-12 d-flex flex-wrap gap-2"><button class="btn btn-primary app-btn-primary" type="submit">Apply filters</button><a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_list' %}">Reset</a></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total business miles</p><h2>{{ summary.business_miles|floatformat:1 }}</h2><span>Within the filtered period</span></div></div>
|
||||
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Personal + commuting + repair miles</p><h2>{{ summary.non_business_miles|floatformat:1 }}</h2><span>Included for full context</span></div></div>
|
||||
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total trips</p><h2>{{ summary.trip_count }}</h2><span>Entries matched by the current filters</span></div></div>
|
||||
</div>
|
||||
<div class="glass-panel table-panel">
|
||||
{% if trips %}
|
||||
<div class="table-responsive">
|
||||
<table class="table app-table align-middle mb-0">
|
||||
<thead><tr><th>Date</th><th>Route</th><th>Type</th><th>Miles</th><th>Source</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for trip in trips %}
|
||||
<tr>
|
||||
<td><strong>{{ trip.date|date:"M j, Y" }}</strong><br><span class="table-muted">{{ trip.start_time|time:"g:i A" }}–{{ trip.end_time|time:"g:i A" }}</span></td>
|
||||
<td><strong>{{ trip.start_location }}</strong><div class="table-muted">to {{ trip.end_location }}</div></td>
|
||||
<td>{{ trip.get_trip_type_display }}</td>
|
||||
<td>{{ trip.distance_miles|floatformat:1 }}</td>
|
||||
<td>{{ trip.get_distance_source_display }}</td>
|
||||
<td class="text-end"><a href="{% url 'trip_detail' trip.pk %}">Details</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state-card compact-empty"><h3>No trips match those filters</h3><p>Adjust the date range or log a new trip to populate your history.</p><a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log a trip</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -1,3 +1,38 @@
|
||||
from django.test import TestCase
|
||||
from decimal import Decimal
|
||||
|
||||
# Create your tests here.
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Trip
|
||||
|
||||
|
||||
class TripWorkflowTests(TestCase):
|
||||
def setUp(self):
|
||||
Trip.objects.create(
|
||||
date=timezone.localdate(),
|
||||
start_time="09:00",
|
||||
end_time="10:00",
|
||||
start_location="Office",
|
||||
end_location="Client Site",
|
||||
business_purpose="Meeting with client about quarterly roadmap",
|
||||
trip_type=Trip.TripType.BUSINESS,
|
||||
start_odometer=Decimal("100.0"),
|
||||
end_odometer=Decimal("112.5"),
|
||||
)
|
||||
|
||||
def test_homepage_loads(self):
|
||||
response = self.client.get(reverse("home"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Mileage logging that feels ready for tax season")
|
||||
|
||||
def test_trip_list_loads(self):
|
||||
response = self.client.get(reverse("trip_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Client Site")
|
||||
|
||||
def test_report_csv_exports(self):
|
||||
response = self.client.get(reverse("report_export_csv"), {"report_type": "year", "year": timezone.localdate().year})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "text/csv")
|
||||
self.assertIn("Miles for this trip", response.content.decode("utf-8"))
|
||||
|
||||
10
core/urls.py
10
core/urls.py
@ -1,7 +1,15 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import home
|
||||
from .views import distance_estimate, home, report_export_csv, report_view, trip_create, trip_delete, trip_detail, trip_list, trip_update
|
||||
|
||||
urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
path("trips/", trip_list, name="trip_list"),
|
||||
path("trips/new/", trip_create, name="trip_create"),
|
||||
path("trips/<int:pk>/", trip_detail, name="trip_detail"),
|
||||
path("trips/<int:pk>/edit/", trip_update, name="trip_update"),
|
||||
path("trips/<int:pk>/delete/", trip_delete, name="trip_delete"),
|
||||
path("reports/", report_view, name="report_view"),
|
||||
path("reports/export.csv", report_export_csv, name="report_export_csv"),
|
||||
path("distance/estimate/", distance_estimate, name="distance_estimate"),
|
||||
]
|
||||
|
||||
97
core/utils.py
Normal file
97
core/utils.py
Normal file
@ -0,0 +1,97 @@
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from io import StringIO
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlopen
|
||||
|
||||
from django.db.models import DecimalField, Q, Sum, Value
|
||||
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear
|
||||
|
||||
from .models import Trip
|
||||
|
||||
|
||||
@dataclass
|
||||
class DistanceResult:
|
||||
ok: bool
|
||||
miles: Decimal | None = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
def calculate_google_maps_distance(start_location: str, end_location: str) -> DistanceResult:
|
||||
api_key = os.getenv("GOOGLE_MAPS_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return DistanceResult(ok=False, message="Google Maps API key is not configured yet. Add GOOGLE_MAPS_API_KEY to enable route mileage.")
|
||||
|
||||
params = urlencode({"origins": start_location, "destinations": end_location, "mode": "driving", "units": "imperial", "key": api_key})
|
||||
url = f"https://maps.googleapis.com/maps/api/distancematrix/json?{params}"
|
||||
|
||||
try:
|
||||
with urlopen(url, timeout=12) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (HTTPError, URLError, TimeoutError):
|
||||
return DistanceResult(ok=False, message="We could not reach Google Maps right now. Please try again or enter miles manually.")
|
||||
|
||||
if payload.get("status") != "OK":
|
||||
return DistanceResult(ok=False, message="Google Maps could not validate that route. Please refine both addresses.")
|
||||
|
||||
rows = payload.get("rows") or []
|
||||
elements = (rows[0].get("elements") if rows else []) or []
|
||||
element = elements[0] if elements else {}
|
||||
if element.get("status") != "OK":
|
||||
return DistanceResult(ok=False, message="Google Maps could not find that route. Try a fuller street address or city/state.")
|
||||
|
||||
meters = element.get("distance", {}).get("value")
|
||||
if meters is None:
|
||||
return DistanceResult(ok=False, message="Google Maps did not return a distance for that route.")
|
||||
|
||||
miles = (Decimal(str(meters)) / Decimal("1609.344")).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
|
||||
return DistanceResult(ok=True, miles=miles, message="Driving mileage calculated from Google Maps.")
|
||||
|
||||
|
||||
def apply_trip_filters(queryset, data):
|
||||
if data.get("start_date"):
|
||||
queryset = queryset.filter(date__gte=data["start_date"])
|
||||
if data.get("end_date"):
|
||||
queryset = queryset.filter(date__lte=data["end_date"])
|
||||
if data.get("month"):
|
||||
queryset = queryset.annotate(filter_month=ExtractMonth("date")).filter(filter_month=data["month"])
|
||||
if data.get("year"):
|
||||
queryset = queryset.annotate(filter_year=ExtractYear("date")).filter(filter_year=data["year"])
|
||||
if data.get("trip_type"):
|
||||
queryset = queryset.filter(trip_type=data["trip_type"])
|
||||
return queryset
|
||||
|
||||
|
||||
def report_queryset(cleaned_data):
|
||||
queryset = Trip.objects.all()
|
||||
report_type = cleaned_data.get("report_type")
|
||||
if report_type == "month":
|
||||
queryset = queryset.filter(date__month=cleaned_data["month"], date__year=cleaned_data["year"])
|
||||
elif report_type == "range":
|
||||
queryset = queryset.filter(date__range=(cleaned_data["start_date"], cleaned_data["end_date"]))
|
||||
elif report_type == "year":
|
||||
queryset = queryset.filter(date__year=cleaned_data["year"])
|
||||
return queryset.order_by("date", "start_time", "created_at")
|
||||
|
||||
|
||||
def summarize_trips(queryset):
|
||||
aggregates = queryset.aggregate(
|
||||
business_miles=Coalesce(Sum("distance_miles", filter=Q(trip_type=Trip.TripType.BUSINESS)), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)),
|
||||
non_business_miles=Coalesce(Sum("distance_miles", filter=~Q(trip_type=Trip.TripType.BUSINESS)), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)),
|
||||
total_miles=Coalesce(Sum("distance_miles"), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)),
|
||||
)
|
||||
aggregates["trip_count"] = queryset.count()
|
||||
return aggregates
|
||||
|
||||
|
||||
def export_trips_csv(trips):
|
||||
buffer = StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(["Date of trip", "Start time", "End time", "Starting location", "Destination", "Business purpose", "Trip type", "Starting odometer", "Ending odometer", "Miles for this trip", "Distance source", "Notes", "Created at", "Updated at"])
|
||||
for trip in trips:
|
||||
writer.writerow([trip.date, trip.start_time, trip.end_time, trip.start_location, trip.end_location, trip.business_purpose, trip.get_trip_type_display(), trip.start_odometer or "", trip.end_odometer or "", trip.distance_miles or "", trip.get_distance_source_display(), trip.notes, trip.created_at, trip.updated_at])
|
||||
return buffer.getvalue()
|
||||
153
core/views.py
153
core/views.py
@ -1,25 +1,146 @@
|
||||
import os
|
||||
import platform
|
||||
from decimal import Decimal
|
||||
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
from django.contrib import messages
|
||||
from django.db.models import Sum
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from .forms import ReportFilterForm, TripFilterForm, TripForm
|
||||
from .models import Trip
|
||||
from .utils import apply_trip_filters, calculate_google_maps_distance, export_trips_csv, report_queryset, summarize_trips
|
||||
|
||||
|
||||
DEFAULT_META_DESCRIPTION = "Mobile-friendly mileage logging with odometer history, Google Maps assist, and IRS-style reporting."
|
||||
|
||||
|
||||
def latest_trip_with_odometer():
|
||||
return Trip.objects.exclude(end_odometer__isnull=True).order_by("-date", "-end_time", "-created_at").first()
|
||||
|
||||
|
||||
def home(request):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
today = timezone.localdate()
|
||||
current_month = Trip.objects.filter(date__year=today.year, date__month=today.month)
|
||||
ytd = Trip.objects.filter(date__year=today.year)
|
||||
recent_trips = Trip.objects.all()[:5]
|
||||
|
||||
month_business = current_month.filter(trip_type=Trip.TripType.BUSINESS).aggregate(total=Sum("distance_miles"))["total"] or Decimal("0.0")
|
||||
ytd_business = ytd.filter(trip_type=Trip.TripType.BUSINESS).aggregate(total=Sum("distance_miles"))["total"] or Decimal("0.0")
|
||||
business_trip_count = current_month.filter(trip_type=Trip.TripType.BUSINESS).count()
|
||||
last_trip = Trip.objects.order_by("-date", "-end_time", "-created_at").first()
|
||||
latest_odo = latest_trip_with_odometer()
|
||||
|
||||
context = {
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
"project_name": "MileLedger",
|
||||
"meta_title": "MileLedger | IRS-friendly mileage tracking for mobile",
|
||||
"meta_description": DEFAULT_META_DESCRIPTION,
|
||||
"month_business": month_business,
|
||||
"ytd_business": ytd_business,
|
||||
"business_trip_count": business_trip_count,
|
||||
"recent_trips": recent_trips,
|
||||
"last_trip": last_trip,
|
||||
"latest_end_odometer": latest_odo.end_odometer if latest_odo else None,
|
||||
"report_month": today.month,
|
||||
"report_year": today.year,
|
||||
"empty_state": not Trip.objects.exists(),
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def trip_create(request):
|
||||
latest_odo = latest_trip_with_odometer()
|
||||
latest_end_odometer = latest_odo.end_odometer if latest_odo else None
|
||||
if request.method == "POST":
|
||||
form = TripForm(request.POST, latest_end_odometer=latest_end_odometer)
|
||||
if form.is_valid():
|
||||
trip = form.save()
|
||||
messages.success(request, "Trip saved. Your mileage log is updated and ready for review.")
|
||||
return redirect("trip_detail", pk=trip.pk)
|
||||
else:
|
||||
now = timezone.localtime(timezone.now())
|
||||
form = TripForm(initial={"date": timezone.localdate(), "start_time": now.strftime("%H:%M"), "end_time": now.strftime("%H:%M"), "trip_type": Trip.TripType.BUSINESS}, latest_end_odometer=latest_end_odometer)
|
||||
|
||||
return render(request, "core/trip_form.html", {"form": form, "page_heading": "Add a mileage entry", "submit_label": "Save trip", "meta_title": "Log a trip | MileLedger", "meta_description": DEFAULT_META_DESCRIPTION, "latest_end_odometer": latest_end_odometer, "trip": None})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def trip_update(request, pk):
|
||||
trip = get_object_or_404(Trip, pk=pk)
|
||||
if request.method == "POST":
|
||||
form = TripForm(request.POST, instance=trip)
|
||||
if form.is_valid():
|
||||
trip = form.save()
|
||||
messages.success(request, "Trip updated. Your mileage history now reflects the latest details.")
|
||||
return redirect("trip_detail", pk=trip.pk)
|
||||
else:
|
||||
form = TripForm(instance=trip)
|
||||
|
||||
return render(request, "core/trip_form.html", {"form": form, "page_heading": "Update trip details", "submit_label": "Save changes", "meta_title": "Edit trip | MileLedger", "meta_description": DEFAULT_META_DESCRIPTION, "trip": trip, "latest_end_odometer": None})
|
||||
|
||||
|
||||
@require_GET
|
||||
def trip_list(request):
|
||||
form = TripFilterForm(request.GET or None)
|
||||
trips = Trip.objects.all()
|
||||
if form.is_valid():
|
||||
trips = apply_trip_filters(trips, form.cleaned_data)
|
||||
context = {"form": form, "trips": trips[:100], "summary": summarize_trips(trips), "meta_title": "Trip history | MileLedger", "meta_description": "Review, filter, and audit mileage entries."}
|
||||
return render(request, "core/trip_list.html", context)
|
||||
|
||||
|
||||
@require_GET
|
||||
def trip_detail(request, pk):
|
||||
trip = get_object_or_404(Trip, pk=pk)
|
||||
return render(request, "core/trip_detail.html", {"trip": trip, "meta_title": f"Trip on {trip.date:%b %d, %Y} | MileLedger", "meta_description": "Trip detail and audit-ready timestamps."})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def trip_delete(request, pk):
|
||||
trip = get_object_or_404(Trip, pk=pk)
|
||||
if request.method == "POST":
|
||||
trip.delete()
|
||||
messages.success(request, "Trip deleted.")
|
||||
return redirect("trip_list")
|
||||
return render(request, "core/trip_detail.html", {"trip": trip, "confirm_delete": True, "meta_title": "Delete trip | MileLedger", "meta_description": "Confirm trip deletion."})
|
||||
|
||||
|
||||
@require_GET
|
||||
def report_view(request):
|
||||
today = timezone.localdate()
|
||||
initial = {"report_type": "month", "month": today.month, "year": today.year}
|
||||
form = ReportFilterForm(request.GET or initial)
|
||||
trips = Trip.objects.none()
|
||||
summary = {"business_miles": Decimal("0.0"), "non_business_miles": Decimal("0.0"), "total_miles": Decimal("0.0"), "trip_count": 0}
|
||||
if form.is_valid():
|
||||
trips = report_queryset(form.cleaned_data)
|
||||
summary = summarize_trips(trips)
|
||||
return render(request, "core/report.html", {"form": form, "trips": trips, "summary": summary, "meta_title": "IRS-style mileage report | MileLedger", "meta_description": "Generate monthly, annual, or custom mileage reports and export them to CSV."})
|
||||
|
||||
|
||||
@require_GET
|
||||
def report_export_csv(request):
|
||||
today = timezone.localdate()
|
||||
initial = {"report_type": "month", "month": today.month, "year": today.year}
|
||||
form = ReportFilterForm(request.GET or initial)
|
||||
if not form.is_valid():
|
||||
messages.error(request, "Choose a valid report filter before exporting.")
|
||||
return redirect("report_view")
|
||||
|
||||
trips = report_queryset(form.cleaned_data)
|
||||
response = HttpResponse(export_trips_csv(trips), content_type="text/csv")
|
||||
response["Content-Disposition"] = 'attachment; filename="mileage-report.csv"'
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
def distance_estimate(request):
|
||||
start_location = (request.POST.get("start_location") or "").strip()
|
||||
end_location = (request.POST.get("end_location") or "").strip()
|
||||
if not start_location or not end_location:
|
||||
return JsonResponse({"ok": False, "message": "Enter both a start and destination first."}, status=400)
|
||||
|
||||
result = calculate_google_maps_distance(start_location, end_location)
|
||||
status = 200 if result.ok else 422
|
||||
return JsonResponse({"ok": result.ok, "miles": float(result.miles) if result.miles is not None else None, "message": result.message}, status=status)
|
||||
|
||||
@ -1,4 +1,130 @@
|
||||
/* Custom styles for the application */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
||||
:root {
|
||||
--app-bg: #f6f2ea;
|
||||
--app-surface: rgba(255, 252, 248, 0.8);
|
||||
--app-surface-strong: #fffaf3;
|
||||
--app-border: rgba(17, 24, 39, 0.1);
|
||||
--app-text: #18212b;
|
||||
--app-muted: #5f6875;
|
||||
--app-primary: #0e776f;
|
||||
--app-primary-dark: #0a5d57;
|
||||
--app-secondary: #152033;
|
||||
--app-accent: #f07a56;
|
||||
--app-highlight: #f2c66c;
|
||||
--app-shadow: 0 28px 60px rgba(17, 24, 39, 0.12);
|
||||
--app-radius-xl: 28px;
|
||||
--app-radius-lg: 22px;
|
||||
--app-radius-md: 16px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--app-text);
|
||||
background: radial-gradient(circle at top left, rgba(240, 122, 86, 0.15), transparent 34%), radial-gradient(circle at 85% 15%, rgba(14, 119, 111, 0.18), transparent 24%), linear-gradient(180deg, #fcfaf6 0%, var(--app-bg) 100%);
|
||||
}
|
||||
h1,h2,h3,h4,h5,h6,.navbar-brand,.btn { font-family: 'Manrope', 'Inter', sans-serif; }
|
||||
a { color: var(--app-primary); text-decoration: none; }
|
||||
a:hover { color: var(--app-primary-dark); }
|
||||
.site-shell { position: relative; overflow: hidden; }
|
||||
.hero-grid {
|
||||
position: fixed; inset: 0;
|
||||
background-image: linear-gradient(rgba(17, 24, 39, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(17, 24, 39, 0.03) 1px, transparent 1px);
|
||||
background-size: 42px 42px; mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%);
|
||||
pointer-events: none; z-index: -2;
|
||||
}
|
||||
.hero-orb { position: fixed; border-radius: 50%; pointer-events: none; z-index: -1; }
|
||||
.orb-1 { width: 320px; height: 320px; top: -96px; right: -90px; background: radial-gradient(circle, rgba(240, 122, 86, 0.28), rgba(240, 122, 86, 0) 68%); }
|
||||
.orb-2 { width: 400px; height: 400px; bottom: -160px; left: -120px; background: radial-gradient(circle, rgba(14, 119, 111, 0.18), rgba(14, 119, 111, 0) 70%); }
|
||||
.site-header { padding: 1rem 0; backdrop-filter: blur(20px); background: rgba(250, 245, 238, 0.72); border-bottom: 1px solid rgba(17, 24, 39, 0.05); }
|
||||
.app-navbar { padding: 0; }
|
||||
.brand-lockup { display: inline-flex; align-items: center; gap: 0.85rem; }
|
||||
.brand-badge { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 34px rgba(14, 119, 111, 0.22); }
|
||||
.brand-name,.brand-tag { display: block; line-height: 1.05; }
|
||||
.brand-name { font-size: 1rem; font-weight: 800; color: var(--app-secondary); }
|
||||
.brand-tag { font-size: 0.75rem; color: var(--app-muted); letter-spacing: 0.04em; text-transform: uppercase; margin-top: 0.2rem; }
|
||||
.nav-link { color: var(--app-muted); font-weight: 600; }
|
||||
.nav-link:hover,.nav-link:focus { color: var(--app-secondary); }
|
||||
.nav-toggle { border: 1px solid rgba(17, 24, 39, 0.08); border-radius: 14px; }
|
||||
.hero-section,.page-hero-sm,.section-shell { position: relative; }
|
||||
.hero-section { padding: 4.5rem 0 2rem; }
|
||||
.page-hero-sm { padding: 3rem 0 1.25rem; }
|
||||
.section-shell { padding: 1.5rem 0 3rem; }
|
||||
.section-tight { padding-top: 0.5rem; }
|
||||
.eyebrow,.section-kicker { display: inline-block; margin-bottom: 1rem; padding: 0.45rem 0.8rem; border-radius: 999px; color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.hero-title,.section-title { font-size: clamp(2.4rem, 4vw, 4.7rem); line-height: 0.98; letter-spacing: -0.04em; color: var(--app-secondary); margin-bottom: 1rem; }
|
||||
.page-hero-sm .section-title,.h4.section-title { font-size: clamp(2rem, 3vw, 3rem); }
|
||||
.hero-copy,.section-subtitle { max-width: 46rem; font-size: 1.05rem; color: var(--app-muted); line-height: 1.75; }
|
||||
.hero-actions,.form-actions { margin-top: 2rem; }
|
||||
.hero-note,.audit-caption,.table-muted,.field-help,.distance-status { color: var(--app-muted); font-size: 0.92rem; line-height: 1.55; }
|
||||
.hero-note { margin-top: 1.15rem; }
|
||||
.glass-panel,.metric-card,.feature-card,.action-card,.side-card,.trip-stream-item,.empty-state-card,.panel-solid { background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 250, 244, 0.78)); border: 1px solid var(--app-border); box-shadow: var(--app-shadow); border-radius: var(--app-radius-xl); }
|
||||
.glass-panel { padding: clamp(1.25rem, 3vw, 2rem); backdrop-filter: blur(18px); }
|
||||
.showcase-top,.section-heading-wrap,.trip-stream-footer,.distance-action-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.status-pill,.trip-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.8rem; border-radius: 999px; background: rgba(21, 32, 51, 0.08); color: var(--app-secondary); font-size: 0.8rem; font-weight: 700; }
|
||||
.metric-stack,.side-stack,.action-list,.feature-grid,.trip-stream { display: grid; gap: 1rem; }
|
||||
.metric-card,.feature-card,.side-card,.panel-solid { padding: 1.35rem; }
|
||||
.metric-card h2 { font-size: clamp(2rem, 3.8vw, 3rem); line-height: 1; margin: 0.5rem 0; }
|
||||
.metric-card p,.metric-card span,.feature-card p,.side-card p,.action-card span,.trip-stream-item p,.delete-box p { margin: 0; color: var(--app-muted); }
|
||||
.metric-card p,.feature-card h3,.side-card h3,.action-card strong,.empty-state-card h3,.delete-box h3 { color: var(--app-secondary); }
|
||||
.accent-card { background: linear-gradient(135deg, rgba(240, 122, 86, 0.16), rgba(242, 198, 108, 0.16)); }
|
||||
.panel-solid { background: linear-gradient(180deg, #fffaf3, #fffdfa); }
|
||||
.feature-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 1.5rem; }
|
||||
.feature-icon { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 0.82rem; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--app-secondary), #334155); }
|
||||
.action-card { display: block; padding: 1.1rem 1.15rem; transition: transform 0.2s ease, border-color 0.2s ease; }
|
||||
.action-card:hover { transform: translateY(-2px); border-color: rgba(14, 119, 111, 0.3); }
|
||||
.trip-stream-item { padding: 1.2rem; }
|
||||
.trip-stream-meta,.trip-stream-route,.trip-stream-footer { display: flex; gap: 0.85rem; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.trip-stream-route { margin: 0.9rem 0; font-size: 1rem; }
|
||||
.route-arrow { color: var(--app-accent); font-weight: 700; }
|
||||
.empty-state-card { position: relative; text-align: center; padding: clamp(2rem, 5vw, 3rem); }
|
||||
.empty-orb { width: 92px; height: 92px; margin: 0 auto 1rem; border-radius: 28px; background: linear-gradient(135deg, rgba(14, 119, 111, 0.18), rgba(240, 122, 86, 0.2)); }
|
||||
.compact-empty { text-align: left; }
|
||||
.form-label { font-weight: 700; color: var(--app-secondary); margin-bottom: 0.5rem; }
|
||||
.form-control,.form-select { min-height: 3.2rem; border-radius: 16px; border: 1px solid rgba(17, 24, 39, 0.1); background: rgba(255, 255, 255, 0.94); padding: 0.85rem 1rem; color: var(--app-text); }
|
||||
textarea.form-control { min-height: 8.5rem; }
|
||||
.form-control:focus,.form-select:focus,.form-check-input:focus,.btn:focus { border-color: rgba(14, 119, 111, 0.52); box-shadow: 0 0 0 0.22rem rgba(14, 119, 111, 0.15); }
|
||||
.field-error { color: #b42318; font-size: 0.9rem; margin-top: 0.35rem; }
|
||||
.app-check { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.form-check-input { width: 1.15rem; height: 1.15rem; margin-top: 0; }
|
||||
.form-check-input:checked { background-color: var(--app-primary); border-color: var(--app-primary); }
|
||||
.distance-status { padding: 0.85rem 1rem; border-radius: 16px; background: rgba(21, 32, 51, 0.06); }
|
||||
.distance-status.is-success { color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); }
|
||||
.distance-status.is-error { color: #b42318; background: rgba(212, 59, 48, 0.1); }
|
||||
.timestamp-panel,.delete-box { padding: 1.2rem; border-radius: 22px; background: rgba(21, 32, 51, 0.04); border: 1px solid rgba(17, 24, 39, 0.06); }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||
.detail-grid article,.detail-copy p { line-height: 1.65; }
|
||||
.detail-grid span { display: block; color: var(--app-muted); font-size: 0.88rem; margin-bottom: 0.35rem; }
|
||||
.detail-grid strong,.detail-copy h2,.delete-box h3 { color: var(--app-secondary); }
|
||||
.app-btn-primary,.app-btn-secondary { min-height: 3rem; padding: 0.85rem 1.25rem; border-radius: 999px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.app-btn-primary { border: none; color: #fff; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 32px rgba(14, 119, 111, 0.22); }
|
||||
.app-btn-primary:hover,.app-btn-primary:focus { color: #fff; background: linear-gradient(135deg, var(--app-primary-dark), #0e8c84); }
|
||||
.app-btn-secondary { color: var(--app-secondary); border-color: rgba(21, 32, 51, 0.14); background: rgba(255, 255, 255, 0.72); }
|
||||
.app-btn-secondary:hover,.app-btn-secondary:focus { color: var(--app-secondary); border-color: rgba(14, 119, 111, 0.32); background: rgba(255, 255, 255, 0.9); }
|
||||
.app-alert { border-radius: 18px; border: 1px solid rgba(17, 24, 39, 0.06); }
|
||||
.section-link { color: var(--app-secondary); font-weight: 700; }
|
||||
.table-panel { overflow: hidden; }
|
||||
.app-table { --bs-table-bg: transparent; --bs-table-border-color: rgba(17, 24, 39, 0.08); }
|
||||
.app-table thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--app-muted); border-bottom-width: 1px; padding-top: 1.1rem; padding-bottom: 1.1rem; }
|
||||
.app-table tbody td { padding-top: 1rem; padding-bottom: 1rem; color: var(--app-text); }
|
||||
.no-print { display: initial; }
|
||||
@media (max-width: 991.98px) {
|
||||
.hero-section { padding-top: 3rem; }
|
||||
.hero-title,.section-title { max-width: 16ch; }
|
||||
.site-header { padding: 0.85rem 0; }
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.hero-section,.page-hero-sm { padding-top: 2rem; }
|
||||
.glass-panel,.metric-card,.feature-card,.side-card,.trip-stream-item,.panel-solid { border-radius: 22px; }
|
||||
.distance-action-bar,.section-heading-wrap,.trip-stream-footer,.trip-stream-route,.trip-stream-meta { align-items: flex-start; flex-direction: column; }
|
||||
.table-responsive { border-radius: 22px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.site-header,.hero-orb,.hero-grid,.no-print,.app-alert { display: none !important; }
|
||||
.glass-panel,.panel-solid,.metric-card { box-shadow: none; background: #fff; border: 1px solid #d6d6d6; }
|
||||
.section-shell,.page-hero-sm { padding: 0; }
|
||||
.report-table th,.report-table td { font-size: 0.78rem; }
|
||||
}
|
||||
|
||||
81
static/js/mileage_app.js
Normal file
81
static/js/mileage_app.js
Normal file
@ -0,0 +1,81 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.querySelector('#trip-form');
|
||||
if (!form) return;
|
||||
|
||||
const startInput = form.querySelector('#id_start_location');
|
||||
const endInput = form.querySelector('#id_end_location');
|
||||
const distanceInput = form.querySelector('#id_distance_miles');
|
||||
const startOdometerInput = form.querySelector('#id_start_odometer');
|
||||
const endOdometerInput = form.querySelector('#id_end_odometer');
|
||||
const useMapCheckbox = form.querySelector('#id_update_end_odometer_from_map');
|
||||
const statusBox = document.querySelector('#distance-status');
|
||||
const button = document.querySelector('#calculate-distance-btn');
|
||||
const endpoint = form.dataset.distanceEndpoint;
|
||||
const csrfToken = form.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
||||
|
||||
const setStatus = (message, mode = '') => {
|
||||
statusBox.textContent = message;
|
||||
statusBox.classList.remove('is-success', 'is-error');
|
||||
if (mode) statusBox.classList.add(mode);
|
||||
};
|
||||
|
||||
const maybeUpdateEndOdometer = () => {
|
||||
if (!useMapCheckbox.checked) return;
|
||||
const startOdometer = parseFloat(startOdometerInput.value || '');
|
||||
const distanceMiles = parseFloat(distanceInput.value || '');
|
||||
if (!Number.isNaN(startOdometer) && !Number.isNaN(distanceMiles)) {
|
||||
endOdometerInput.value = (startOdometer + distanceMiles).toFixed(1);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDistance = async () => {
|
||||
const startLocation = startInput.value.trim();
|
||||
const endLocation = endInput.value.trim();
|
||||
if (!startLocation || !endLocation) {
|
||||
setStatus('Enter both locations to calculate route mileage.');
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
setStatus('Calculating driving miles from Google Maps…');
|
||||
|
||||
const body = new URLSearchParams({
|
||||
start_location: startLocation,
|
||||
end_location: endLocation,
|
||||
csrfmiddlewaretoken: csrfToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
setStatus(payload.message || 'Mileage could not be calculated. Please refine the addresses.', 'is-error');
|
||||
return;
|
||||
}
|
||||
distanceInput.value = Number(payload.miles).toFixed(1);
|
||||
maybeUpdateEndOdometer();
|
||||
setStatus(`${payload.message} You can still override the miles before saving.`, 'is-success');
|
||||
} catch (error) {
|
||||
setStatus('Mileage could not be calculated right now. Please try again or enter miles manually.', 'is-error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', calculateDistance);
|
||||
[startInput, endInput].forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
if (startInput.value.trim() && endInput.value.trim()) calculateDistance();
|
||||
});
|
||||
});
|
||||
useMapCheckbox.addEventListener('change', maybeUpdateEndOdometer);
|
||||
startOdometerInput.addEventListener('input', maybeUpdateEndOdometer);
|
||||
distanceInput.addEventListener('input', maybeUpdateEndOdometer);
|
||||
});
|
||||
@ -1,21 +1,130 @@
|
||||
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
--app-bg: #f6f2ea;
|
||||
--app-surface: rgba(255, 252, 248, 0.8);
|
||||
--app-surface-strong: #fffaf3;
|
||||
--app-border: rgba(17, 24, 39, 0.1);
|
||||
--app-text: #18212b;
|
||||
--app-muted: #5f6875;
|
||||
--app-primary: #0e776f;
|
||||
--app-primary-dark: #0a5d57;
|
||||
--app-secondary: #152033;
|
||||
--app-accent: #f07a56;
|
||||
--app-highlight: #f2c66c;
|
||||
--app-shadow: 0 28px 60px rgba(17, 24, 39, 0.12);
|
||||
--app-radius-xl: 28px;
|
||||
--app-radius-lg: 22px;
|
||||
--app-radius-md: 16px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--app-text);
|
||||
background: radial-gradient(circle at top left, rgba(240, 122, 86, 0.15), transparent 34%), radial-gradient(circle at 85% 15%, rgba(14, 119, 111, 0.18), transparent 24%), linear-gradient(180deg, #fcfaf6 0%, var(--app-bg) 100%);
|
||||
}
|
||||
h1,h2,h3,h4,h5,h6,.navbar-brand,.btn { font-family: 'Manrope', 'Inter', sans-serif; }
|
||||
a { color: var(--app-primary); text-decoration: none; }
|
||||
a:hover { color: var(--app-primary-dark); }
|
||||
.site-shell { position: relative; overflow: hidden; }
|
||||
.hero-grid {
|
||||
position: fixed; inset: 0;
|
||||
background-image: linear-gradient(rgba(17, 24, 39, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(17, 24, 39, 0.03) 1px, transparent 1px);
|
||||
background-size: 42px 42px; mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%);
|
||||
pointer-events: none; z-index: -2;
|
||||
}
|
||||
.hero-orb { position: fixed; border-radius: 50%; pointer-events: none; z-index: -1; }
|
||||
.orb-1 { width: 320px; height: 320px; top: -96px; right: -90px; background: radial-gradient(circle, rgba(240, 122, 86, 0.28), rgba(240, 122, 86, 0) 68%); }
|
||||
.orb-2 { width: 400px; height: 400px; bottom: -160px; left: -120px; background: radial-gradient(circle, rgba(14, 119, 111, 0.18), rgba(14, 119, 111, 0) 70%); }
|
||||
.site-header { padding: 1rem 0; backdrop-filter: blur(20px); background: rgba(250, 245, 238, 0.72); border-bottom: 1px solid rgba(17, 24, 39, 0.05); }
|
||||
.app-navbar { padding: 0; }
|
||||
.brand-lockup { display: inline-flex; align-items: center; gap: 0.85rem; }
|
||||
.brand-badge { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 34px rgba(14, 119, 111, 0.22); }
|
||||
.brand-name,.brand-tag { display: block; line-height: 1.05; }
|
||||
.brand-name { font-size: 1rem; font-weight: 800; color: var(--app-secondary); }
|
||||
.brand-tag { font-size: 0.75rem; color: var(--app-muted); letter-spacing: 0.04em; text-transform: uppercase; margin-top: 0.2rem; }
|
||||
.nav-link { color: var(--app-muted); font-weight: 600; }
|
||||
.nav-link:hover,.nav-link:focus { color: var(--app-secondary); }
|
||||
.nav-toggle { border: 1px solid rgba(17, 24, 39, 0.08); border-radius: 14px; }
|
||||
.hero-section,.page-hero-sm,.section-shell { position: relative; }
|
||||
.hero-section { padding: 4.5rem 0 2rem; }
|
||||
.page-hero-sm { padding: 3rem 0 1.25rem; }
|
||||
.section-shell { padding: 1.5rem 0 3rem; }
|
||||
.section-tight { padding-top: 0.5rem; }
|
||||
.eyebrow,.section-kicker { display: inline-block; margin-bottom: 1rem; padding: 0.45rem 0.8rem; border-radius: 999px; color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.hero-title,.section-title { font-size: clamp(2.4rem, 4vw, 4.7rem); line-height: 0.98; letter-spacing: -0.04em; color: var(--app-secondary); margin-bottom: 1rem; }
|
||||
.page-hero-sm .section-title,.h4.section-title { font-size: clamp(2rem, 3vw, 3rem); }
|
||||
.hero-copy,.section-subtitle { max-width: 46rem; font-size: 1.05rem; color: var(--app-muted); line-height: 1.75; }
|
||||
.hero-actions,.form-actions { margin-top: 2rem; }
|
||||
.hero-note,.audit-caption,.table-muted,.field-help,.distance-status { color: var(--app-muted); font-size: 0.92rem; line-height: 1.55; }
|
||||
.hero-note { margin-top: 1.15rem; }
|
||||
.glass-panel,.metric-card,.feature-card,.action-card,.side-card,.trip-stream-item,.empty-state-card,.panel-solid { background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 250, 244, 0.78)); border: 1px solid var(--app-border); box-shadow: var(--app-shadow); border-radius: var(--app-radius-xl); }
|
||||
.glass-panel { padding: clamp(1.25rem, 3vw, 2rem); backdrop-filter: blur(18px); }
|
||||
.showcase-top,.section-heading-wrap,.trip-stream-footer,.distance-action-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.status-pill,.trip-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.8rem; border-radius: 999px; background: rgba(21, 32, 51, 0.08); color: var(--app-secondary); font-size: 0.8rem; font-weight: 700; }
|
||||
.metric-stack,.side-stack,.action-list,.feature-grid,.trip-stream { display: grid; gap: 1rem; }
|
||||
.metric-card,.feature-card,.side-card,.panel-solid { padding: 1.35rem; }
|
||||
.metric-card h2 { font-size: clamp(2rem, 3.8vw, 3rem); line-height: 1; margin: 0.5rem 0; }
|
||||
.metric-card p,.metric-card span,.feature-card p,.side-card p,.action-card span,.trip-stream-item p,.delete-box p { margin: 0; color: var(--app-muted); }
|
||||
.metric-card p,.feature-card h3,.side-card h3,.action-card strong,.empty-state-card h3,.delete-box h3 { color: var(--app-secondary); }
|
||||
.accent-card { background: linear-gradient(135deg, rgba(240, 122, 86, 0.16), rgba(242, 198, 108, 0.16)); }
|
||||
.panel-solid { background: linear-gradient(180deg, #fffaf3, #fffdfa); }
|
||||
.feature-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 1.5rem; }
|
||||
.feature-icon { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 0.82rem; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--app-secondary), #334155); }
|
||||
.action-card { display: block; padding: 1.1rem 1.15rem; transition: transform 0.2s ease, border-color 0.2s ease; }
|
||||
.action-card:hover { transform: translateY(-2px); border-color: rgba(14, 119, 111, 0.3); }
|
||||
.trip-stream-item { padding: 1.2rem; }
|
||||
.trip-stream-meta,.trip-stream-route,.trip-stream-footer { display: flex; gap: 0.85rem; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.trip-stream-route { margin: 0.9rem 0; font-size: 1rem; }
|
||||
.route-arrow { color: var(--app-accent); font-weight: 700; }
|
||||
.empty-state-card { position: relative; text-align: center; padding: clamp(2rem, 5vw, 3rem); }
|
||||
.empty-orb { width: 92px; height: 92px; margin: 0 auto 1rem; border-radius: 28px; background: linear-gradient(135deg, rgba(14, 119, 111, 0.18), rgba(240, 122, 86, 0.2)); }
|
||||
.compact-empty { text-align: left; }
|
||||
.form-label { font-weight: 700; color: var(--app-secondary); margin-bottom: 0.5rem; }
|
||||
.form-control,.form-select { min-height: 3.2rem; border-radius: 16px; border: 1px solid rgba(17, 24, 39, 0.1); background: rgba(255, 255, 255, 0.94); padding: 0.85rem 1rem; color: var(--app-text); }
|
||||
textarea.form-control { min-height: 8.5rem; }
|
||||
.form-control:focus,.form-select:focus,.form-check-input:focus,.btn:focus { border-color: rgba(14, 119, 111, 0.52); box-shadow: 0 0 0 0.22rem rgba(14, 119, 111, 0.15); }
|
||||
.field-error { color: #b42318; font-size: 0.9rem; margin-top: 0.35rem; }
|
||||
.app-check { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.form-check-input { width: 1.15rem; height: 1.15rem; margin-top: 0; }
|
||||
.form-check-input:checked { background-color: var(--app-primary); border-color: var(--app-primary); }
|
||||
.distance-status { padding: 0.85rem 1rem; border-radius: 16px; background: rgba(21, 32, 51, 0.06); }
|
||||
.distance-status.is-success { color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); }
|
||||
.distance-status.is-error { color: #b42318; background: rgba(212, 59, 48, 0.1); }
|
||||
.timestamp-panel,.delete-box { padding: 1.2rem; border-radius: 22px; background: rgba(21, 32, 51, 0.04); border: 1px solid rgba(17, 24, 39, 0.06); }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||
.detail-grid article,.detail-copy p { line-height: 1.65; }
|
||||
.detail-grid span { display: block; color: var(--app-muted); font-size: 0.88rem; margin-bottom: 0.35rem; }
|
||||
.detail-grid strong,.detail-copy h2,.delete-box h3 { color: var(--app-secondary); }
|
||||
.app-btn-primary,.app-btn-secondary { min-height: 3rem; padding: 0.85rem 1.25rem; border-radius: 999px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.app-btn-primary { border: none; color: #fff; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 32px rgba(14, 119, 111, 0.22); }
|
||||
.app-btn-primary:hover,.app-btn-primary:focus { color: #fff; background: linear-gradient(135deg, var(--app-primary-dark), #0e8c84); }
|
||||
.app-btn-secondary { color: var(--app-secondary); border-color: rgba(21, 32, 51, 0.14); background: rgba(255, 255, 255, 0.72); }
|
||||
.app-btn-secondary:hover,.app-btn-secondary:focus { color: var(--app-secondary); border-color: rgba(14, 119, 111, 0.32); background: rgba(255, 255, 255, 0.9); }
|
||||
.app-alert { border-radius: 18px; border: 1px solid rgba(17, 24, 39, 0.06); }
|
||||
.section-link { color: var(--app-secondary); font-weight: 700; }
|
||||
.table-panel { overflow: hidden; }
|
||||
.app-table { --bs-table-bg: transparent; --bs-table-border-color: rgba(17, 24, 39, 0.08); }
|
||||
.app-table thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--app-muted); border-bottom-width: 1px; padding-top: 1.1rem; padding-bottom: 1.1rem; }
|
||||
.app-table tbody td { padding-top: 1rem; padding-bottom: 1rem; color: var(--app-text); }
|
||||
.no-print { display: initial; }
|
||||
@media (max-width: 991.98px) {
|
||||
.hero-section { padding-top: 3rem; }
|
||||
.hero-title,.section-title { max-width: 16ch; }
|
||||
.site-header { padding: 0.85rem 0; }
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.hero-section,.page-hero-sm { padding-top: 2rem; }
|
||||
.glass-panel,.metric-card,.feature-card,.side-card,.trip-stream-item,.panel-solid { border-radius: 22px; }
|
||||
.distance-action-bar,.section-heading-wrap,.trip-stream-footer,.trip-stream-route,.trip-stream-meta { align-items: flex-start; flex-direction: column; }
|
||||
.table-responsive { border-radius: 22px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.site-header,.hero-orb,.hero-grid,.no-print,.app-alert { display: none !important; }
|
||||
.glass-panel,.panel-solid,.metric-card { box-shadow: none; background: #fff; border: 1px solid #d6d6d6; }
|
||||
.section-shell,.page-hero-sm { padding: 0; }
|
||||
.report-table th,.report-table td { font-size: 0.78rem; }
|
||||
}
|
||||
|
||||
81
staticfiles/js/mileage_app.js
Normal file
81
staticfiles/js/mileage_app.js
Normal file
@ -0,0 +1,81 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.querySelector('#trip-form');
|
||||
if (!form) return;
|
||||
|
||||
const startInput = form.querySelector('#id_start_location');
|
||||
const endInput = form.querySelector('#id_end_location');
|
||||
const distanceInput = form.querySelector('#id_distance_miles');
|
||||
const startOdometerInput = form.querySelector('#id_start_odometer');
|
||||
const endOdometerInput = form.querySelector('#id_end_odometer');
|
||||
const useMapCheckbox = form.querySelector('#id_update_end_odometer_from_map');
|
||||
const statusBox = document.querySelector('#distance-status');
|
||||
const button = document.querySelector('#calculate-distance-btn');
|
||||
const endpoint = form.dataset.distanceEndpoint;
|
||||
const csrfToken = form.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
||||
|
||||
const setStatus = (message, mode = '') => {
|
||||
statusBox.textContent = message;
|
||||
statusBox.classList.remove('is-success', 'is-error');
|
||||
if (mode) statusBox.classList.add(mode);
|
||||
};
|
||||
|
||||
const maybeUpdateEndOdometer = () => {
|
||||
if (!useMapCheckbox.checked) return;
|
||||
const startOdometer = parseFloat(startOdometerInput.value || '');
|
||||
const distanceMiles = parseFloat(distanceInput.value || '');
|
||||
if (!Number.isNaN(startOdometer) && !Number.isNaN(distanceMiles)) {
|
||||
endOdometerInput.value = (startOdometer + distanceMiles).toFixed(1);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDistance = async () => {
|
||||
const startLocation = startInput.value.trim();
|
||||
const endLocation = endInput.value.trim();
|
||||
if (!startLocation || !endLocation) {
|
||||
setStatus('Enter both locations to calculate route mileage.');
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
setStatus('Calculating driving miles from Google Maps…');
|
||||
|
||||
const body = new URLSearchParams({
|
||||
start_location: startLocation,
|
||||
end_location: endLocation,
|
||||
csrfmiddlewaretoken: csrfToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
setStatus(payload.message || 'Mileage could not be calculated. Please refine the addresses.', 'is-error');
|
||||
return;
|
||||
}
|
||||
distanceInput.value = Number(payload.miles).toFixed(1);
|
||||
maybeUpdateEndOdometer();
|
||||
setStatus(`${payload.message} You can still override the miles before saving.`, 'is-success');
|
||||
} catch (error) {
|
||||
setStatus('Mileage could not be calculated right now. Please try again or enter miles manually.', 'is-error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', calculateDistance);
|
||||
[startInput, endInput].forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
if (startInput.value.trim() && endInput.value.trim()) calculateDistance();
|
||||
});
|
||||
});
|
||||
useMapCheckbox.addEventListener('change', maybeUpdateEndOdometer);
|
||||
startOdometerInput.addEventListener('input', maybeUpdateEndOdometer);
|
||||
distanceInput.addEventListener('input', maybeUpdateEndOdometer);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user