479 lines
15 KiB
Plaintext
479 lines
15 KiB
Plaintext
package pages
|
|
|
|
import (
|
|
"fmt"
|
|
"tankstopp/internal/models"
|
|
"tankstopp/internal/views/components"
|
|
)
|
|
|
|
templ DashboardPage(user *models.User, username string, stops []models.FuelStop, vehicles []models.Vehicle, totalStops int, totalCost float64, avgConsumption float64, lastFillUp *models.FuelStop) {
|
|
@components.BaseLayout("Dashboard", user, username) {
|
|
@components.PageHeader("Overview", "Dashboard")
|
|
<div class="page-body">
|
|
<div class="container-xl">
|
|
<!-- Statistics Cards -->
|
|
<div class="row row-deck row-cards mb-4">
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.Card("Total Stops", "gas-station") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Fuel stops recorded</div>
|
|
<div class="ms-auto lh-1">
|
|
<div class="dropdown">
|
|
<a class="dropdown-toggle text-muted" href="#" data-bs-toggle="dropdown">Last 30 days</a>
|
|
<div class="dropdown-menu dropdown-menu-end">
|
|
<a class="dropdown-item active" href="#">Last 30 days</a>
|
|
<a class="dropdown-item" href="#">Last 90 days</a>
|
|
<a class="dropdown-item" href="#">All time</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="h1 mb-3">{ fmt.Sprintf("%d", totalStops) }</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar bg-primary" style="width: 75%" role="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">75%</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.Card("Total Spent", "currency") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Total fuel costs</div>
|
|
</div>
|
|
<div class="h1 mb-3">{ fmt.Sprintf("%.2f %s", totalCost, user.BaseCurrency) }</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar bg-success" style="width: 60%" role="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">60%</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.Card("Avg Consumption", "gauge") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Liters per 100km</div>
|
|
</div>
|
|
<div class="h1 mb-3">
|
|
if avgConsumption > 0 {
|
|
{ fmt.Sprintf("%.1f L/100km", avgConsumption) }
|
|
} else {
|
|
{ "N/A" }
|
|
}
|
|
</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar bg-warning" style="width: 45%" role="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">Efficiency</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.Card("Vehicles", "car") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Active vehicles</div>
|
|
</div>
|
|
<div class="h1 mb-3">{ fmt.Sprintf("%d", len(vehicles)) }</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar bg-info" style="width: 100%" role="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">100%</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
<!-- Trip Length and Efficiency Statistics -->
|
|
<div class="row row-deck row-cards mb-4">
|
|
<div class="col-sm-6 col-lg-4">
|
|
@components.Card("Total Distance", "trip") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Kilometers driven</div>
|
|
</div>
|
|
<div class="h1 mb-3">
|
|
{ fmt.Sprintf("%.0f km", calculateTotalDistance(stops)) }
|
|
</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar bg-info" style="width: 80%" role="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">Tracked</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-4">
|
|
@components.Card("Efficiency Trend", "chart-bar") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Recent performance</div>
|
|
</div>
|
|
<div class="h1 mb-3">
|
|
if len(stops) >= 3 {
|
|
if calculateEfficiencyTrend(stops) == "improving" {
|
|
<span class="text-success">Improving</span>
|
|
} else if calculateEfficiencyTrend(stops) == "worsening" {
|
|
<span class="text-danger">Declining</span>
|
|
} else {
|
|
<span class="text-info">Stable</span>
|
|
}
|
|
} else {
|
|
<span class="text-muted">N/A</span>
|
|
}
|
|
</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
if calculateEfficiencyTrend(stops) == "improving" {
|
|
<div class="progress-bar bg-success" style="width: 75%" role="progressbar"></div>
|
|
} else if calculateEfficiencyTrend(stops) == "worsening" {
|
|
<div class="progress-bar bg-danger" style="width: 30%" role="progressbar"></div>
|
|
} else {
|
|
<div class="progress-bar bg-info" style="width: 50%" role="progressbar"></div>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">Trend</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-4">
|
|
@components.Card("Best Efficiency", "award") {
|
|
<div class="d-flex align-items-center">
|
|
<div class="subheader">Lowest consumption</div>
|
|
</div>
|
|
<div class="h1 mb-3">
|
|
{ fmt.Sprintf("%.1f L/100km", getBestEfficiency(stops)) }
|
|
</div>
|
|
<div class="d-flex mb-2">
|
|
<div class="flex-fill">
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar bg-success" style="width: 90%" role="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted ms-2">Personal best</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
<!-- Last Fill-up Info -->
|
|
if lastFillUp != nil {
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title">
|
|
@components.Icon("clock", 24)
|
|
Last Fill-up
|
|
</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="mb-3">
|
|
<div class="text-muted">Date</div>
|
|
<div class="h4">{ lastFillUp.Date.Format("Jan 2, 2006") }</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="mb-3">
|
|
<div class="text-muted">Amount</div>
|
|
<div class="h4">{ fmt.Sprintf("%.2f L", lastFillUp.Liters) }</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="mb-3">
|
|
<div class="text-muted">Cost</div>
|
|
<div class="h4">{ fmt.Sprintf("%.2f %s", lastFillUp.TotalPrice, user.BaseCurrency) }</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="mb-3">
|
|
<div class="text-muted">Location</div>
|
|
<div class="h4">{ lastFillUp.Location }</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
<!-- Filters and Search -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title">Filters</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.FormGroup("Vehicle", "") {
|
|
@components.VehicleSelect("vehicle_id", 0, vehicles, false)
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.FormGroup("Fuel Type", "") {
|
|
@components.FuelTypeSelect("fuel_type", "", false)
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.FormGroup("Date From", "") {
|
|
@components.DateInput("date_from", "", false)
|
|
}
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
@components.FormGroup("Date To", "") {
|
|
@components.DateInput("date_to", "", false)
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-12">
|
|
@components.ButtonGroup() {
|
|
<button type="button" class="btn btn-primary" onclick="applyFilters()">
|
|
@components.Icon("search", 24)
|
|
Apply Filters
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" onclick="clearFilters()">
|
|
@components.Icon("refresh", 24)
|
|
Clear
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Fuel Stops Table -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
if len(stops) > 0 {
|
|
@FuelStopsTable(stops, user.BaseCurrency)
|
|
} else {
|
|
@components.EmptyState("gas-station", "No fuel stops found", "Start tracking your fuel expenses by adding your first fuel stop.", "Add your first stop", "/add")
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@DashboardScript()
|
|
}
|
|
}
|
|
|
|
templ FuelStopsTable(stops []models.FuelStop, currency string) {
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title">Recent Fuel Stops</h3>
|
|
<div class="card-actions">
|
|
<a href="/add" class="btn btn-primary">
|
|
@components.Icon("plus", 24)
|
|
Add Stop
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-vcenter table-mobile-md card-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Vehicle</th>
|
|
<th>Location</th>
|
|
<th>Fuel Type</th>
|
|
<th>Amount</th>
|
|
<th>Price/L</th>
|
|
<th>Total</th>
|
|
<th>Trip Length</th>
|
|
<th>Consumption</th>
|
|
<th>Odometer</th>
|
|
<th class="w-1">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, stop := range stops {
|
|
<tr>
|
|
<td data-label="Date">
|
|
<div class="d-flex py-1 align-items-center">
|
|
<div class="flex-fill">
|
|
<div class="font-weight-medium">{ stop.Date.Format("Jan 2, 2006") }</div>
|
|
<div class="text-muted">{ stop.Date.Format("15:04") }</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td data-label="Vehicle">
|
|
<div class="d-flex py-1 align-items-center">
|
|
<div class="flex-fill">
|
|
<div class="font-weight-medium">{ stop.Vehicle.Name }</div>
|
|
if stop.Vehicle.LicensePlate != "" {
|
|
<div class="text-muted">{ stop.Vehicle.LicensePlate }</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td data-label="Location">
|
|
<div class="d-flex py-1 align-items-center">
|
|
@components.Icon("location", 24)
|
|
<div class="ms-2">{ stop.Location }</div>
|
|
</div>
|
|
</td>
|
|
<td data-label="Fuel Type">
|
|
<span class="badge bg-secondary">{ stop.FuelType }</span>
|
|
</td>
|
|
<td data-label="Amount">
|
|
<div class="font-weight-medium">{ fmt.Sprintf("%.2f L", stop.Liters) }</div>
|
|
</td>
|
|
<td data-label="Price/L">
|
|
<div class="font-weight-medium">{ fmt.Sprintf("%.3f %s", stop.PricePerL, currency) }</div>
|
|
</td>
|
|
<td data-label="Total">
|
|
<div class="font-weight-medium text-success">{ fmt.Sprintf("%.2f %s", stop.TotalPrice, currency) }</div>
|
|
</td>
|
|
<td data-label="Trip Length">
|
|
if stop.TripLength > 0 {
|
|
<div class="font-weight-medium">{ fmt.Sprintf("%.1f km", stop.TripLength) }</div>
|
|
} else {
|
|
<div class="text-muted">Not recorded</div>
|
|
}
|
|
</td>
|
|
<td data-label="Consumption">
|
|
if stop.TripLength > 0 {
|
|
<div class="font-weight-medium">
|
|
{ fmt.Sprintf("%.1f L/100km", (stop.Liters/stop.TripLength)*100) }
|
|
</div>
|
|
} else {
|
|
<div class="text-muted">N/A</div>
|
|
}
|
|
</td>
|
|
<td data-label="Odometer">
|
|
if stop.Odometer > 0 {
|
|
<div class="font-weight-medium">{ fmt.Sprintf("%d km", stop.Odometer) }</div>
|
|
} else {
|
|
<div class="text-muted">Not recorded</div>
|
|
}
|
|
</td>
|
|
<td>
|
|
@components.ButtonGroup() {
|
|
@components.EditButton(fmt.Sprintf("/edit/%d", stop.ID))
|
|
@components.DeleteButton(fmt.Sprintf("/delete/%d", stop.ID), fmt.Sprintf("fuel stop from %s", stop.Date.Format("Jan 2, 2006")))
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// Helper functions for statistics calculations
|
|
func calculateTotalDistance(stops []models.FuelStop) float64 {
|
|
var total float64
|
|
for _, stop := range stops {
|
|
if stop.TripLength > 0 {
|
|
total += stop.TripLength
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
func calculateEfficiencyTrend(stops []models.FuelStop) string {
|
|
if len(stops) < 3 {
|
|
return "insufficient_data"
|
|
}
|
|
|
|
// Calculate average consumption for recent vs older stops
|
|
recent := stops[:len(stops)/2]
|
|
older := stops[len(stops)/2:]
|
|
|
|
recentAvg := calculateAverageConsumption(recent)
|
|
olderAvg := calculateAverageConsumption(older)
|
|
|
|
if recentAvg == 0 || olderAvg == 0 {
|
|
return "insufficient_data"
|
|
}
|
|
|
|
diff := recentAvg - olderAvg
|
|
if diff < -0.5 {
|
|
return "improving"
|
|
} else if diff > 0.5 {
|
|
return "worsening"
|
|
}
|
|
return "stable"
|
|
}
|
|
|
|
func calculateAverageConsumption(stops []models.FuelStop) float64 {
|
|
var totalConsumption float64
|
|
var count int
|
|
|
|
for _, stop := range stops {
|
|
if stop.TripLength > 0 {
|
|
consumption := (stop.Liters / stop.TripLength) * 100
|
|
if consumption > 0 && consumption < 50 {
|
|
totalConsumption += consumption
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
if count == 0 {
|
|
return 0
|
|
}
|
|
return totalConsumption / float64(count)
|
|
}
|
|
|
|
func getBestEfficiency(stops []models.FuelStop) float64 {
|
|
bestEfficiency := float64(999) // Start with high value
|
|
|
|
for _, stop := range stops {
|
|
if stop.TripLength > 0 {
|
|
consumption := (stop.Liters / stop.TripLength) * 100
|
|
if consumption > 0 && consumption < bestEfficiency && consumption < 50 {
|
|
bestEfficiency = consumption
|
|
}
|
|
}
|
|
}
|
|
|
|
if bestEfficiency == 999 {
|
|
return 0
|
|
}
|
|
return bestEfficiency
|
|
}
|
|
|
|
script DashboardScript() {
|
|
function applyFilters() {
|
|
const vehicleId = document.querySelector('select[name="vehicle_id"]').value;
|
|
const fuelType = document.querySelector('select[name="fuel_type"]').value;
|
|
const dateFrom = document.querySelector('input[name="date_from"]').value;
|
|
const dateTo = document.querySelector('input[name="date_to"]').value;
|
|
|
|
const params = new URLSearchParams();
|
|
if (vehicleId) params.append('vehicle_id', vehicleId);
|
|
if (fuelType) params.append('fuel_type', fuelType);
|
|
if (dateFrom) params.append('date_from', dateFrom);
|
|
if (dateTo) params.append('date_to', dateTo);
|
|
|
|
window.location.href = '/dashboard?' + params.toString();
|
|
}
|
|
|
|
function clearFilters() {
|
|
window.location.href = '/dashboard';
|
|
}
|
|
|
|
function confirmDelete(itemName) {
|
|
return confirm(`Are you sure you want to delete this ${itemName}? This action cannot be undone.`);
|
|
}
|
|
}
|