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>
322 lines
15 KiB
Vue
Executable File
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>
|