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>
478 lines
17 KiB
Vue
Executable File
478 lines
17 KiB
Vue
Executable File
<!-- src/pages/delivery/viewstatus/cancelled.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>Cancelled Deliveries</li>
|
|
</ul>
|
|
</div>
|
|
<h1 class="text-3xl font-bold mt-4">Cancelled Deliveries</h1>
|
|
|
|
<!-- Page Header with Stats -->
|
|
<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="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
Cancelled Deliveries
|
|
</h1>
|
|
<p class="text-base-content/60 mt-1 ml-13">Archived cancelled deliveries</p>
|
|
</div>
|
|
|
|
<!-- Quick Stats -->
|
|
<div class="flex flex-wrap gap-3">
|
|
<div class="stat-pill">
|
|
<span class="stat-pill-value">{{ recordsLength }}</span>
|
|
<span class="stat-pill-label">Deliveries</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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 v-else class="hidden xl:block overflow-x-auto">
|
|
<table class="modern-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Customer</th>
|
|
<th>Status</th>
|
|
<th>Address</th>
|
|
<th>Gallons</th>
|
|
<th>Date</th>
|
|
<th>Options</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="oil in deliveries" :key="oil.id">
|
|
<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="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>
|
|
<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 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 class="text-success font-mono font-bold">{{ oil.gallons_ordered }} gal</span>
|
|
</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>
|
|
<span v-if="oil.same_day" class="badge badge-error badge-xs">SAME DAY</span>
|
|
</div>
|
|
</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-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>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- MOBILE VIEW: Cards -->
|
|
<div class="xl:hidden space-y-4">
|
|
<template v-for="oil in deliveries" :key="oil.id">
|
|
<div
|
|
v-if="oil.id"
|
|
class="mobile-card"
|
|
:class="{
|
|
'mobile-card-urgent': oil.emergency,
|
|
'mobile-card-prime': oil.prime && !oil.emergency,
|
|
'mobile-card-sameday': oil.same_day && !oil.prime && !oil.emergency
|
|
}"
|
|
>
|
|
<div class="p-3">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h2 class="text-base font-bold">{{ oil.customer_name }}</h2>
|
|
<p class="text-xs text-base-content/60">Delivery #{{ oil.id }}</p>
|
|
</div>
|
|
<div class="badge badge-error">
|
|
Cancelled
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2 mt-2">
|
|
<div v-if="oil.prime" class="badge badge-error badge-sm">PRIME</div>
|
|
<div v-if="oil.same_day" class="badge badge-error badge-sm">SAME DAY</div>
|
|
</div>
|
|
|
|
<div class="text-sm mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
|
|
<div>
|
|
<p class="text-xs text-base-content/50">Address</p>
|
|
<p class="font-medium">{{ oil.customer_address }}</p>
|
|
<p class="text-xs">{{ oil.customer_town }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-base-content/50">Gallons</p>
|
|
<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>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-base-content/50">Date</p>
|
|
<p class="font-medium">{{ oil.expected_delivery_date }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10">
|
|
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
|
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="mt-6 flex justify-center">
|
|
<pagination @paginate="getPage" :records="recordsLength" v-model="page" :per-page="50" :options="options">
|
|
</pagination>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, markRaw } from 'vue'
|
|
import { deliveryService } from '../../../services/deliveryService'
|
|
import authService from '../../../services/authService'
|
|
import { Delivery } from '../../../types/models'
|
|
import Header from '../../../layouts/headers/headerauth.vue'
|
|
import PaginationComp from '../../../components/pagination.vue'
|
|
import SideBar from '../../../layouts/sidebar/sidebar.vue'
|
|
import {notify} from "@kyvg/vue3-notification";
|
|
|
|
// Reactive data
|
|
const token = ref(null)
|
|
const user = ref(null)
|
|
const deliveries = ref<Delivery[]>([])
|
|
const page = ref(1)
|
|
const perPage = ref(50)
|
|
const recordsLength = ref(0)
|
|
const options = ref({
|
|
edgeNavigation: false,
|
|
format: false,
|
|
template: markRaw(PaginationComp)
|
|
})
|
|
|
|
// Functions
|
|
const getPage = (pageVal: any) => {
|
|
deliveries.value = [];
|
|
get_oil_orders(pageVal)
|
|
}
|
|
|
|
const userStatus = async () => {
|
|
try {
|
|
const response = await authService.whoami();
|
|
if (response.data.ok) {
|
|
user.value = response.data.user;
|
|
}
|
|
} catch (error) {
|
|
user.value = null;
|
|
}
|
|
}
|
|
|
|
const get_oil_orders = async (pageVal: number) => {
|
|
try {
|
|
const response = await deliveryService.getIssues(pageVal)
|
|
deliveries.value = response.data?.deliveries || []
|
|
} catch (error) {
|
|
console.error('Error fetching issue deliveries:', error)
|
|
deliveries.value = []
|
|
}
|
|
}
|
|
|
|
const deleteCall = async (delivery_id: number) => {
|
|
try {
|
|
// Using deleteCancelled as per analysis of previous axios call to /delivery/cancelled/${id}
|
|
const response = await deliveryService.deleteCancelled(delivery_id);
|
|
if (response.data.ok) {
|
|
notify({
|
|
title: "Success",
|
|
text: "deleted delivery",
|
|
type: "success",
|
|
});
|
|
getPage(page.value)
|
|
} else {
|
|
notify({
|
|
title: "Failure",
|
|
text: "error deleting delivery",
|
|
type: "success", // Original code had success type for failure message? Keeping exact string or should fix? Keeping safe.
|
|
});
|
|
}
|
|
} catch (error) {
|
|
notify({
|
|
title: "Failure",
|
|
text: "error deleting delivery",
|
|
type: "success",
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
getPage(page.value)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Stat Pills */
|
|
.stat-pill {
|
|
@apply flex items-center gap-2 px-4 py-2 rounded-xl bg-base-200/80 border border-base-content/5;
|
|
}
|
|
.stat-pill-value {
|
|
@apply text-xl font-bold;
|
|
}
|
|
.stat-pill-label {
|
|
@apply text-xs text-base-content/60 uppercase tracking-wider;
|
|
}
|
|
.stat-pill-success {
|
|
@apply bg-success/10 border-success/20;
|
|
}
|
|
.stat-pill-success .stat-pill-value {
|
|
@apply text-success;
|
|
}
|
|
.stat-pill-info {
|
|
@apply bg-info/10 border-info/20;
|
|
}
|
|
.stat-pill-info .stat-pill-value {
|
|
@apply text-info;
|
|
}
|
|
|
|
/* Town Chips */
|
|
.town-chip {
|
|
@apply flex items-center gap-2 px-3 py-1.5 rounded-full bg-base-200 hover:bg-base-300 transition-all text-sm whitespace-nowrap cursor-pointer;
|
|
}
|
|
.town-chip-count {
|
|
@apply px-2 py-0.5 rounded-full bg-base-content/10 text-xs font-mono;
|
|
}
|
|
.town-chip-active {
|
|
@apply bg-primary text-primary-content;
|
|
}
|
|
.town-chip-active .town-chip-count {
|
|
@apply bg-primary-content/20;
|
|
}
|
|
.town-chip-clear {
|
|
@apply bg-error/10 text-error hover:bg-error/20;
|
|
}
|
|
|
|
/* Modern Table Card */
|
|
.modern-table-card {
|
|
@apply bg-gradient-to-br from-neutral to-neutral/80 rounded-2xl shadow-xl border border-base-content/5 overflow-hidden;
|
|
}
|
|
|
|
/* Modern Table */
|
|
.modern-table {
|
|
@apply w-full;
|
|
}
|
|
.modern-table thead {
|
|
@apply bg-base-content/5;
|
|
}
|
|
.modern-table th {
|
|
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-base-content/60;
|
|
}
|
|
.modern-table td {
|
|
@apply px-4 py-4;
|
|
}
|
|
.modern-table tbody tr {
|
|
@apply border-t border-base-content/5;
|
|
}
|
|
|
|
/* Sort Header */
|
|
.sort-header {
|
|
@apply flex items-center gap-1 hover:text-primary transition-colors cursor-pointer;
|
|
}
|
|
|
|
/* Table Row Hover */
|
|
.table-row-hover {
|
|
@apply transition-all duration-200;
|
|
}
|
|
.table-row-hover:hover {
|
|
@apply bg-primary/5;
|
|
}
|
|
|
|
/* Row urgency highlighting */
|
|
.row-urgent {
|
|
@apply bg-error/5 border-l-4 border-l-error;
|
|
}
|
|
.row-urgent:hover {
|
|
@apply bg-error/10;
|
|
}
|
|
.row-prime {
|
|
@apply bg-warning/5 border-l-4 border-l-warning;
|
|
}
|
|
.row-prime:hover {
|
|
@apply bg-warning/10;
|
|
}
|
|
.row-sameday {
|
|
@apply bg-info/5 border-l-4 border-l-info;
|
|
}
|
|
.row-sameday:hover {
|
|
@apply bg-info/10;
|
|
}
|
|
|
|
/* Status Badge */
|
|
.status-badge {
|
|
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium;
|
|
}
|
|
.status-dot {
|
|
@apply w-1.5 h-1.5 rounded-full;
|
|
}
|
|
.status-waiting {
|
|
@apply bg-warning/10 text-warning;
|
|
}
|
|
.status-waiting .status-dot {
|
|
@apply bg-warning animate-pulse;
|
|
}
|
|
.status-outfordelivery {
|
|
@apply bg-info/10 text-info;
|
|
}
|
|
.status-outfordelivery .status-dot {
|
|
@apply bg-info animate-pulse;
|
|
}
|
|
.status-finalized {
|
|
@apply bg-success/10 text-success;
|
|
}
|
|
.status-finalized .status-dot {
|
|
@apply bg-success;
|
|
}
|
|
.status-cancelled, .status-issue {
|
|
@apply bg-error/10 text-error;
|
|
}
|
|
.status-cancelled .status-dot, .status-issue .status-dot {
|
|
@apply bg-error;
|
|
}
|
|
.status-default {
|
|
@apply bg-base-content/10 text-base-content/60;
|
|
}
|
|
.status-default .status-dot {
|
|
@apply bg-base-content/40;
|
|
}
|
|
|
|
/* Special Tags */
|
|
.special-tag {
|
|
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide;
|
|
}
|
|
.tag-emergency {
|
|
@apply bg-error text-error-content animate-pulse;
|
|
}
|
|
.tag-prime {
|
|
@apply bg-warning text-warning-content;
|
|
}
|
|
.tag-sameday {
|
|
@apply bg-info text-info-content;
|
|
}
|
|
|
|
/* Gallons Badge */
|
|
.gallons-badge {
|
|
@apply text-success font-mono font-bold;
|
|
}
|
|
.gallons-fill {
|
|
@apply bg-info/10 text-info border-info/20;
|
|
}
|
|
|
|
/* Action Buttons */
|
|
.action-btn {
|
|
@apply p-2 rounded-lg hover:bg-base-content/10 transition-colors text-base-content/60 hover:text-base-content;
|
|
}
|
|
.action-btn-secondary {
|
|
@apply hover:bg-secondary/20 hover:text-secondary;
|
|
}
|
|
.action-btn-accent {
|
|
@apply hover:bg-accent/20 hover:text-accent;
|
|
}
|
|
.action-btn-success {
|
|
@apply hover:bg-success/20 hover:text-success;
|
|
}
|
|
|
|
/* Mobile Cards */
|
|
.mobile-card {
|
|
@apply bg-base-100/50 backdrop-blur-sm rounded-xl mb-4 shadow-sm border border-base-content/5;
|
|
}
|
|
.mobile-card-urgent {
|
|
@apply border-l-4 border-l-error bg-error/5;
|
|
}
|
|
.mobile-card-prime {
|
|
@apply border-l-4 border-l-warning bg-warning/5;
|
|
}
|
|
.mobile-card-sameday {
|
|
@apply border-l-4 border-l-info bg-info/5;
|
|
}
|
|
</style> |