feat(ui): Massive frontend modernization including customer table redesign, new map features, and consistent styling
This commit is contained in:
282
src/pages/stats/DailyDeliveriesGraph.vue
Normal file
282
src/pages/stats/DailyDeliveriesGraph.vue
Normal 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>
|
||||
51
src/pages/stats/StatsLayout.vue
Normal file
51
src/pages/stats/StatsLayout.vue
Normal 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>
|
||||
207
src/pages/stats/TotalsComparison.vue
Normal file
207
src/pages/stats/TotalsComparison.vue
Normal 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>
|
||||
81
src/pages/stats/components/ComparisonCard.vue
Normal file
81
src/pages/stats/components/ComparisonCard.vue
Normal 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>
|
||||
179
src/pages/stats/components/DeliveryChart.vue
Normal file
179
src/pages/stats/components/DeliveryChart.vue
Normal 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>
|
||||
108
src/pages/stats/components/ExportButtons.vue
Normal file
108
src/pages/stats/components/ExportButtons.vue
Normal 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>
|
||||
32
src/pages/stats/components/TimeRangeSelector.vue
Normal file
32
src/pages/stats/components/TimeRangeSelector.vue
Normal 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>
|
||||
60
src/pages/stats/components/YearSelector.vue
Normal file
60
src/pages/stats/components/YearSelector.vue
Normal 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
28
src/pages/stats/routes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user