Files
eamco_office_frontend/src/pages/customer/home.vue
Edwin Eames 1a53e50d91 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>
2026-02-08 17:54:30 -05:00

322 lines
15 KiB
Vue
Executable File

<!-- src/pages/customer/home.vue -->
<template>
<div class="flex">
<div class="w-full px-4 md:px-10 py-4">
<!-- Breadcrumbs & Title -->
<div class="text-sm breadcrumbs">
<ul>
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li>Customers</li>
</ul>
</div>
<!-- Page Header -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mt-4 mb-6">
<div>
<h1 class="text-2xl md:text-3xl font-bold flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5 text-primary-content">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
Customers
</h1>
<p class="text-base-content/60 mt-1 ml-13">Manage customer profiles and accounts</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="stat-pill">
<span class="stat-pill-value">{{ customer_count }}</span>
<span class="stat-pill-label">Total Customers</span>
</div>
</div>
</div>
<!-- Main Content Card -->
<div class="modern-table-card">
<!-- Search/Filter Placeholder -->
<div class="p-4 border-b border-base-content/5 flex justify-between items-center">
<div class="text-sm text-base-content/60">
Showing {{ customers.length }} records
</div>
</div>
<!-- DESKTOP VIEW: Table -->
<div class="overflow-x-auto hidden xl:block">
<table class="modern-table">
<thead>
<tr>
<th>Customer Name</th>
<th>Address</th>
<th>Map</th>
<th>Status</th>
<th>Phone</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="person in customers" :key="person.id" class="table-row-hover">
<td>
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
class="group">
<div class="font-bold text-base group-hover:text-primary transition-colors">
{{ person.customer_first_name }} {{ person.customer_last_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>
#{{ person.account_number }}
</div>
</router-link>
<div v-else>
<div class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name }}
</div>
<div class="text-xs opacity-60">#{{ person.account_number }}</div>
</div>
</td>
<td>
<div class="flex flex-col">
<span class="font-medium text-sm">{{ person.customer_address }}</span>
<span class="text-xs opacity-70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state)
}} {{ person.customer_zip }}</span>
</div>
</td>
<td>
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${person.customer_address}, ${person.customer_town}, ${getStateAbbr(person.customer_state)}, ${person.customer_zip}`)}`"
target="_blank"
class="btn btn-xs btn-circle btn-ghost text-base-content/60 hover:text-primary hover:bg-primary/10 border border-base-content/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-4 h-4">
<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>
</td>
<td>
<span class="badge badge-sm font-medium"
:class="person.customer_automatic ? 'badge-success text-white' : 'badge-ghost opacity-70'">
{{ person.customer_automatic ? 'Automatic' : 'Will Call' }}
</span>
</td>
<td class="font-mono text-sm">{{ person.customer_phone_number }}</td>
<td class="text-right">
<div class="flex items-center justify-end gap-1">
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }"
class="btn btn-xs btn-success btn-outline" title="New Delivery">
<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.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
</svg>
Delivery
</router-link>
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
class="btn btn-xs btn-accent btn-outline" title="New Service">
<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="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
Service
</router-link>
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }"
class="btn btn-xs btn-neutral btn-outline">View</router-link>
<router-link :to="{ name: 'customerEdit', params: { id: person.id } }"
class="btn btn-xs btn-info btn-outline">Edit</router-link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- MOBILE VIEW: Cards -->
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
<div v-for="person in customers" :key="person.id" class="mobile-card">
<div class="p-3">
<div class="flex justify-between items-start">
<div>
<router-link v-if="person.id" :to="{ name: 'customerProfile', params: { id: person.id } }"
class="font-bold text-base link link-hover text-primary">
{{ person.customer_first_name }} {{ person.customer_last_name }}
</router-link>
<div v-else class="font-bold text-base">{{ person.customer_first_name }} {{ person.customer_last_name
}}</div>
<div class="text-xs text-base-content/60 flex items-center gap-1 mt-0.5">
Account #{{ person.account_number }}
</div>
</div>
<span class="badge badge-sm"
:class="person.customer_automatic ? 'badge-success text-white' : 'badge-ghost opacity-70'">
{{ person.customer_automatic ? 'Auto' : 'Will Call' }}
</span>
</div>
<div class="text-sm mt-3 grid grid-cols-1 gap-y-2">
<div class="flex items-start gap-2">
<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 mt-0.5 opacity-50">
<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>
<div>
<p>{{ person.customer_address }}</p>
<p class="text-xs opacity-70">{{ person.customer_town }}, {{ getStateAbbr(person.customer_state) }}
{{ person.customer_zip }}</p>
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${person.customer_address}, ${person.customer_town}, ${getStateAbbr(person.customer_state)}, ${person.customer_zip}`)}`"
target="_blank" class="link link-primary text-xs mt-1 block">
Open in Maps
</a>
</div>
</div>
<div class="flex items-center gap-2">
<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 opacity-50">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
<span>{{ person.customer_phone_number }}</span>
</div>
</div>
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
<router-link :to="{ name: 'deliveryCreate', params: { id: person.id } }"
class="btn btn-sm btn-success btn-outline flex-1">
Delivery
</router-link>
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
class="btn btn-sm btn-accent flex-1">
Service
</router-link>
<router-link :to="{ name: 'customerEdit', params: { id: person.id } }"
class="btn btn-sm btn-info btn-outline flex-1">
Edit
</router-link>
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }"
class="btn btn-sm btn-neutral btn-outline flex-1">
View
</router-link>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div class="mt-6 flex justify-center">
<pagination @paginate="getPage" :records="customer_count" v-model="page" :per-page="10" :options="options">
</pagination>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { customerService } from '../../services/customerService'
import { authService } from '../../services/authService'
import { Customer } from '../../types/models'
import Header from '../../layouts/headers/headerauth.vue'
import PaginationComp from '../../components/pagination.vue'
import SideBar from '../../layouts/sidebar/sidebar.vue'
// Reactive data
const token = ref(null)
const user = ref(null)
const customers = ref<Customer[]>([])
const customer_count = ref(0)
const page = ref(1)
const perPage = ref(50)
const recordsLength = ref(0)
const options = ref({
edgeNavigation: false,
format: false,
template: markRaw(PaginationComp)
})
// State Mapping
const STATE_ABBR_MAP: Record<number, string> = {
0: 'MA', // Default for unmapped
1: 'AL', 2: 'AK', 3: 'AS', 4: 'AZ', 5: 'AR', 6: 'CA', 7: 'CO', 8: 'CT',
9: 'DE', 10: 'DC', 11: 'FL', 12: 'GA', 13: 'GU', 14: 'HI', 15: 'ID',
16: 'IL', 17: 'IN', 18: 'IA', 19: 'KS', 20: 'KY', 21: 'LA', 22: 'ME',
23: 'MD', 24: 'MA', 25: 'MI', 26: 'MN', 27: 'MS', 28: 'MO', 29: 'MT',
30: 'NE', 31: 'NV', 32: 'NH', 33: 'NJ', 34: 'NM', 35: 'NY', 36: 'NC',
37: 'ND', 38: 'OH', 39: 'OK', 40: 'OR', 41: 'PA', 42: 'PR', 43: 'RI',
44: 'SC', 45: 'SD', 46: 'TN', 47: 'TX', 48: 'UT', 49: 'VT', 50: 'VA',
51: 'VI', 52: 'WA', 53: 'WV', 54: 'WI', 55: 'WY',
}
const getStateAbbr = (stateId: number): string => {
return STATE_ABBR_MAP[stateId] || 'MA';
}
// Functions
const getPage = (pageVal: any) => {
customers.value = [];
get_customers(pageVal)
get_customer_count()
}
const userStatus = () => {
authService.whoami()
.then((response: any) => {
if (response.data.ok) {
user.value = response.data.user;
}
})
.catch(() => {
user.value = null
})
}
const get_customers = async (pageVal: number) => {
try {
const response = await customerService.getAll(pageVal)
customers.value = response.data?.customers || []
} catch (error) {
console.error('Error fetching customers:', error)
customers.value = []
}
}
const get_customer_count = async () => {
try {
const response = await customerService.getCount()
if (response.data) {
customer_count.value = response.data.count
}
} catch (error) {
console.error('Error fetching customer count:', error)
}
}
const deleteCustomer = (user_id: number) => {
customerService.delete(user_id).then(() => {
get_customers(1)
})
}
// Lifecycle
onMounted(() => {
userStatus()
getPage(page.value)
})
</script>
<style scoped></style>