feat(ui): Massive frontend modernization including customer table redesign, new map features, and consistent styling

This commit is contained in:
2026-02-06 20:31:16 -05:00
parent 421ba896a0
commit 6c28c0c2d2
68 changed files with 7472 additions and 1253 deletions

View File

@@ -0,0 +1,282 @@
<template>
<div class="bg-neutral rounded-lg p-4 sm:p-6">
<!-- Controls -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-6">
<div class="flex flex-col gap-4">
<div>
<label class="label">
<span class="label-text font-semibold">Time Range</span>
</label>
<TimeRangeSelector v-model="timeRange" />
</div>
<div>
<label class="label">
<span class="label-text font-semibold">Compare Years</span>
</label>
<YearSelector v-model="selectedYears" />
</div>
</div>
<div class="flex flex-col gap-4 items-end">
<div class="flex gap-2">
<div class="form-control">
<label class="label">
<span class="label-text">Start Date</span>
</label>
<input
type="date"
v-model="startDate"
class="input input-bordered input-sm"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">End Date</span>
</label>
<input
type="date"
v-model="endDate"
class="input input-bordered input-sm"
/>
</div>
</div>
<ExportButtons :data="exportData" :filename="'gallons-' + timeRange" />
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center h-[400px]">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
<button class="btn btn-sm" @click="fetchData">Retry</button>
</div>
<!-- Chart -->
<DeliveryChart
v-else
:datasets="chartDatasets"
:title="chartTitle"
:use-time-scale="timeRange !== 'month'"
:x-axis-label="xAxisLabel"
/>
<!-- Summary Stats -->
<div v-if="!loading && !error" class="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
<div v-for="(yearData, index) in summaryStats" :key="yearData.year" class="stat bg-base-100 rounded-lg p-3">
<div class="stat-title text-sm">{{ yearData.year }} Total</div>
<div class="stat-value text-lg" :style="{ color: getYearColor(index) }">
{{ yearData.totalGallons.toLocaleString() }}
</div>
<div class="stat-desc">{{ yearData.totalDeliveries }} deliveries</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import dayjs from 'dayjs';
import TimeRangeSelector from './components/TimeRangeSelector.vue';
import YearSelector from './components/YearSelector.vue';
import DeliveryChart from './components/DeliveryChart.vue';
import ExportButtons from './components/ExportButtons.vue';
import { statsService } from '../../services/statsService';
import type { TimeRange, DailyGallonsYearData, WeeklyGallonsYearData, MonthlyGallonsYearData } from '../../types/stats';
const currentYear = new Date().getFullYear();
// State
const timeRange = ref<TimeRange>('day');
const selectedYears = ref<number[]>([currentYear, currentYear - 1]);
const startDate = ref(dayjs().subtract(30, 'day').format('YYYY-MM-DD'));
const endDate = ref(dayjs().format('YYYY-MM-DD'));
const loading = ref(false);
const error = ref<string | null>(null);
// Data storage
const dailyData = ref<DailyGallonsYearData[]>([]);
const weeklyData = ref<WeeklyGallonsYearData[]>([]);
const monthlyData = ref<MonthlyGallonsYearData[]>([]);
// Colors for year lines
const yearColors = [
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.1)' }, // Blue
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.1)' }, // Green
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.1)' }, // Orange
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.1)' }, // Purple
];
function getYearColor(index: number): string {
return yearColors[index % yearColors.length].border;
}
// Computed
const chartTitle = computed(() => {
const rangeLabels = { day: 'Daily', week: 'Weekly', month: 'Monthly' };
return `${rangeLabels[timeRange.value]} Gallons Delivered`;
});
const xAxisLabel = computed(() => {
const labels = { day: 'Date', week: 'Week', month: 'Month' };
return labels[timeRange.value];
});
const chartDatasets = computed(() => {
if (timeRange.value === 'day') {
return dailyData.value.map((yearData, index) => ({
label: `${yearData.year}`,
data: yearData.data.map(d => ({ x: d.date, y: d.gallons })),
borderColor: yearColors[index % yearColors.length].border,
backgroundColor: yearColors[index % yearColors.length].background
}));
} else if (timeRange.value === 'week') {
return weeklyData.value.map((yearData, index) => ({
label: `${yearData.year}`,
data: yearData.data.map(d => ({ x: d.week_start, y: d.gallons })),
borderColor: yearColors[index % yearColors.length].border,
backgroundColor: yearColors[index % yearColors.length].background
}));
} else {
// Monthly - use month names as categories
return monthlyData.value.map((yearData, index) => ({
label: `${yearData.year}`,
data: yearData.data.map(d => ({ x: d.month_name, y: d.gallons })),
borderColor: yearColors[index % yearColors.length].border,
backgroundColor: yearColors[index % yearColors.length].background
}));
}
});
const summaryStats = computed(() => {
let data: { year: number; totalGallons: number; totalDeliveries: number }[] = [];
if (timeRange.value === 'day') {
data = dailyData.value.map(yearData => ({
year: yearData.year,
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
}));
} else if (timeRange.value === 'week') {
data = weeklyData.value.map(yearData => ({
year: yearData.year,
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
}));
} else {
data = monthlyData.value.map(yearData => ({
year: yearData.year,
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
}));
}
return data;
});
const exportData = computed(() => {
const rows: Record<string, unknown>[] = [];
if (timeRange.value === 'day') {
dailyData.value.forEach(yearData => {
yearData.data.forEach(d => {
rows.push({
year: yearData.year,
date: d.date,
gallons: d.gallons,
deliveries: d.deliveries
});
});
});
} else if (timeRange.value === 'week') {
weeklyData.value.forEach(yearData => {
yearData.data.forEach(d => {
rows.push({
year: yearData.year,
week_start: d.week_start,
week_end: d.week_end,
week_number: d.week_number,
gallons: d.gallons,
deliveries: d.deliveries
});
});
});
} else {
monthlyData.value.forEach(yearData => {
yearData.data.forEach(d => {
rows.push({
year: yearData.year,
month: d.month,
month_name: d.month_name,
gallons: d.gallons,
deliveries: d.deliveries
});
});
});
}
return rows;
});
// Methods
async function fetchData() {
loading.value = true;
error.value = null;
try {
if (timeRange.value === 'day') {
const response = await statsService.getDailyGallons({
start_date: startDate.value,
end_date: endDate.value,
years: selectedYears.value
});
if (response.data.ok !== false) {
dailyData.value = response.data.years || [];
} else {
error.value = 'Failed to load daily data';
}
} else if (timeRange.value === 'week') {
const response = await statsService.getWeeklyGallons({
start_date: startDate.value,
end_date: endDate.value,
years: selectedYears.value
});
if (response.data.ok !== false) {
weeklyData.value = response.data.years || [];
} else {
error.value = 'Failed to load weekly data';
}
} else {
const response = await statsService.getMonthlyGallons({
year: selectedYears.value[0],
compare_years: selectedYears.value.slice(1)
});
if (response.data.ok !== false) {
monthlyData.value = response.data.years || [];
} else {
error.value = 'Failed to load monthly data';
}
}
} catch (err) {
console.error('Error fetching stats data:', err);
error.value = 'An error occurred while fetching data';
} finally {
loading.value = false;
}
}
// Watchers
watch([timeRange, selectedYears, startDate, endDate], () => {
fetchData();
}, { deep: true });
// Lifecycle
onMounted(() => {
fetchData();
});
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex">
<div class="w-full px-4 md:px-10 py-4">
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs">
<ul>
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li>Stats</li>
</ul>
</div>
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mt-4 mb-6">
<h1 class="text-2xl font-bold">Delivery Statistics</h1>
</div>
<!-- Tab Navigation -->
<div role="tablist" class="tabs tabs-boxed bg-neutral mb-6">
<router-link
:to="{ name: 'statsDailyDeliveries' }"
role="tab"
class="tab"
:class="{ 'tab-active': $route.name === 'statsDailyDeliveries' }"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
Daily Deliveries
</router-link>
<router-link
:to="{ name: 'statsTotals' }"
role="tab"
class="tab"
:class="{ 'tab-active': $route.name === 'statsTotals' }"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
</svg>
Totals Comparison
</router-link>
</div>
<!-- Child Route Content -->
<router-view />
</div>
</div>
</template>
<script setup lang="ts">
// Layout component - no logic needed
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div class="bg-neutral rounded-lg p-4 sm:p-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div>
<h2 class="text-xl font-bold">Period Totals</h2>
<p class="text-sm text-base-content/70">
Comparing {{ currentYear }} vs {{ compareYear }}
</p>
</div>
<ExportButtons :data="exportData" filename="totals-comparison" />
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center h-64">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
<button class="btn btn-sm" @click="fetchData">Retry</button>
</div>
<!-- Comparison Cards -->
<div v-else class="space-y-8">
<!-- Today -->
<div>
<h3 class="text-lg font-semibold mb-3">Today</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ComparisonCard
v-if="comparisonData?.today"
title="Gallons Delivered"
:current-value="comparisonData.today.gallons.current"
:previous-value="comparisonData.today.gallons.previous"
:change-percent="comparisonData.today.gallons.change_percent"
:change-direction="comparisonData.today.gallons.change_direction"
:compare-year="compareYear"
format="gallons"
/>
<ComparisonCard
v-if="comparisonData?.today"
title="Deliveries"
:current-value="comparisonData.today.deliveries.current"
:previous-value="comparisonData.today.deliveries.previous"
:change-percent="comparisonData.today.deliveries.change_percent"
:change-direction="comparisonData.today.deliveries.change_direction"
:compare-year="compareYear"
/>
</div>
</div>
<!-- Week to Date -->
<div>
<h3 class="text-lg font-semibold mb-3">Week to Date</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ComparisonCard
v-if="comparisonData?.week_to_date"
title="Gallons Delivered"
:current-value="comparisonData.week_to_date.gallons.current"
:previous-value="comparisonData.week_to_date.gallons.previous"
:change-percent="comparisonData.week_to_date.gallons.change_percent"
:change-direction="comparisonData.week_to_date.gallons.change_direction"
:compare-year="compareYear"
format="gallons"
/>
<ComparisonCard
v-if="comparisonData?.week_to_date"
title="Deliveries"
:current-value="comparisonData.week_to_date.deliveries.current"
:previous-value="comparisonData.week_to_date.deliveries.previous"
:change-percent="comparisonData.week_to_date.deliveries.change_percent"
:change-direction="comparisonData.week_to_date.deliveries.change_direction"
:compare-year="compareYear"
/>
</div>
</div>
<!-- Month to Date -->
<div>
<h3 class="text-lg font-semibold mb-3">Month to Date</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ComparisonCard
v-if="comparisonData?.month_to_date"
title="Gallons Delivered"
:current-value="comparisonData.month_to_date.gallons.current"
:previous-value="comparisonData.month_to_date.gallons.previous"
:change-percent="comparisonData.month_to_date.gallons.change_percent"
:change-direction="comparisonData.month_to_date.gallons.change_direction"
:compare-year="compareYear"
format="gallons"
/>
<ComparisonCard
v-if="comparisonData?.month_to_date"
title="Deliveries"
:current-value="comparisonData.month_to_date.deliveries.current"
:previous-value="comparisonData.month_to_date.deliveries.previous"
:change-percent="comparisonData.month_to_date.deliveries.change_percent"
:change-direction="comparisonData.month_to_date.deliveries.change_direction"
:compare-year="compareYear"
/>
</div>
</div>
<!-- Year to Date -->
<div>
<h3 class="text-lg font-semibold mb-3">Year to Date</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ComparisonCard
v-if="comparisonData?.year_to_date"
title="Gallons Delivered"
:current-value="comparisonData.year_to_date.gallons.current"
:previous-value="comparisonData.year_to_date.gallons.previous"
:change-percent="comparisonData.year_to_date.gallons.change_percent"
:change-direction="comparisonData.year_to_date.gallons.change_direction"
:compare-year="compareYear"
format="gallons"
/>
<ComparisonCard
v-if="comparisonData?.year_to_date"
title="Deliveries"
:current-value="comparisonData.year_to_date.deliveries.current"
:previous-value="comparisonData.year_to_date.deliveries.previous"
:change-percent="comparisonData.year_to_date.deliveries.change_percent"
:change-direction="comparisonData.year_to_date.deliveries.change_direction"
:compare-year="compareYear"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import ComparisonCard from './components/ComparisonCard.vue';
import ExportButtons from './components/ExportButtons.vue';
import { statsService } from '../../services/statsService';
import type { TotalsComparisonData } from '../../types/stats';
// State
const loading = ref(false);
const error = ref<string | null>(null);
const comparisonData = ref<TotalsComparisonData | null>(null);
const currentYear = ref(new Date().getFullYear());
const compareYear = ref(new Date().getFullYear() - 1);
// Export data for CSV
const exportData = computed(() => {
if (!comparisonData.value) return [];
const periods = ['today', 'week_to_date', 'month_to_date', 'year_to_date'] as const;
const metrics = ['gallons', 'deliveries'] as const;
const rows: Record<string, unknown>[] = [];
periods.forEach(period => {
const periodData = comparisonData.value?.[period];
if (periodData) {
metrics.forEach(metric => {
const metricData = periodData[metric];
rows.push({
period: period.replace(/_/g, ' '),
metric,
current_year: currentYear.value,
current_value: metricData.current,
previous_year: compareYear.value,
previous_value: metricData.previous,
change_percent: metricData.change_percent,
change_direction: metricData.change_direction
});
});
}
});
return rows;
});
// Methods
async function fetchData() {
loading.value = true;
error.value = null;
try {
const response = await statsService.getTotalsComparison();
if (response.data.ok !== false) {
comparisonData.value = response.data.comparison;
currentYear.value = response.data.current_year;
compareYear.value = response.data.compare_year;
} else {
error.value = 'Failed to load comparison data';
}
} catch (err) {
console.error('Error fetching comparison data:', err);
error.value = 'An error occurred while fetching data';
} finally {
loading.value = false;
}
}
// Lifecycle
onMounted(() => {
fetchData();
});
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-figure" :class="iconColorClass">
<svg v-if="changeDirection === 'up'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
</svg>
<svg v-else-if="changeDirection === 'down'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6L9 12.75l4.286-4.286a11.948 11.948 0 014.306 6.43l.776 2.898m0 0l3.182-5.511m-3.182 5.51l-5.511-3.181" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
</svg>
</div>
<div class="stat-title">{{ title }}</div>
<div class="stat-value text-2xl">{{ formattedValue }}</div>
<div class="stat-desc" :class="changeColorClass">
<span v-if="changeDirection === 'up'">+</span>
<span v-else-if="changeDirection === 'down'">-</span>
{{ Math.abs(changePercent).toFixed(1) }}% vs {{ compareYear }}
<span class="text-base-content/60 ml-1">({{ formattedPrevious }})</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title: string;
currentValue: number;
previousValue: number;
changePercent: number;
changeDirection: 'up' | 'down' | 'neutral';
compareYear: number;
format?: 'number' | 'currency' | 'gallons';
}
const props = withDefaults(defineProps<Props>(), {
format: 'number'
});
const formattedValue = computed(() => {
if (props.format === 'currency') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(props.currentValue);
}
if (props.format === 'gallons') {
return props.currentValue.toLocaleString() + ' gal';
}
return props.currentValue.toLocaleString();
});
const formattedPrevious = computed(() => {
if (props.format === 'currency') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(props.previousValue);
}
if (props.format === 'gallons') {
return props.previousValue.toLocaleString() + ' gal';
}
return props.previousValue.toLocaleString();
});
const iconColorClass = computed(() => {
if (props.changeDirection === 'up') return 'text-success';
if (props.changeDirection === 'down') return 'text-error';
return 'text-base-content/50';
});
const changeColorClass = computed(() => {
if (props.changeDirection === 'up') return 'text-success';
if (props.changeDirection === 'down') return 'text-error';
return 'text-base-content/50';
});
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div class="w-full h-[400px] relative">
<Line
v-if="chartData"
:data="chartData as any"
:options="chartOptions as any"
/>
<div v-else class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
TimeScale
} from 'chart.js';
import { Line } from 'vue-chartjs';
import 'chartjs-adapter-dayjs-4';
import zoomPlugin from 'chartjs-plugin-zoom';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
TimeScale,
zoomPlugin
);
interface DataPoint {
x: string | Date;
y: number;
}
interface Dataset {
label: string;
data: DataPoint[];
borderColor: string;
backgroundColor: string;
}
interface Props {
datasets: Dataset[];
title?: string;
xAxisLabel?: string;
yAxisLabel?: string;
useTimeScale?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '',
xAxisLabel: 'Date',
yAxisLabel: 'Gallons',
useTimeScale: true
});
// Color palette for different years
const colors = [
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.1)' }, // Blue
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.1)' }, // Green
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.1)' }, // Orange
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.1)' }, // Purple
];
const chartData = computed(() => {
if (!props.datasets || props.datasets.length === 0) {
return null;
}
return {
datasets: props.datasets.map((ds, index) => ({
label: ds.label,
data: ds.data,
borderColor: ds.borderColor || colors[index % colors.length].border,
backgroundColor: ds.backgroundColor || colors[index % colors.length].background,
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 6,
spanGaps: true
}))
};
});
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
title: {
display: !!props.title,
text: props.title,
font: { size: 16 }
},
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: (context: { parsed: { y: number | null }; dataset: { label?: string } }) => {
const value = context.parsed?.y ?? 0;
return `${context.dataset.label || ''}: ${value.toLocaleString()} gallons`;
}
}
},
zoom: {
pan: {
enabled: true,
mode: 'x',
},
zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true,
},
mode: 'x',
}
}
},
scales: {
x: props.useTimeScale ? {
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM D',
week: 'MMM D',
month: 'MMM YYYY'
}
},
title: {
display: true,
text: props.xAxisLabel
}
} : {
title: {
display: true,
text: props.xAxisLabel
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: props.yAxisLabel
},
ticks: {
callback: (value: string | number) => {
if (typeof value === 'number') {
return value.toLocaleString();
}
return value;
}
}
}
}
}));
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="flex gap-2">
<button
class="btn btn-sm btn-outline"
@click="exportToCSV"
:disabled="!data || data.length === 0"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Export CSV
</button>
<button
class="btn btn-sm btn-outline"
@click="copyToClipboard"
:disabled="!data || data.length === 0"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
Copy
</button>
</div>
</template>
<script setup lang="ts">
import { useNotification } from "@kyvg/vue3-notification";
interface Props {
data: Record<string, unknown>[];
filename?: string;
columns?: string[];
}
const props = withDefaults(defineProps<Props>(), {
filename: 'stats-export',
columns: () => []
});
const { notify } = useNotification();
function getHeaders(): string[] {
if (props.columns.length > 0) {
return props.columns;
}
if (props.data.length > 0) {
return Object.keys(props.data[0]);
}
return [];
}
function convertToCSV(): string {
const headers = getHeaders();
const headerRow = headers.join(',');
const rows = props.data.map(row => {
return headers.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value ?? '';
}).join(',');
});
return [headerRow, ...rows].join('\n');
}
function exportToCSV() {
const csv = convertToCSV();
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${props.filename}-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
notify({
title: 'Export Complete',
text: 'CSV file downloaded successfully',
type: 'success'
});
}
async function copyToClipboard() {
const csv = convertToCSV();
try {
await navigator.clipboard.writeText(csv);
notify({
title: 'Copied',
text: 'Data copied to clipboard',
type: 'success'
});
} catch {
notify({
title: 'Error',
text: 'Failed to copy to clipboard',
type: 'error'
});
}
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="btn-group">
<button
v-for="option in options"
:key="option.value"
class="btn btn-sm"
:class="{ 'btn-active': modelValue === option.value }"
@click="$emit('update:modelValue', option.value)"
>
{{ option.label }}
</button>
</div>
</template>
<script setup lang="ts">
import type { TimeRange } from '../../../types/stats';
interface Props {
modelValue: TimeRange;
}
defineProps<Props>();
defineEmits<{
(e: 'update:modelValue', value: TimeRange): void;
}>();
const options = [
{ value: 'day' as TimeRange, label: 'Daily' },
{ value: 'week' as TimeRange, label: 'Weekly' },
{ value: 'month' as TimeRange, label: 'Monthly' }
];
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="flex flex-wrap gap-2">
<label
v-for="year in availableYears"
:key="year"
class="label cursor-pointer gap-2 p-0"
>
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
:checked="modelValue.includes(year)"
@change="toggleYear(year)"
/>
<span class="label-text">{{ year }}</span>
</label>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
modelValue: number[];
yearsBack?: number;
}
const props = withDefaults(defineProps<Props>(), {
yearsBack: 3
});
const emit = defineEmits<{
(e: 'update:modelValue', value: number[]): void;
}>();
const currentYear = new Date().getFullYear();
const availableYears = computed(() => {
const years: number[] = [];
for (let i = 0; i <= props.yearsBack; i++) {
years.push(currentYear - i);
}
return years;
});
function toggleYear(year: number) {
const newValue = [...props.modelValue];
const index = newValue.indexOf(year);
if (index === -1) {
newValue.push(year);
} else if (newValue.length > 1) {
// Don't allow deselecting the last year
newValue.splice(index, 1);
}
// Sort descending
newValue.sort((a, b) => b - a);
emit('update:modelValue', newValue);
}
</script>

28
src/pages/stats/routes.ts Normal file
View File

@@ -0,0 +1,28 @@
const StatsLayout = () => import('./StatsLayout.vue');
const DailyDeliveriesGraph = () => import('./DailyDeliveriesGraph.vue');
const TotalsComparison = () => import('./TotalsComparison.vue');
const statsRoutes = [
{
path: '/stats',
component: StatsLayout,
children: [
{
path: '',
redirect: { name: 'statsDailyDeliveries' }
},
{
path: 'daily',
name: 'statsDailyDeliveries',
component: DailyDeliveriesGraph
},
{
path: 'totals',
name: 'statsTotals',
component: TotalsComparison
}
]
}
];
export default statsRoutes;