feat: 5-tier pricing UI, market ticker, delivery map, and stats dashboard

Full frontend companion to the API updates:

- Pricing: Oil price admin page now supports 5-tier configuration for
  same-day/prime/emergency fees with collapsible tier sections
- Market Ticker: Add GlobalMarketTicker and OilPriceTicker components
  with real-time commodity + competitor prices in header bar
- Delivery Map: New interactive Leaflet map view for daily deliveries
- Stats: Add PricingHistoryChart component and info pages for market
  trends with daily/weekly/monthly gallon charts and YoY comparisons
- Layout: Refactor header navbar to separate search into navbar-center,
  add oilPrice Pinia store with polling, update sidebar navigation
- Forms: Wire tier selection into delivery create/edit flows, update
  types and services for new pricing and scraper API endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 17:54:30 -05:00
parent 6c28c0c2d2
commit 1a53e50d91
69 changed files with 4756 additions and 3040 deletions

View File

@@ -37,15 +37,25 @@
<!-- Main Table Card -->
<div class="modern-table-card">
<!-- Empty State -->
<div v-if="deliveries.length === 0" class="text-center py-16">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-base-200 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-base-content/40">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-1">No deliveries found</h3>
<p class="text-base-content/60">No cancelled deliveries</p>
</div>
<!-- DESKTOP VIEW: Table -->
<div class="hidden xl:block overflow-x-auto">
<div v-else class="hidden xl:block overflow-x-auto">
<table class="modern-table">
<thead>
<tr>
<th>Delivery #</th>
<th>Name</th>
<th>Customer</th>
<th>Status</th>
<th>Town / Address</th>
<th>Address</th>
<th>Gallons</th>
<th>Date</th>
<th>Options</th>
@@ -54,26 +64,49 @@
</thead>
<tbody>
<template v-for="oil in deliveries" :key="oil.id">
<tr v-if="oil.id" class="hover:bg-blue-600 hover:text-white">
<td>{{ oil.id }}</td>
<tr v-if="oil.id" class="table-row-hover">
<td>
<router-link v-if="oil.customer_id" :to="{ name: 'customerProfile', params: { id: oil.customer_id } }" class="link link-hover">
{{ oil.customer_name }}
<router-link v-if="oil.customer_id" :to="{ name: 'customerProfile', params: { id: oil.customer_id } }" class="group">
<div class="font-bold text-base group-hover:text-primary transition-colors">
{{ oil.customer_name }}
</div>
<div class="text-xs font-mono opacity-60 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
#{{ oil.id }}
</div>
</router-link>
<span v-else>{{ oil.customer_name }}</span>
<div v-else>
<div class="font-bold text-base">{{ oil.customer_name }}</div>
<div class="text-xs font-mono opacity-60">#{{ oil.id }}</div>
</div>
</td>
<td>
<span class="badge badge-sm badge-error">Cancelled</span>
</td>
<td>
<div>{{ oil.customer_town }}</div>
<div class="text-xs opacity-70">{{ oil.customer_address }}</div>
<div class="flex flex-col">
<span class="font-medium text-sm">{{ oil.customer_address }}</span>
<div class="flex items-center gap-1">
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ oil.customer_zip }}</span>
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${oil.customer_address}, ${oil.customer_town}, ${getStateAbbr(oil.customer_state)} ${oil.customer_zip}`)}`"
target="_blank"
class="btn btn-xs btn-circle btn-ghost text-base-content/60 hover:text-primary hover:bg-primary/10"
title="View on Google Maps">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</a>
</div>
</div>
</td>
<td>
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info text-lg h-auto py-1">FILL</span>
<span v-else class="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-success/10 border border-success/20 text-success font-mono text-lg font-bold shadow-sm">{{ oil.gallons_ordered }} gal</span>
<span class="text-success font-mono font-bold">{{ oil.gallons_ordered }} gal</span>
</td>
<td>{{ oil.expected_delivery_date }}</td>
<td class="text-sm">{{ oil.expected_delivery_date }}</td>
<td>
<div class="flex flex-col gap-1">
<span v-if="oil.prime" class="badge badge-error badge-xs">PRIME</span>
@@ -82,7 +115,7 @@
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-1">
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost">View</router-link>
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-neutral btn-outline">View</router-link>
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline">Edit</router-link>
</div>
</td>
@@ -128,7 +161,7 @@
</div>
<div>
<p class="text-xs text-base-content/50">Gallons</p>
<p class="font-bold text-lg text-success">
<p class="font-bold text-success">
<span v-if="oil.customer_asked_for_fill" class="badge badge-info badge-xs">FILL</span>
<span v-else>{{ oil.gallons_ordered }}</span>
</p>
@@ -236,6 +269,22 @@ const deleteCall = async (delivery_id: number) => {
}
}
// State abbreviation mapping
const STATE_ABBR_MAP: { [key: number]: string } = {
0: 'MA', 1: 'RI', 2: 'NH', 3: 'ME', 4: 'VT', 5: 'CT', 6: 'NY', 7: 'NJ',
8: 'PA', 9: 'DE', 10: 'MD', 11: 'DC', 12: 'VA', 13: 'WV', 14: 'NC',
15: 'SC', 16: 'GA', 17: 'FL', 18: 'AL', 19: 'MS', 20: 'TN', 21: 'KY',
22: 'OH', 23: 'IN', 24: 'MI', 25: 'IL', 26: 'WI', 27: 'MN', 28: 'IA',
29: 'MO', 30: 'AR', 31: 'LA', 32: 'TX', 33: 'OK', 34: 'KS', 35: 'NE',
36: 'SD', 37: 'ND', 38: 'MT', 39: 'WY', 40: 'CO', 41: 'NM', 42: 'AZ',
43: 'UT', 44: 'NV', 45: 'ID', 46: 'WA', 47: 'OR', 48: 'CA', 49: 'AK', 50: 'HI'
}
const getStateAbbr = (stateId: number | string): string => {
const id = typeof stateId === 'string' ? parseInt(stateId) : stateId;
return STATE_ABBR_MAP[id] || 'MA';
}
// Lifecycle
onMounted(() => {
userStatus()
@@ -393,7 +442,7 @@ onMounted(() => {
/* Gallons Badge */
.gallons-badge {
@apply inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-success/10 border border-success/20 text-success font-mono text-lg font-bold shadow-sm;
@apply text-success font-mono font-bold;
}
.gallons-fill {
@apply bg-info/10 text-info border-info/20;