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:
@@ -5,4 +5,5 @@ VITE_AUTHORIZE_URL="http://localhost:9616"
|
||||
VITE_VOIPMS_URL="http://localhost:9617"
|
||||
VITE_SERVICE_URL="http://localhost:9615"
|
||||
VITE_ADDRESS_CHECKER_URL="http://localhost:9618"
|
||||
VITE_SCRAPER_URL="http://localhost:9619"
|
||||
VITE_COMPANY_ID='1'
|
||||
|
||||
192
src/components/GlobalMarketTicker.vue
Normal file
192
src/components/GlobalMarketTicker.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="flex items-center space-x-4 max-w-full overflow-hidden whitespace-nowrap text-xs h-6">
|
||||
<span class="loading loading-dots loading-xs opacity-50"></span>
|
||||
</div>
|
||||
<div v-else class="ticker-container w-full overflow-hidden h-6 select-none flex items-center">
|
||||
<!-- Play/Pause Button -->
|
||||
<button
|
||||
@click="isPaused = !isPaused"
|
||||
class="btn btn-ghost btn-xs btn-circle z-20 bg-base-300/80 hover:bg-base-300 ml-1 shrink-0"
|
||||
:title="isPaused ? 'Play Ticker' : 'Pause Ticker'"
|
||||
>
|
||||
<svg v-if="isPaused" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||
<path d="M6.3 2.841A.75.75 0 017.05 2.5a.75.75 0 01.35.087l8.25 5c.429.26.429.926 0 1.186l-8.25 5A.75.75 0 016.3 13.159V2.841z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="ticker-track flex items-center whitespace-nowrap text-xs font-mono" :class="{ 'is-paused-perma': isPaused }">
|
||||
<!-- Original List -->
|
||||
<div class="ticker-content flex items-center space-x-12 px-6">
|
||||
<div
|
||||
v-for="item in tickerItems"
|
||||
:key="'original-' + item.symbol"
|
||||
class="flex items-center space-x-2 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span class="font-bold text-base-content/70">{{ getDisplayName(item.symbol) }}</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="font-bold">{{ formatPrice(item.price) }}</span>
|
||||
<span
|
||||
:class="getChangeColor(item.change)"
|
||||
class="flex items-center text-[10px]"
|
||||
>
|
||||
<span class="mr-0.5">{{ item.change >= 0 ? '▲' : '▼' }}</span>
|
||||
{{ Math.abs(item.percent_change).toFixed(2) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Updated (Original) -->
|
||||
<div class="text-[10px] text-base-content/40 pl-4 border-l border-base-content/10">
|
||||
Last Updated: {{ lastUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicated List for Seamless Loop -->
|
||||
<div class="ticker-content flex items-center space-x-12 px-6" aria-hidden="true">
|
||||
<div
|
||||
v-for="item in tickerItems"
|
||||
:key="'duplicate-' + item.symbol"
|
||||
class="flex items-center space-x-2 transition-opacity"
|
||||
>
|
||||
<span class="font-bold text-base-content/70">{{ getDisplayName(item.symbol) }}</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="font-bold">{{ formatPrice(item.price) }}</span>
|
||||
<span
|
||||
:class="getChangeColor(item.change)"
|
||||
class="flex items-center text-[10px]"
|
||||
>
|
||||
<span class="mr-0.5">{{ item.change >= 0 ? '▲' : '▼' }}</span>
|
||||
{{ Math.abs(item.percent_change).toFixed(2) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Updated (Duplicate) -->
|
||||
<div class="text-[10px] text-base-content/40 pl-4 border-l border-base-content/10">
|
||||
Last Updated: {{ lastUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import authHeader from '../services/auth.header';
|
||||
|
||||
interface TickerItem {
|
||||
symbol: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
change: number;
|
||||
percent_change: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const isPaused = ref(false); // New persistence state
|
||||
const tickerItems = ref<TickerItem[]>([]);
|
||||
const lastUpdated = ref<string>('');
|
||||
let intervalId: number | null = null;
|
||||
|
||||
const getDisplayName = (symbol: string) => {
|
||||
switch (symbol) {
|
||||
case 'HO=F': return 'HEATING OIL';
|
||||
case 'CL=F': return 'CRUDE OIL';
|
||||
case 'RB=F': return 'GASOLINE';
|
||||
case 'LMT OIL': return 'LMT';
|
||||
case 'CHARLTON OIL': return 'CHARLTON';
|
||||
case 'LEBLANC OIL': return 'LEBLANC';
|
||||
case 'ALS OIL': return 'AL\'S';
|
||||
case 'VALUE OIL': return 'VALUE';
|
||||
case 'DADDY\'S OIL': return 'DADDY\'S';
|
||||
default: return symbol;
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return price.toFixed(4);
|
||||
};
|
||||
|
||||
const getChangeColor = (change: number) => {
|
||||
if (change > 0) return 'text-success';
|
||||
if (change < 0) return 'text-error';
|
||||
return 'text-base-content/50';
|
||||
};
|
||||
|
||||
const fetchTickerData = async () => {
|
||||
try {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/info/price/ticker';
|
||||
const response = await axios.get(path, {
|
||||
headers: authHeader(),
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
if (response.data.ok && response.data.tickers) {
|
||||
const data = response.data.tickers;
|
||||
const order = ['CL=F', 'HO=F', 'RB=F', 'LMT', 'CHARLTON', 'LEBLANC', 'ALS', 'VALUE', 'DADDY'];
|
||||
|
||||
tickerItems.value = order
|
||||
.map(key => data[key])
|
||||
.filter(item => item !== undefined);
|
||||
|
||||
lastUpdated.value = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ticker data:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTickerData();
|
||||
// Refresh every 1 minute
|
||||
intervalId = window.setInterval(fetchTickerData, 1 * 60 * 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticker-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticker-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: scroll-ticker 30s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.ticker-container:hover .ticker-track,
|
||||
.ticker-track.is-paused-perma {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes scroll-ticker {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.ticker-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
158
src/components/OilPriceTicker.vue
Normal file
158
src/components/OilPriceTicker.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
|
||||
<template>
|
||||
<div v-if="displayPrices.length > 0" class="w-full mb-8 mt-6 animate-fade-in">
|
||||
<!-- Title -->
|
||||
|
||||
|
||||
<!-- Main Consolidated Box -->
|
||||
<div class="card-glass relative h-28 flex items-center px-6 overflow-x-auto no-scrollbar">
|
||||
|
||||
<!-- Horizontal Price Line Container -->
|
||||
<div class="flex items-center justify-between w-full min-w-max md:min-w-0 relative gap-2 h-full">
|
||||
|
||||
<template v-for="(price, index) in displayPrices" :key="price.company_name">
|
||||
|
||||
<!-- CASE 1: Our Price Indicator (Highlighted Bubble) -->
|
||||
<div v-if="price.isOurPrice" class="flex flex-col items-center px-3 py-2 rounded-xl bg-info/5 border border-info/20 shadow-sm transition-all duration-300 group self-center z-10 mx-2">
|
||||
<!-- Price -->
|
||||
<div class="mb-0 pointer-events-none">
|
||||
<span class="font-mono font-black text-lg tracking-tight text-info">
|
||||
${{ Number(price.price_decimal).toFixed(3) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="text-[9px] uppercase font-black tracking-wider text-info whitespace-nowrap mt-0.5">
|
||||
Our Price
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- CASE 2: Competitor Price -->
|
||||
<div v-else class="flex flex-col items-center px-4 py-2 rounded-lg hover:bg-base-content/5 transition-all duration-300 group self-center z-0">
|
||||
<!-- Price Bubble -->
|
||||
<div class="mb-1 pointer-events-none group-hover:-translate-y-1 transition-transform duration-300">
|
||||
<span class="font-mono font-bold text-lg tracking-tight" :class="getTextColor(Number(price.price_decimal))">
|
||||
${{ Number(price.price_decimal).toFixed(3) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Color Dot Indicator -->
|
||||
<div class="w-1.5 h-1.5 rounded-full mb-2 opacity-50" :class="getStatusColor(Number(price.price_decimal))"></div>
|
||||
|
||||
<!-- Company Name -->
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider opacity-60 group-hover:opacity-100 transition-opacity whitespace-nowrap max-w-[100px] truncate">
|
||||
{{ formatCompanyName(price.company_name) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Connector Line -->
|
||||
<div v-if="index < displayPrices.length - 1 && !displayPrices[index+1].isOurPrice && !price.isOurPrice" class="h-px w-8 bg-base-content/10 mx-2 self-center"></div>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { PriceRecord } from '../services/scraperService';
|
||||
import { queryService } from '../services/queryService';
|
||||
|
||||
const props = defineProps<{
|
||||
prices: PriceRecord[];
|
||||
}>();
|
||||
|
||||
const ourPrice = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await queryService.getOilPrice();
|
||||
const data = response.data as any;
|
||||
if (data.ok && data.price_for_customer) {
|
||||
ourPrice.value = data.price_for_customer;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch our oil price', e);
|
||||
}
|
||||
});
|
||||
|
||||
const TARGET_COMPANIES = [
|
||||
'CHARLTON OIL',
|
||||
'LMT OIL',
|
||||
'LEBLANC OIL',
|
||||
'ALS OIL',
|
||||
'VALUE OIL',
|
||||
'DADDY\'S OIL'
|
||||
];
|
||||
|
||||
interface DisplayPrice extends PriceRecord {
|
||||
isOurPrice?: boolean;
|
||||
}
|
||||
|
||||
const normalize = (name: string) => name.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
const filteredPrices = computed(() => {
|
||||
const targets = TARGET_COMPANIES.map(normalize);
|
||||
return props.prices.filter(p => {
|
||||
const nName = normalize(p.company_name);
|
||||
return targets.some(t => nName.includes(t));
|
||||
});
|
||||
});
|
||||
|
||||
const displayPrices = computed(() => {
|
||||
let list: DisplayPrice[] = [...filteredPrices.value];
|
||||
|
||||
if (ourPrice.value !== null) {
|
||||
list.push({
|
||||
company_name: 'OUR PRICE',
|
||||
town: 'Auburn',
|
||||
price_decimal: ourPrice.value,
|
||||
scrape_date: new Date().toISOString(),
|
||||
zone: 'internal',
|
||||
isOurPrice: true
|
||||
});
|
||||
}
|
||||
|
||||
// Sort lowest to highest
|
||||
return list.sort((a, b) => a.price_decimal - b.price_decimal);
|
||||
});
|
||||
|
||||
const averagePrice = computed(() => {
|
||||
if (filteredPrices.value.length === 0) return 0;
|
||||
const sum = filteredPrices.value.reduce((acc, curr) => acc + curr.price_decimal, 0);
|
||||
return sum / filteredPrices.value.length;
|
||||
});
|
||||
|
||||
const getTextColor = (price: number) => {
|
||||
if (price < averagePrice.value - 0.05) return 'text-success';
|
||||
if (price > averagePrice.value + 0.05) return 'text-warning';
|
||||
return 'text-base-content';
|
||||
};
|
||||
|
||||
const getStatusColor = (price: number) => {
|
||||
if (price < averagePrice.value - 0.05) return 'bg-success';
|
||||
if (price > averagePrice.value + 0.05) return 'bg-warning';
|
||||
return 'bg-base-content/30';
|
||||
};
|
||||
|
||||
const formatCompanyName = (name: string) => {
|
||||
if (name === 'OUR PRICE') return 'OUR PRICE';
|
||||
return name.replace(/ OIL$/i, '').trim();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-glass {
|
||||
@apply bg-base-100/80 backdrop-blur-md border border-base-content/10 shadow-lg rounded-2xl;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
173
src/components/PricingHistoryChart.vue
Normal file
173
src/components/PricingHistoryChart.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Market Price Trends</h2>
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:class="{ 'btn-active': days === 7 }"
|
||||
@click="days = 7"
|
||||
>7 Days</button>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:class="{ 'btn-active': days === 30 }"
|
||||
@click="days = 30"
|
||||
>30 Days</button>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:class="{ 'btn-active': days === 90 }"
|
||||
@click="days = 90"
|
||||
>90 Days</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<div v-else class="h-80 w-full relative">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import authHeader from '../services/auth.header';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { Line } from 'vue-chartjs';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
initialDays?: number;
|
||||
}>();
|
||||
|
||||
const days = ref(props.initialDays || 30);
|
||||
const loading = ref(true);
|
||||
const historyData = ref<any[]>([]);
|
||||
|
||||
const chartData = ref({
|
||||
labels: [] as string[],
|
||||
datasets: [] as any[]
|
||||
});
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 3 }).format(context.parsed.y);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return '$' + value.toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/info/price/ticker/history';
|
||||
const response = await axios.get(path, {
|
||||
params: { days: days.value },
|
||||
headers: authHeader(),
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
if (response.data.ok && response.data.history) {
|
||||
historyData.value = response.data.history;
|
||||
updateChart();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ticker history:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateChart = () => {
|
||||
chartData.value = {
|
||||
labels: historyData.value.map(d => formatDate(d.date)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Heating Oil (HO)',
|
||||
borderColor: '#ef4444', // Red
|
||||
backgroundColor: '#ef4444',
|
||||
data: historyData.value.map(d => d.prices['HO=F'] || null),
|
||||
tension: 0.1
|
||||
},
|
||||
{
|
||||
label: 'Crude Oil (CL)',
|
||||
borderColor: '#3b82f6', // Blue
|
||||
backgroundColor: '#3b82f6',
|
||||
data: historyData.value.map(d => d.prices['CL=F'] || null),
|
||||
tension: 0.1
|
||||
},
|
||||
{
|
||||
label: 'Gasoline (RB)',
|
||||
borderColor: '#22c55e', // Green
|
||||
backgroundColor: '#22c55e',
|
||||
data: historyData.value.map(d => d.prices['RB=F'] || null),
|
||||
tension: 0.1
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
watch(days, () => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
23
src/constants/states.ts
Normal file
23
src/constants/states.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const STATE_ID_TO_ABBR: 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',
|
||||
}
|
||||
|
||||
export const STATE_ID_TO_NAME: Record<number, string> = {
|
||||
0: 'Massachusetts',
|
||||
1: 'Alabama', 2: 'Alaska', 3: 'American Samoa', 4: 'Arizona', 5: 'Arkansas', 6: 'California', 7: 'Colorado', 8: 'Connecticut',
|
||||
9: 'Delaware', 10: 'District of Columbia', 11: 'Florida', 12: 'Georgia', 13: 'Guam', 14: 'Hawaii', 15: 'Idaho',
|
||||
16: 'Illinois', 17: 'Indiana', 18: 'Iowa', 19: 'Kansas', 20: 'Kentucky', 21: 'Louisiana', 22: 'Maine',
|
||||
23: 'Maryland', 24: 'Massachusetts', 25: 'Michigan', 26: 'Minnesota', 27: 'Mississippi', 28: 'Missouri', 29: 'Montana',
|
||||
30: 'Nebraska', 31: 'Nevada', 32: 'New Hampshire', 33: 'New Jersey', 34: 'New Mexico', 35: 'New York', 36: 'North Carolina',
|
||||
37: 'North Dakota', 38: 'Ohio', 39: 'Oklahoma', 40: 'Oregon', 41: 'Pennsylvania', 42: 'Puerto Rico', 43: 'Rhode Island',
|
||||
44: 'South Carolina', 45: 'South Dakota', 46: 'Tennessee', 47: 'Texas', 48: 'Utah', 49: 'Vermont', 50: 'Virginia',
|
||||
51: 'Virgin Islands', 52: 'Washington', 53: 'West Virginia', 54: 'Wisconsin', 55: 'Wyoming',
|
||||
}
|
||||
@@ -33,11 +33,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSearchStore } from '../stores/search';
|
||||
import { useOilPriceStore } from '../stores/oilPrice'; // [NEW]
|
||||
import HeaderAuth from './headers/headerauth.vue';
|
||||
import SideBar from './sidebar/sidebar.vue';
|
||||
import Footer from './footers/footer.vue';
|
||||
import SearchResults from '../components/SearchResults.vue';
|
||||
import PageTransition from '../components/PageTransition.vue';
|
||||
import { onMounted } from 'vue'; // [NEW]
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
const oilPriceStore = useOilPriceStore(); // [NEW]
|
||||
|
||||
onMounted(() => {
|
||||
oilPriceStore.fetchPrices(); // [NEW] Global check
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</label>
|
||||
|
||||
<!-- Oil Price Dropdown (replaces date display) -->
|
||||
<div class="dropdown dropdown-hover">
|
||||
<div class="dropdown dropdown-hover hidden xl:block">
|
||||
<label tabindex="0" class="btn btn-ghost gap-2 hover:bg-warning/10 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-warning to-warning/60 flex items-center justify-center shadow-lg">
|
||||
@@ -60,17 +60,51 @@
|
||||
<span class="text-sm font-medium">Regular Delivery</span>
|
||||
<span class="font-mono font-bold text-lg text-success">${{ formatPrice(oilPrice.price_for_customer) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||
<span class="text-sm text-base-content/70">Same Day</span>
|
||||
<span class="font-mono font-semibold">${{ formatPrice(oilPrice.price_same_day) }}</span>
|
||||
<!-- Delivery Service Pricing in a compact table -->
|
||||
<div class="mt-3">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-1 py-1 text-xs opacity-60">Type</th>
|
||||
<th class="px-1 py-1 text-center text-xs opacity-60">1</th>
|
||||
<th class="px-1 py-1 text-center text-xs opacity-60">2</th>
|
||||
<th class="px-1 py-1 text-center text-xs opacity-60">3</th>
|
||||
<th class="px-1 py-1 text-center text-xs opacity-60">4</th>
|
||||
<th class="px-1 py-1 text-center text-xs opacity-60">5</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Same Day-->
|
||||
<tr class="hover">
|
||||
<td class="px-1 py-1 font-semibold text-xs whitespace-nowrap">Same Day</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_same_day_tier1 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_same_day_tier2 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_same_day_tier3 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_same_day_tier4 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_same_day_tier5 || 0) }}</td>
|
||||
</tr>
|
||||
<!-- Prime-->
|
||||
<tr class="hover">
|
||||
<td class="px-1 py-1 font-semibold text-xs whitespace-nowrap">Prime</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_prime_tier1 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_prime_tier2 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_prime_tier3 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_prime_tier4 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_prime_tier5 || 0) }}</td>
|
||||
</tr>
|
||||
<!-- Emergency -->
|
||||
<tr class="hover text-error">
|
||||
<td class="px-1 py-1 font-semibold text-xs whitespace-nowrap">Emergency</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_emergency_tier1 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_emergency_tier2 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_emergency_tier3 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_emergency_tier4 || 0) }}</td>
|
||||
<td class="px-1 py-1 text-center text-xs font-mono">${{ Math.round(oilPrice.price_emergency_tier5 || 0) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||
<span class="text-sm text-base-content/70">Prime</span>
|
||||
<span class="font-mono font-semibold">${{ formatPrice(oilPrice.price_prime) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||
<span class="text-sm text-base-content/70">Emergency</span>
|
||||
<span class="font-mono font-semibold text-error">${{ formatPrice(oilPrice.price_emergency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,16 +142,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Navbar End: Contains the Search Bar (on tablet+) and all action buttons.
|
||||
Using flexbox to manage the space distribution.
|
||||
-->
|
||||
<div class="navbar-end w-full gap-4">
|
||||
|
||||
<!-- Search Bar Wrapper (for tablet and up) -->
|
||||
<!-- This is the growing element. Hidden on mobile. -->
|
||||
<div class="hidden md:flex flex-grow max-w-xl">
|
||||
<div class="relative w-full">
|
||||
<!-- Navbar Center: Search Bar (tablet+) -->
|
||||
<div class="navbar-center hidden md:flex min-w-[24rem]">
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-base-content/50">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
@@ -134,12 +161,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navbar End: Action Buttons -->
|
||||
<div class="navbar-end gap-2">
|
||||
|
||||
<!-- Action Buttons Wrapper -->
|
||||
<!-- This group is set to not shrink, keeping it always visible. -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
|
||||
<!-- VOIP Routing Dropdown (visible tablet+) -->
|
||||
<div class="dropdown dropdown-end hidden md:flex">
|
||||
<div class="dropdown dropdown-end hidden xl:flex">
|
||||
<label tabindex="0" class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-success">
|
||||
<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 6.75z" />
|
||||
@@ -208,6 +238,11 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: Stock Ticker -->
|
||||
<div class="w-full bg-base-300/50 border-b border-base-200 py-1 overflow-hidden">
|
||||
<GlobalMarketTicker />
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- MODAL SECTION REMAINS UNCHANGED -->
|
||||
@@ -309,13 +344,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import { useSearchStore } from '../../stores/search' // Adjust path if needed
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useThemeStore, AVAILABLE_THEMES } from '../../stores/theme'
|
||||
import GlobalMarketTicker from '../../components/GlobalMarketTicker.vue' // Import Global Ticker
|
||||
|
||||
// Define the shape of your data for internal type safety
|
||||
interface User {
|
||||
@@ -337,9 +373,27 @@ interface OilPrice {
|
||||
price_from_supplier: number | null;
|
||||
price_for_customer: number | null;
|
||||
price_for_employee: number | null;
|
||||
price_same_day: number | null;
|
||||
price_prime: number | null;
|
||||
price_emergency: number | null;
|
||||
|
||||
// Same Day Tiers
|
||||
price_same_day_tier1: number | null;
|
||||
price_same_day_tier2: number | null;
|
||||
price_same_day_tier3: number | null;
|
||||
price_same_day_tier4: number | null;
|
||||
price_same_day_tier5: number | null;
|
||||
|
||||
// Prime Tiers
|
||||
price_prime_tier1: number | null;
|
||||
price_prime_tier2: number | null;
|
||||
price_prime_tier3: number | null;
|
||||
price_prime_tier4: number | null;
|
||||
price_prime_tier5: number | null;
|
||||
|
||||
// Emergency Tiers
|
||||
price_emergency_tier1: number | null;
|
||||
price_emergency_tier2: number | null;
|
||||
price_emergency_tier3: number | null;
|
||||
price_emergency_tier4: number | null;
|
||||
price_emergency_tier5: number | null;
|
||||
}
|
||||
|
||||
// Reactive data
|
||||
@@ -349,10 +403,26 @@ const oilPrice = ref<OilPrice>({
|
||||
price_from_supplier: null,
|
||||
price_for_customer: null,
|
||||
price_for_employee: null,
|
||||
price_same_day: null,
|
||||
price_prime: null,
|
||||
price_emergency: null
|
||||
|
||||
price_same_day_tier1: null,
|
||||
price_same_day_tier2: null,
|
||||
price_same_day_tier3: null,
|
||||
price_same_day_tier4: null,
|
||||
price_same_day_tier5: null,
|
||||
|
||||
price_prime_tier1: null,
|
||||
price_prime_tier2: null,
|
||||
price_prime_tier3: null,
|
||||
price_prime_tier4: null,
|
||||
price_prime_tier5: null,
|
||||
|
||||
price_emergency_tier1: null,
|
||||
price_emergency_tier2: null,
|
||||
price_emergency_tier3: null,
|
||||
price_emergency_tier4: null,
|
||||
price_emergency_tier5: null,
|
||||
})
|
||||
let oilPriceIntervalId: number | null = null
|
||||
const routingOptions = ref([
|
||||
{ value: 'main', label: '407323' },
|
||||
{ value: 'sip', label: '407323_auburnoil' },
|
||||
@@ -408,6 +478,14 @@ onMounted(() => {
|
||||
userStatus()
|
||||
updatestatus()
|
||||
fetchOilPrice()
|
||||
// Refresh oil price every 1 minute
|
||||
oilPriceIntervalId = window.setInterval(fetchOilPrice, 1 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (oilPriceIntervalId) {
|
||||
clearInterval(oilPriceIntervalId)
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch oil pricing
|
||||
@@ -424,9 +502,24 @@ const fetchOilPrice = () => {
|
||||
price_from_supplier: response.data.price_from_supplier,
|
||||
price_for_customer: response.data.price_for_customer,
|
||||
price_for_employee: response.data.price_for_employee,
|
||||
price_same_day: response.data.price_same_day,
|
||||
price_prime: response.data.price_prime,
|
||||
price_emergency: response.data.price_emergency
|
||||
|
||||
price_same_day_tier1: response.data.price_same_day_tier1,
|
||||
price_same_day_tier2: response.data.price_same_day_tier2,
|
||||
price_same_day_tier3: response.data.price_same_day_tier3,
|
||||
price_same_day_tier4: response.data.price_same_day_tier4,
|
||||
price_same_day_tier5: response.data.price_same_day_tier5,
|
||||
|
||||
price_prime_tier1: response.data.price_prime_tier1,
|
||||
price_prime_tier2: response.data.price_prime_tier2,
|
||||
price_prime_tier3: response.data.price_prime_tier3,
|
||||
price_prime_tier4: response.data.price_prime_tier4,
|
||||
price_prime_tier5: response.data.price_prime_tier5,
|
||||
|
||||
price_emergency_tier1: response.data.price_emergency_tier1,
|
||||
price_emergency_tier2: response.data.price_emergency_tier2,
|
||||
price_emergency_tier3: response.data.price_emergency_tier3,
|
||||
price_emergency_tier4: response.data.price_emergency_tier4,
|
||||
price_emergency_tier5: response.data.price_emergency_tier5,
|
||||
};
|
||||
})
|
||||
.catch((error: any) => {
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
Home
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'PricingHistory' }" exact-active-class="active" class="gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
Market Trends
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
<!-- Customer Section -->
|
||||
<li>
|
||||
@@ -42,6 +50,7 @@
|
||||
</summary>
|
||||
<ul>
|
||||
<li><router-link :to="{ name: 'delivery' }" exact-active-class="active">Home</router-link></li>
|
||||
<li><router-link :to="{ name: 'deliveryMap' }" exact-active-class="active">Map</router-link></li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'deliveryOutForDelivery' }" exact-active-class="active">
|
||||
Todays Tickets
|
||||
@@ -161,7 +170,6 @@
|
||||
<li><router-link :to="{ name: 'employee' }" exact-active-class="active">Employees</router-link></li>
|
||||
<li><router-link :to="{ name: 'oilprice' }" exact-active-class="active">Oil Pricing</router-link></li>
|
||||
<li><router-link :to="{ name: 'promo' }" exact-active-class="active">Promos</router-link></li>
|
||||
<li><router-link :to="{ name: 'MoneyYear' }" exact-active-class="active">Money</router-link></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Oil Price Ticker -->
|
||||
<OilPriceTicker :prices="oilPriceStore.prices" />
|
||||
|
||||
<!-- Main Dashboard Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 animate-fade-in">
|
||||
|
||||
@@ -246,12 +249,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../services/auth.header'
|
||||
import { deliveryService } from '../services/deliveryService'
|
||||
import { useCountsStore } from '../stores/counts'
|
||||
import { useOilPriceStore } from '../stores/oilPrice' // [NEW]
|
||||
import OilPriceTicker from '../components/OilPriceTicker.vue' // [NEW]
|
||||
import { DeliveryMapItem } from '../types/models'
|
||||
import "leaflet/dist/leaflet.css"
|
||||
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet"
|
||||
@@ -285,6 +290,7 @@ const router = useRouter()
|
||||
|
||||
// Stores
|
||||
const countsStore = useCountsStore()
|
||||
const oilPriceStore = useOilPriceStore() // [NEW]
|
||||
|
||||
// Reactive data
|
||||
const delivery_count = ref(0)
|
||||
@@ -430,6 +436,11 @@ onMounted(() => {
|
||||
countsStore.fetchSidebarCounts()
|
||||
fetchMapDeliveries()
|
||||
fetchWeeklyChartData()
|
||||
oilPriceStore.startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
oilPriceStore.stopPolling()
|
||||
})
|
||||
|
||||
// API Functions
|
||||
|
||||
@@ -62,36 +62,151 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: Service Fees -->
|
||||
<!-- SECTION 2: Service Fees with 5 Tiers -->
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">Service Fees</h2>
|
||||
<p class="text-xs text-gray-400">Set the flat fees for special delivery services.</p>
|
||||
<h2 class="text-lg font-bold">Service Fees (5-Tier Pricing)</h2>
|
||||
<p class="text-xs text-gray-400">Set pricing tiers for special delivery services.</p>
|
||||
<div class="divider mt-2 mb-4"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Price Same Day -->
|
||||
|
||||
<!-- Same Day Fee Tiers -->
|
||||
<div class="collapse collapse-arrow bg-base-200 mb-3">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-md font-medium">
|
||||
Same Day Fee (5 Tiers)
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-2">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Same Day Fee</span></label>
|
||||
<label class="label"><span class="label-text">Tier 1</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_same_day" type="number" step="1.00" placeholder="50.00" class="input input-bordered input-sm w-full" />
|
||||
<input v-model.number="OilForm.price_same_day_tier1" type="number" step="0.01" placeholder="50.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<!-- Price Prime -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Prime Fee</span></label>
|
||||
<label class="label"><span class="label-text">Tier 2</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_prime" type="number" step="1.00" placeholder="75.00" class="input input-bordered input-sm w-full" />
|
||||
<input v-model.number="OilForm.price_same_day_tier2" type="number" step="0.01" placeholder="75.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<!-- Price Emergency -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Emergency Fee</span></label>
|
||||
<label class="label"><span class="label-text">Tier 3</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_emergency" type="number" step="1.00" placeholder="150.00" class="input input-bordered input-sm w-full" />
|
||||
<input v-model.number="OilForm.price_same_day_tier3" type="number" step="0.01" placeholder="100.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 4</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_same_day_tier4" type="number" step="0.01" placeholder="125.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 5</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_same_day_tier5" type="number" step="0.01" placeholder="150.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prime Fee Tiers -->
|
||||
<div class="collapse collapse-arrow bg-base-200 mb-3">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-md font-medium">
|
||||
Prime Fee (5 Tiers)
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-2">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 1</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_prime_tier1" type="number" step="0.01" placeholder="75.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 2</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_prime_tier2" type="number" step="0.01" placeholder="100.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 3</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_prime_tier3" type="number" step="0.01" placeholder="125.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 4</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_prime_tier4" type="number" step="0.01" placeholder="150.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 5</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_prime_tier5" type="number" step="0.01" placeholder="175.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emergency Fee Tiers -->
|
||||
<div class="collapse collapse-arrow bg-base-200 mb-3">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-md font-medium">
|
||||
Emergency Fee (5 Tiers)
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-2">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 1</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_emergency_tier1" type="number" step="0.01" placeholder="150.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 2</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_emergency_tier2" type="number" step="0.01" placeholder="200.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 3</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_emergency_tier3" type="number" step="0.01" placeholder="250.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 4</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_emergency_tier4" type="number" step="0.01" placeholder="300.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Tier 5</span></label>
|
||||
<label class="input-group input-group-sm">
|
||||
<span>$</span>
|
||||
<input v-model.number="OilForm.price_emergency_tier5" type="number" step="0.01" placeholder="350.00" class="input input-bordered input-sm w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,9 +235,24 @@ const OilForm = ref({
|
||||
price_from_supplier: 0,
|
||||
price_for_customer: 0,
|
||||
price_for_employee: 0,
|
||||
price_same_day: 0,
|
||||
price_prime: 0,
|
||||
price_emergency: 0,
|
||||
// Same Day tiers
|
||||
price_same_day_tier1: 0,
|
||||
price_same_day_tier2: 0,
|
||||
price_same_day_tier3: 0,
|
||||
price_same_day_tier4: 0,
|
||||
price_same_day_tier5: 0,
|
||||
// Prime tiers
|
||||
price_prime_tier1: 0,
|
||||
price_prime_tier2: 0,
|
||||
price_prime_tier3: 0,
|
||||
price_prime_tier4: 0,
|
||||
price_prime_tier5: 0,
|
||||
// Emergency tiers
|
||||
price_emergency_tier1: 0,
|
||||
price_emergency_tier2: 0,
|
||||
price_emergency_tier3: 0,
|
||||
price_emergency_tier4: 0,
|
||||
price_emergency_tier5: 0,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'customer' }">
|
||||
Customers
|
||||
<router-link :to="{ name: 'promo' }">
|
||||
Promotions
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -22,155 +22,97 @@
|
||||
<div class="text-[24px]">
|
||||
Create Promo
|
||||
</div>
|
||||
<form class="rounded-md px-8 pt-6 pb-8 mb-4 w-full"
|
||||
enctype="multipart/form-data"
|
||||
@submit.prevent="onSubmit">
|
||||
<form class="rounded-md px-8 pt-6 pb-8 mb-4 w-full" enctype="multipart/form-data" @submit.prevent="onSubmit">
|
||||
|
||||
<div class="col-span-12 md:col-span-4 mb-5 md:mb-0 gap-10">
|
||||
<label class="block text-white text-sm font-bold cursor-pointer label">Promotion Name</label>
|
||||
<input v-model="CreatePromoForm.name_of_promotion"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
id="title" type="text" placeholder="Name"/>
|
||||
<input v-model="form.name_of_promotion" class="input input-bordered input-sm w-full max-w-xs" id="title"
|
||||
type="text" placeholder="Name" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4 mb-5 md:mb-0 gap-10">
|
||||
<label class="block text-white text-sm font-bold cursor-pointer label">Text Appears on Ticket</label>
|
||||
<input v-model="CreatePromoForm.text_on_ticket"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
id="title" type="text" placeholder="Text on Ticket max 20 characters"/>
|
||||
<label class="block text-white text-sm font-bold cursor-pointer label">Text Appears on
|
||||
Ticket</label>
|
||||
<input v-model="form.text_on_ticket" class="input input-bordered input-sm w-full max-w-xs" id="title"
|
||||
type="text" placeholder="Text on Ticket max 20 characters" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2">How much off gallon (0.05 for example)</label>
|
||||
<input v-model="CreatePromoForm.money_off_delivery"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
id="title" type="text" placeholder="0.01"/>
|
||||
<label class="block text-white text-sm font-bold mb-2">How much off gallon (0.05 for
|
||||
example)</label>
|
||||
<input v-model="form.money_off_delivery" class="input input-bordered input-sm w-full max-w-xs" id="title"
|
||||
type="text" placeholder="0.01" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4 mb-5 md:mb-0">
|
||||
<textarea v-model="CreatePromoForm.description" rows="4"
|
||||
class="textarea block p-2.5 w-full input-bordered " id="description" type="text" placeholder="Description of Promo" />
|
||||
<textarea v-model="form.description" rows="4" class="textarea block p-2.5 w-full input-bordered "
|
||||
id="description" type="text" placeholder="Description of Promo" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-12 flex mt-5 mb-5">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm">
|
||||
Create Promo
|
||||
<button class="btn btn-secondary btn-sm" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? 'Creating...' : 'Create Promo' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { adminService } from '../../../services/adminService'
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../../services/auth.header'
|
||||
import Header from '../../../layouts/headers/headerauth.vue'
|
||||
import SideBar from '../../../layouts/sidebar/sidebar.vue'
|
||||
import useValidate from "@vuelidate/core";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
const router = useRouter()
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PromoCreate',
|
||||
|
||||
components: {
|
||||
Header,
|
||||
SideBar,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
PromoOrder:{
|
||||
id: 0,
|
||||
// State
|
||||
const isSubmitting = ref(false)
|
||||
const form = ref({
|
||||
name_of_promotion: '',
|
||||
description: '',
|
||||
money_off_delivery: '',
|
||||
text_on_ticket: '',
|
||||
},
|
||||
CreatePromoForm: {
|
||||
name_of_promotion: '',
|
||||
description: '',
|
||||
money_off_delivery: '',
|
||||
text_on_ticket: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.userStatus()
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
|
||||
CreatePromo(payload: {
|
||||
name_of_promotion: string;
|
||||
money_off_delivery: string;
|
||||
description: string;
|
||||
text_on_ticket: string;
|
||||
// Methods
|
||||
async function onSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
|
||||
}) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/promo/create" ;
|
||||
axios({
|
||||
method: "post",
|
||||
url: path,
|
||||
data: payload,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
try {
|
||||
const response = await adminService.promos.create({
|
||||
name_of_promotion: form.value.name_of_promotion,
|
||||
money_off_delivery: form.value.money_off_delivery,
|
||||
description: form.value.description,
|
||||
text_on_ticket: form.value.text_on_ticket,
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
|
||||
if ((response.data as any).ok) {
|
||||
notify({
|
||||
title: "update",
|
||||
title: "Success",
|
||||
text: "Promo has been created!",
|
||||
type: "success",
|
||||
});
|
||||
this.$router.push({name: "promo"});
|
||||
}
|
||||
if (response.data.error) {
|
||||
this.$router.push("promo");
|
||||
}
|
||||
})
|
||||
},
|
||||
onSubmit() {
|
||||
let payload = {
|
||||
name_of_promotion: this.CreatePromoForm.name_of_promotion,
|
||||
money_off_delivery: this.CreatePromoForm.money_off_delivery,
|
||||
description: this.CreatePromoForm.description,
|
||||
text_on_ticket: this.CreatePromoForm.text_on_ticket,
|
||||
};
|
||||
this.CreatePromo(payload);
|
||||
},
|
||||
},
|
||||
router.push({ name: "promo" })
|
||||
} else if ((response.data as any).error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: (response.data as any).error,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Failed to create promo",
|
||||
type: "error",
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
@@ -11,8 +11,8 @@
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'customer' }">
|
||||
Customers
|
||||
<router-link :to="{ name: 'promo' }">
|
||||
Promotions
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -20,183 +20,132 @@
|
||||
|
||||
<div class="grid grid-cols-1 rounded-md p-6 ">
|
||||
<div class="text-[24px]">
|
||||
Edit Promo {{ PromoOrder.name_of_promotion }}
|
||||
Edit Promo {{ promoName }}
|
||||
</div>
|
||||
<form class="rounded-md px-8 pt-6 pb-8 mb-4 w-full"
|
||||
enctype="multipart/form-data"
|
||||
@submit.prevent="onSubmit">
|
||||
<form class="rounded-md px-8 pt-6 pb-8 mb-4 w-full" enctype="multipart/form-data" @submit.prevent="onSubmit">
|
||||
|
||||
<div class="col-span-12 md:col-span-4 mb-5 md:mb-0 gap-10">
|
||||
<label class="block text-white text-sm font-bold cursor-pointer label">Promotion Name</label>
|
||||
<input v-model="CreatePromoForm.name_of_promotion"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
id="title" type="text" placeholder="Name"/>
|
||||
<input v-model="form.name_of_promotion" class="input input-bordered input-sm w-full max-w-xs" id="title"
|
||||
type="text" placeholder="Name" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4 mb-5 md:mb-0 gap-10">
|
||||
<label class="block text-white text-sm font-bold cursor-pointer label">Text on Ticket</label>
|
||||
<input v-model="CreatePromoForm.text_on_ticket"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
id="title" type="text" placeholder="Text appears on ticket "/>
|
||||
<input v-model="form.text_on_ticket" class="input input-bordered input-sm w-full max-w-xs" id="title"
|
||||
type="text" placeholder="Text appears on ticket " />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2">How much off gallon (0.05 for example)</label>
|
||||
<input v-model="CreatePromoForm.money_off_delivery"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
id="title" type="text" placeholder="0.01"/>
|
||||
<label class="block text-white text-sm font-bold mb-2">How much off gallon (0.05 for
|
||||
example)</label>
|
||||
<input v-model="form.money_off_delivery" class="input input-bordered input-sm w-full max-w-xs" id="title"
|
||||
type="text" placeholder="0.01" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="col-span-12 md:col-span-4 mb-5 md:mb-0">
|
||||
<textarea v-model="CreatePromoForm.description" rows="4"
|
||||
class="textarea block p-2.5 w-full input-bordered " id="description" type="text" placeholder="Description of Promo" />
|
||||
<textarea v-model="form.description" rows="4" class="textarea block p-2.5 w-full input-bordered "
|
||||
id="description" type="text" placeholder="Description of Promo" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="col-span-12 md:col-span-12 flex mt-5 mb-5">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm">
|
||||
Edit Promo
|
||||
<button class="btn btn-secondary btn-sm" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? 'Saving...' : 'Edit Promo' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { adminService } from '../../../services/adminService'
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../../services/auth.header'
|
||||
import Header from '../../../layouts/headers/headerauth.vue'
|
||||
import SideBar from '../../../layouts/sidebar/sidebar.vue'
|
||||
import useValidate from "@vuelidate/core";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PromoEdit',
|
||||
|
||||
components: {
|
||||
Header,
|
||||
SideBar,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
PromoOrder:{
|
||||
id: 0,
|
||||
// State
|
||||
const promoId = ref<number>(0)
|
||||
const promoName = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const form = ref({
|
||||
name_of_promotion: '',
|
||||
description: '',
|
||||
money_off_delivery: '',
|
||||
text_on_ticket: ''
|
||||
},
|
||||
CreatePromoForm: {
|
||||
name_of_promotion: '',
|
||||
description:'',
|
||||
money_off_delivery: '',
|
||||
text_on_ticket: ''
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
created() {
|
||||
this.userStatus()
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.getCurrentPromo(this.$route.params.id);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getCurrentPromo(this.$route.params.id);
|
||||
},
|
||||
methods: {
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
getCurrentPromo(promo_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/promo/" + promo_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
// Methods
|
||||
async function getCurrentPromo(id: number | string) {
|
||||
try {
|
||||
const response = await adminService.promos.getById(Number(id))
|
||||
if (response.data) {
|
||||
this.PromoOrder = response.data
|
||||
this.CreatePromoForm.name_of_promotion = response.data.name_of_promotion;
|
||||
this.CreatePromoForm.description = response.data.description;
|
||||
this.CreatePromoForm.money_off_delivery = response.data.money_off_delivery;
|
||||
this.CreatePromoForm.text_on_ticket = response.data.text_on_ticket;
|
||||
const data = response.data as any
|
||||
promoId.value = data.id
|
||||
promoName.value = data.name_of_promotion
|
||||
form.value.name_of_promotion = data.name_of_promotion
|
||||
form.value.description = data.description
|
||||
form.value.money_off_delivery = data.money_off_delivery
|
||||
form.value.text_on_ticket = data.text_on_ticket
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch promo:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
EditPromo(payload: {
|
||||
name_of_promotion: string;
|
||||
description: string;
|
||||
money_off_delivery: string;
|
||||
text_on_ticket: string;
|
||||
|
||||
}) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/promo/edit/" + this.PromoOrder.id;
|
||||
axios({
|
||||
method: "put",
|
||||
url: path,
|
||||
data: payload,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
async function onSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const response = await adminService.promos.update(promoId.value, {
|
||||
name_of_promotion: form.value.name_of_promotion,
|
||||
description: form.value.description,
|
||||
text_on_ticket: form.value.text_on_ticket,
|
||||
money_off_delivery: form.value.money_off_delivery,
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
|
||||
if ((response.data as any).ok) {
|
||||
notify({
|
||||
title: "update",
|
||||
title: "Success",
|
||||
text: "Promo has been edited!",
|
||||
type: "success",
|
||||
});
|
||||
this.$router.push({name: "promo"});
|
||||
})
|
||||
router.push({ name: "promo" })
|
||||
} else if ((response.data as any).error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: (response.data as any).error,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
if (response.data.error) {
|
||||
this.$router.push("promo");
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Failed to update promo",
|
||||
type: "error",
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for route changes
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) {
|
||||
getCurrentPromo(newId as string)
|
||||
}
|
||||
})
|
||||
},
|
||||
onSubmit() {
|
||||
let payload = {
|
||||
name_of_promotion: this.CreatePromoForm.name_of_promotion,
|
||||
description: this.CreatePromoForm.description,
|
||||
text_on_ticket: this.CreatePromoForm.text_on_ticket,
|
||||
money_off_delivery: this.CreatePromoForm.money_off_delivery,
|
||||
};
|
||||
this.EditPromo(payload);
|
||||
},
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (route.params.id) {
|
||||
getCurrentPromo(route.params.id as string)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
@@ -58,27 +58,27 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="promo in promos" :key="promo['id']" class="table-row-hover">
|
||||
<td>{{ promo['id'] }}</td>
|
||||
<tr v-for="promo in promos" :key="promo.id" class="table-row-hover">
|
||||
<td>{{ promo.id }}</td>
|
||||
<td class="font-bold">
|
||||
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
|
||||
<router-link :to="{ name: 'promoedit', params: { id: promo.id } }"
|
||||
class="link link-hover">
|
||||
{{ promo['name_of_promotion'] }}
|
||||
{{ promo.name_of_promotion }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-success badge-sm">${{ promo['money_off_delivery'] }}
|
||||
<span class="badge badge-success badge-sm">${{ promo.money_off_delivery }}
|
||||
off/gal</span>
|
||||
</td>
|
||||
<td>{{ promo['description'] }}</td>
|
||||
<td>{{ promo['text_on_ticket'] }}</td>
|
||||
<td>{{ promo.description }}</td>
|
||||
<td>{{ promo.text_on_ticket }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
|
||||
<router-link :to="{ name: 'promoedit', params: { id: promo.id } }"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click.prevent="deletepromo(promo['id'])"
|
||||
<button @click.prevent="deletePromo(promo.id)"
|
||||
class="btn btn-sm btn-error btn-outline">
|
||||
Delete
|
||||
</button>
|
||||
@@ -91,32 +91,32 @@
|
||||
|
||||
<!-- MOBILE VIEW: Cards -->
|
||||
<div class="xl:hidden space-y-4 px-4 pb-4 pt-4">
|
||||
<div v-for="promo in promos" :key="promo['id']" class="mobile-card">
|
||||
<div v-for="promo in promos" :key="promo.id" class="mobile-card">
|
||||
<div class="p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-base font-bold">{{ promo['name_of_promotion'] }}</h2>
|
||||
<p class="text-xs text-base-content/60">ID: #{{ promo['id'] }}</p>
|
||||
<h2 class="text-base font-bold">{{ promo.name_of_promotion }}</h2>
|
||||
<p class="text-xs text-base-content/60">ID: #{{ promo.id }}</p>
|
||||
</div>
|
||||
<div class="badge badge-success badge-sm">
|
||||
${{ promo['money_off_delivery'] }} off/gal
|
||||
${{ promo.money_off_delivery }} off/gal
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm mt-3 space-y-2">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Description</p>
|
||||
<p>{{ promo['description'] }}</p>
|
||||
<p>{{ promo.description }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Ticket Text</p>
|
||||
<p class="font-mono text-xs bg-base-300 p-1 rounded inline-block">{{
|
||||
promo['text_on_ticket'] }}</p>
|
||||
promo.text_on_ticket }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||
<router-link :to="{ name: 'promoedit', params: { id: promo['id'] } }"
|
||||
<router-link :to="{ name: 'promoedit', params: { id: promo.id } }"
|
||||
class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||
<button @click.prevent="deletepromo(promo['id'])"
|
||||
<button @click.prevent="deletePromo(promo.id)"
|
||||
class="btn btn-sm btn-error btn-outline flex-1">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,145 +129,67 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../../services/auth.header'
|
||||
import Header from '../../../layouts/headers/headerauth.vue'
|
||||
import SideBar from '../../../layouts/sidebar/sidebar.vue'
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { adminService } from '../../../services/adminService'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Promo',
|
||||
|
||||
components: {
|
||||
Header,
|
||||
SideBar,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
token: null,
|
||||
user: null,
|
||||
promos: [],
|
||||
// Types
|
||||
interface Promo {
|
||||
id: number
|
||||
name_of_promotion: string
|
||||
description: string
|
||||
money_off_delivery: string | number
|
||||
text_on_ticket: string
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.userStatus()
|
||||
},
|
||||
mounted() {
|
||||
this.promos = [];
|
||||
this.getpromos()
|
||||
},
|
||||
methods: {
|
||||
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
// State
|
||||
const promos = ref<Promo[]>([])
|
||||
|
||||
getpromos() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/promo/all';
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
if (response.data && response.data.promos) {
|
||||
this.promos = response.data.promos;
|
||||
// Methods
|
||||
async function getPromos() {
|
||||
try {
|
||||
const response = await adminService.promos.getAll()
|
||||
if (response.data && (response.data as any).promos) {
|
||||
promos.value = (response.data as any).promos
|
||||
} else {
|
||||
this.promos = [];
|
||||
promos.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch promos:', error)
|
||||
promos.value = []
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
deletepromo(promo_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/promo/delete/' + promo_id;
|
||||
axios({
|
||||
method: 'delete',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
async function deletePromo(promoId: number) {
|
||||
try {
|
||||
const response = await adminService.promos.delete(promoId)
|
||||
if ((response.data as any).ok) {
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "deleted promo ",
|
||||
text: "Deleted promo",
|
||||
type: "success",
|
||||
});
|
||||
this.getpromos()
|
||||
})
|
||||
getPromos()
|
||||
} else {
|
||||
notify({
|
||||
title: "Failure",
|
||||
text: "error deleting promo",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
text: "Error deleting promo",
|
||||
type: "error",
|
||||
})
|
||||
},
|
||||
|
||||
turnonpromo(promo_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/promo/on/' + promo_id;
|
||||
axios({
|
||||
method: 'patch',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Promo is now online for all deliveries ",
|
||||
type: "success",
|
||||
});
|
||||
this.getpromos()
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Failure",
|
||||
text: "error adding promo",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
text: "Error deleting promo",
|
||||
type: "error",
|
||||
})
|
||||
},
|
||||
|
||||
turnoffpromo(promo_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/promo/off/' + promo_id;
|
||||
axios({
|
||||
method: 'patch',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Success",
|
||||
text: "Promo is now offline for all deliveries ",
|
||||
type: "success",
|
||||
});
|
||||
this.getpromos()
|
||||
} else {
|
||||
notify({
|
||||
title: "Failure",
|
||||
text: "error adding promo",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
getPromos()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2">Enter New Password</label>
|
||||
<input v-model="ChangePasswordForm.new_password" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="password" type="password" placeholder="Password" :class="{ 'input-error': v$.ChangePasswordForm.new_password.$error }" />
|
||||
<span v-if="v$.ChangePasswordForm.new_password.$error" class="text-red-600 text-center">
|
||||
{{ v$.ChangePasswordForm.new_password.$errors[0].$message }}
|
||||
<input v-model="form.new_password" class="rounded w-full py-2 px-3 input-primary text-black" id="password"
|
||||
type="password" placeholder="Password" :class="{ 'input-error': v$.form.new_password.$error }" />
|
||||
<span v-if="v$.form.new_password.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.new_password.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-white text-sm font-bold mb-2">Confirm New Password</label>
|
||||
<input v-model="ChangePasswordForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password" :class="{ 'input-error': v$.ChangePasswordForm.password_confirm.$error }" />
|
||||
<span v-if="v$.ChangePasswordForm.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }}
|
||||
<input v-model="form.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password"
|
||||
:class="{ 'input-error': v$.form.password_confirm.$error }" />
|
||||
<span v-if="v$.form.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.password_confirm.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
@@ -38,118 +39,95 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import axios from "axios";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { required, minLength, helpers } from "@vuelidate/validators";
|
||||
import Header from "../../layouts/headers/headerauth.vue";
|
||||
import authHeader from "../../services/auth.header";
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, minLength, helpers } from "@vuelidate/validators"
|
||||
import { authService } from '../../services/authService'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export default defineComponent({
|
||||
name: "changePassword",
|
||||
components: {
|
||||
Header,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
user_admin: 0,
|
||||
loaded: false,
|
||||
ChangePasswordForm: {
|
||||
new_password: "",
|
||||
password_confirm: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
validations () {
|
||||
return {
|
||||
ChangePasswordForm: {
|
||||
// Form state
|
||||
const form = reactive({
|
||||
new_password: '',
|
||||
password_confirm: '',
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
new_password: { required, minLength: minLength(6) },
|
||||
password_confirm: { required, minLength: minLength(6), sameAsPassword: helpers.withMessage('Passwords must match', (value: string) => value === this.ChangePasswordForm.new_password) },
|
||||
password_confirm: {
|
||||
required,
|
||||
minLength: minLength(6),
|
||||
sameAsPassword: helpers.withMessage('Passwords must match', (value: string) => value === form.new_password)
|
||||
},
|
||||
};
|
||||
},
|
||||
created () {
|
||||
this.userStatus();
|
||||
},
|
||||
}))
|
||||
|
||||
methods: {
|
||||
userStatus () {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response:any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
async function checkUserStatus() {
|
||||
try {
|
||||
const response = await authService.whoami()
|
||||
if (!(response.data as any).ok) {
|
||||
router.push({ name: "login" })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.$router.push({ name: "login" });
|
||||
});
|
||||
},
|
||||
|
||||
sendWordRequest (payLoad: { new_password: string; password_confirm: string }) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/auth/change-password";
|
||||
axios({
|
||||
method: "post",
|
||||
url: path,
|
||||
data: payLoad,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
}).then((response:any) => {
|
||||
console.log(response)
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Password changed",
|
||||
type: "success",
|
||||
});
|
||||
this.$router.push({ name: "home" });
|
||||
} catch (error) {
|
||||
router.push({ name: "login" })
|
||||
}
|
||||
if (response.data.error) {
|
||||
notify({
|
||||
title: "Authorization Error",
|
||||
text: response.data.error,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Invalid Credentials.",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
onSubmit () {
|
||||
const payLoad = {
|
||||
|
||||
new_password: this.ChangePasswordForm.new_password,
|
||||
password_confirm: this.ChangePasswordForm.password_confirm,
|
||||
};
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
|
||||
this.v$.$validate(); // checks all inputs
|
||||
if (this.v$.$invalid) {
|
||||
if (!isValid) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Failure",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
|
||||
this.sendWordRequest(payLoad);
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await authService.changePassword({
|
||||
old_password: '', // Note: this endpoint might need adjustment based on actual API
|
||||
new_password: form.new_password,
|
||||
})
|
||||
|
||||
const data = response.data as any
|
||||
|
||||
if (data.ok) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Password changed",
|
||||
type: "success",
|
||||
})
|
||||
router.push({ name: "home" })
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
notify({
|
||||
title: "Authorization Error",
|
||||
text: data.error,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Invalid Credentials.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
checkUserStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -24,17 +24,17 @@
|
||||
In order to unlock your account, please enter your username/email below.
|
||||
</div>
|
||||
<div class="my-5">
|
||||
<input v-model="ForgotForm.username" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
||||
autocomplete="off" placeholder="Username" :class="{ 'input-error': v$.ForgotForm.username.$error }" />
|
||||
<span v-if="v$.ForgotForm.username.$error" class="text-red-600 text-center">
|
||||
{{ v$.ForgotForm.username.$errors[0].$message }}
|
||||
<input v-model="form.username" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
||||
autocomplete="off" placeholder="Username" :class="{ 'input-error': v$.form.username.$error }" />
|
||||
<span v-if="v$.form.username.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.username.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="my-5">
|
||||
<input v-model="ForgotForm.email" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
||||
autocomplete="off" placeholder="Email" :class="{ 'input-error': v$.ForgotForm.email.$error }" />
|
||||
<span v-if="v$.ForgotForm.email.$error" class="text-red-600 text-center">
|
||||
{{ v$.ForgotForm.email.$errors[0].$message }}
|
||||
<input v-model="form.email" class="rounded w-full py-2 px-3 input-primary text-black" type="text"
|
||||
autocomplete="off" placeholder="Email" :class="{ 'input-error': v$.form.email.$error }" />
|
||||
<span v-if="v$.form.email.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.email.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex p-md justify-center">
|
||||
@@ -49,83 +49,66 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import axios from "axios"
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import useValidate from "@vuelidate/core"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required } from "@vuelidate/validators"
|
||||
import Header from "../../layouts/headers/headernoauth.vue"
|
||||
import { authService } from '../../services/authService'
|
||||
|
||||
export default defineComponent({
|
||||
name: "lostPassword",
|
||||
components: {
|
||||
Header,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
ForgotForm: {
|
||||
email: "",
|
||||
username: "",
|
||||
},
|
||||
}
|
||||
},
|
||||
validations () {
|
||||
return {
|
||||
ForgotForm: {
|
||||
const router = useRouter()
|
||||
|
||||
// Form state
|
||||
const form = reactive({
|
||||
email: '',
|
||||
username: '',
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
email: { required },
|
||||
username: { required },
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
methods: {
|
||||
sendWordRequest (payLoad: {
|
||||
email: string;
|
||||
username: string;
|
||||
}) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/auth/unlock-account"
|
||||
axios({
|
||||
method: "post",
|
||||
url: path,
|
||||
data: payLoad,
|
||||
})
|
||||
.then((response:any) => {
|
||||
if (response.data.ok) {
|
||||
localStorage.setItem("auth_token", response.data.token);
|
||||
localStorage.setItem("auth_user", response.data.user);
|
||||
this.$router.push({ name: "changePassword" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Error",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
onSubmit () {
|
||||
const payLoad = {
|
||||
email: this.ForgotForm.email,
|
||||
username: this.ForgotForm.username,
|
||||
}
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
this.v$.$validate(); // checks all inputs
|
||||
if (this.v$.$invalid) {
|
||||
// Methods
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
|
||||
if (!isValid) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Failure",
|
||||
type: "error",
|
||||
});
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authService.unlockAccount({
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
})
|
||||
|
||||
const data = response.data as any
|
||||
|
||||
if (data.ok) {
|
||||
localStorage.setItem("auth_token", data.token)
|
||||
localStorage.setItem("auth_user", data.user)
|
||||
router.push({ name: "changePassword" })
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Error",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
else {
|
||||
this.sendWordRequest(payLoad);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style type="ts" scoped></style>
|
||||
<style scoped></style>
|
||||
@@ -8,35 +8,37 @@
|
||||
<div class="mb-4 text-center text-[28px] ">Register</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2" for="username">Username</label>
|
||||
<input v-model="registerForm.username" class="rounded w-full py-2 px-3 input-primary text-black" id="username"
|
||||
type="text" placeholder="Login Username" :class="{ 'input-error': v$.registerForm.username.$error }" />
|
||||
<span v-if="v$.registerForm.username.$error" class="text-red-600 text-center">
|
||||
{{ v$.registerForm.username.$errors[0].$message }}
|
||||
<input v-model="form.username" class="rounded w-full py-2 px-3 input-primary text-black" id="username"
|
||||
type="text" placeholder="Login Username" :class="{ 'input-error': v$.form.username.$error }" />
|
||||
<span v-if="v$.form.username.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.username.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2" for="username">Email</label>
|
||||
<input v-model="registerForm.email" class="rounded w-full py-2 px-3 input-primary text-black" id="email"
|
||||
type="text" placeholder="Email" :class="{ 'input-error': v$.registerForm.email.$error }" />
|
||||
<span v-if="v$.registerForm.email.$error" class="text-red-600 text-center">
|
||||
{{ v$.registerForm.email.$errors[0].$message }}
|
||||
<label class="block text-white text-sm font-bold mb-2" for="email">Email</label>
|
||||
<input v-model="form.email" class="rounded w-full py-2 px-3 input-primary text-black" id="email" type="text"
|
||||
placeholder="Email" :class="{ 'input-error': v$.form.email.$error }" />
|
||||
<span v-if="v$.form.email.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.email.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2" for="password">Password</label>
|
||||
<input v-model="registerForm.password" class="rounded w-full py-2 px-3 input-primary text-black" id="password"
|
||||
type="password" autocomplete="off" placeholder="Password" :class="{ 'input-error': v$.registerForm.password.$error }" />
|
||||
<span v-if="v$.registerForm.password.$error" class="text-red-600 text-center">
|
||||
{{ v$.registerForm.password.$errors[0].$message }}
|
||||
<input v-model="form.password" class="rounded w-full py-2 px-3 input-primary text-black" id="password"
|
||||
type="password" autocomplete="off" placeholder="Password"
|
||||
:class="{ 'input-error': v$.form.password.$error }" />
|
||||
<span v-if="v$.form.password.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.password.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2" for="password_confirm">Confirm Password</label>
|
||||
<input v-model="registerForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="password" type="password" autocomplete="off" placeholder="Confirm Password" :class="{ 'input-error': v$.registerForm.password_confirm.$error }" />
|
||||
<span v-if="v$.registerForm.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.registerForm.password_confirm.$errors[0].$message }}
|
||||
<input v-model="form.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="password_confirm" type="password" autocomplete="off" placeholder="Confirm Password"
|
||||
:class="{ 'input-error': v$.form.password_confirm.$error }" />
|
||||
<span v-if="v$.form.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.password_confirm.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -61,104 +63,89 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import axios from "axios";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, email, minLength, sameAs } from "@vuelidate/validators";
|
||||
import Header from "../../layouts/headers/headernoauth.vue";
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, email, minLength, sameAs } from "@vuelidate/validators"
|
||||
import { authService } from '../../services/authService'
|
||||
|
||||
export default defineComponent({
|
||||
name: "Register",
|
||||
components: { Header },
|
||||
data () {
|
||||
return {
|
||||
v$: useVuelidate(),
|
||||
isAuthenticated: false,
|
||||
const router = useRouter()
|
||||
|
||||
registerForm: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
password_confirm: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
// Form state
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
})
|
||||
|
||||
},
|
||||
validations () {
|
||||
return {
|
||||
registerForm: {
|
||||
password: { required, minLength: minLength(6) },
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
username: { required, minLength: minLength(6) },
|
||||
email: { email, required },
|
||||
password: { required, minLength: minLength(6) },
|
||||
password_confirm: {
|
||||
required,
|
||||
minLength: minLength(6),
|
||||
sameAs: sameAs(this.registerForm.password),
|
||||
sameAs: sameAs(form.password),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit () {
|
||||
const payLoad = {
|
||||
username: this.registerForm.username,
|
||||
password: this.registerForm.password,
|
||||
email: this.registerForm.email,
|
||||
};
|
||||
this.v$.$validate(); // checks all inputs
|
||||
if (this.v$.$invalid) {
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
|
||||
if (!isValid) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Failure; Fields must be filled our correctly.",
|
||||
text: "Form Failure; Fields must be filled out correctly.",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
this.Register(payLoad);
|
||||
}
|
||||
},
|
||||
Register (payLoad: {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
|
||||
}) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/auth/register";
|
||||
axios({
|
||||
method: "post",
|
||||
url: path,
|
||||
data: payLoad,
|
||||
withCredentials: true,
|
||||
})
|
||||
.then((response:any) => {
|
||||
if (response.data.error) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authService.register({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
email: form.email,
|
||||
})
|
||||
|
||||
const data = response.data as any
|
||||
|
||||
if (data.error) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: response.data.error,
|
||||
text: data.error,
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.data.ok) {
|
||||
|
||||
localStorage.setItem("auth_user", response.data.user);
|
||||
localStorage.setItem("auth_token", response.data.token);
|
||||
|
||||
this.$router.push({ name: "home" });
|
||||
if (data.ok) {
|
||||
localStorage.setItem("auth_user", data.user)
|
||||
localStorage.setItem("auth_token", data.token)
|
||||
router.push({ name: "home" })
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Success",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Registration failed. Please try again.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -26,12 +26,31 @@
|
||||
<p class="text-base-content/60 mt-1 ml-13">Manage automatic delivery customers and schedules</p>
|
||||
</div>
|
||||
|
||||
<!-- NEW: Search Bar -->
|
||||
<div class="form-control w-full lg:w-auto">
|
||||
<div class="input-group">
|
||||
<input v-model="searchQuery" type="text" placeholder="Search customer, town, address..."
|
||||
class="input input-bordered w-full lg:w-80" />
|
||||
<button class="btn btn-square">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="stat-pill">
|
||||
<span class="stat-pill-value">{{ deliveries.length }}</span>
|
||||
<span class="stat-pill-value">{{ sortedDeliveries.length }}</span>
|
||||
<span class="stat-pill-label">Customers</span>
|
||||
</div>
|
||||
<div class="stat-pill" v-if="urgentCount > 0">
|
||||
<span class="stat-pill-value text-error">{{ urgentCount }}</span>
|
||||
<span class="stat-pill-label">Urgent (<=7 days)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,57 +60,58 @@
|
||||
<!-- Data Display -->
|
||||
<div>
|
||||
<!-- DESKTOP VIEW: Sortable Table -->
|
||||
<div class="overflow-x-auto hidden xl:block">
|
||||
<table class="modern-table">
|
||||
<div class="hidden lg:block w-full">
|
||||
<table class="modern-table w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- SORTABLE HEADERS -->
|
||||
<th @click="sortBy('tank_level_percent')" class="sort-header cursor-pointer select-none"
|
||||
<th @click="sortBy('tank_level_percent')" class="cursor-pointer select-none whitespace-nowrap w-[15%]"
|
||||
:class="{ 'text-primary': sortKey === 'tank_level_percent' }">
|
||||
<div class="flex items-center gap-2">
|
||||
Tank Level
|
||||
<span v-if="sortKey === 'tank_level_percent'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||
</div>
|
||||
<span v-if="sortKey === 'tank_level_percent'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th @click="sortBy('days_since_last_fill')" class="sort-header cursor-pointer select-none"
|
||||
<th @click="sortBy('days_since_last_fill')" class="cursor-pointer select-none whitespace-nowrap w-[10%]"
|
||||
:class="{ 'text-primary': sortKey === 'days_since_last_fill' }">
|
||||
<div class="flex items-center gap-2">
|
||||
Days Since Fill
|
||||
<span v-if="sortKey === 'days_since_last_fill'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||
</div>
|
||||
<span v-if="sortKey === 'days_since_last_fill'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th @click="sortBy('customer_full_name')" class="sort-header cursor-pointer select-none"
|
||||
<th @click="sortBy('customer_full_name')" class="cursor-pointer select-none whitespace-nowrap w-[15%]"
|
||||
:class="{ 'text-primary': sortKey === 'customer_full_name' }">
|
||||
<div class="flex items-center gap-2">
|
||||
Name
|
||||
<span v-if="sortKey === 'customer_full_name'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||
</div>
|
||||
Customer
|
||||
<span v-if="sortKey === 'customer_full_name'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th @click="sortBy('house_factor')" class="sort-header cursor-pointer select-none"
|
||||
<th @click="sortBy('house_factor')" class="cursor-pointer select-none whitespace-nowrap w-[8%]"
|
||||
:class="{ 'text-primary': sortKey === 'house_factor' }">
|
||||
<div class="flex items-center gap-2">
|
||||
Usage Factor
|
||||
<span v-if="sortKey === 'house_factor'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||
</div>
|
||||
Usage
|
||||
<span v-if="sortKey === 'house_factor'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th @click="sortBy('hot_water_summer')" class="sort-header cursor-pointer select-none"
|
||||
<th @click="sortBy('confidence_score')" class="cursor-pointer select-none whitespace-nowrap w-[8%] hidden xl:table-cell"
|
||||
:class="{ 'text-primary': sortKey === 'confidence_score' }">
|
||||
Confidence
|
||||
<span v-if="sortKey === 'confidence_score'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th @click="sortBy('days_remaining')" class="cursor-pointer select-none whitespace-nowrap w-[8%]"
|
||||
:class="{ 'text-primary': sortKey === 'days_remaining' }">
|
||||
Days Left
|
||||
<span v-if="sortKey === 'days_remaining'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th @click="sortBy('hot_water_summer')" class="cursor-pointer select-none whitespace-nowrap w-[5%] hidden xl:table-cell"
|
||||
:class="{ 'text-primary': sortKey === 'hot_water_summer' }">
|
||||
<div class="flex items-center gap-2">
|
||||
Hot Water Tank
|
||||
<span v-if="sortKey === 'hot_water_summer'">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="opacity-30 text-xs">⇵</span>
|
||||
</div>
|
||||
HW Tank
|
||||
<span v-if="sortKey === 'hot_water_summer'" class="ml-1">{{ sortAsc ? '▲' : '▼' }}</span>
|
||||
<span v-else class="ml-1 opacity-30 text-xs">⇵</span>
|
||||
</th>
|
||||
<th>Address</th>
|
||||
<th class="text-right">Actions</th>
|
||||
<th class="w-auto">Address</th>
|
||||
<th class="text-right w-[140px]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Loop over the new 'sortedDeliveries' computed property -->
|
||||
<tr v-for="oil in sortedDeliveries" :key="oil.id" class="table-row-hover"
|
||||
:class="{ 'row-urgent': oil.auto_status == 3 }">
|
||||
<td>
|
||||
@@ -108,32 +128,62 @@
|
||||
<td>{{ oil.days_since_last_fill }} days</td>
|
||||
<td>
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: oil.customer_id } }"
|
||||
class="link link-hover">
|
||||
class="group">
|
||||
<div class="font-bold text-base group-hover:text-primary transition-colors truncate">
|
||||
{{ oil.customer_full_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.account_number }}
|
||||
</div>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ oil.house_factor }}</td>
|
||||
<td>{{ oil.hot_water_summer ? 'Yes' : 'No' }}</td>
|
||||
<td>{{ oil.customer_address }}, {{ oil.customer_town }}</td>
|
||||
<td>
|
||||
<span class="font-mono">{{ Number(oil.house_factor).toFixed(4) }}</span>
|
||||
</td>
|
||||
<td class="hidden xl:table-cell">
|
||||
<span class="badge badge-sm" :class="getConfidenceBadge(oil.confidence_score)">
|
||||
{{ oil.confidence_score ?? 20 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<span v-if="oil.days_remaining >= 999" class="text-base-content/40">N/A</span>
|
||||
<span v-else class="font-bold" :class="{
|
||||
'text-error': oil.days_remaining <= 7,
|
||||
'text-warning': oil.days_remaining > 7 && oil.days_remaining <= 14,
|
||||
'text-success': oil.days_remaining > 14
|
||||
}">{{ oil.days_remaining }}d</span>
|
||||
<div v-if="oil.gallons_per_day > 0" class="text-xs opacity-50">{{ oil.gallons_per_day }} gal/day</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden xl:table-cell">{{ oil.hot_water_summer ? 'Yes' : 'No' }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm truncate" :title="oil.customer_address">{{ oil.customer_address }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs opacity-70 truncate">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ oil.customer_zip }}</span>
|
||||
<a :href="getMapLink(oil)" 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 class="text-right">
|
||||
|
||||
<!-- <router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }" class="btn btn-sm btn-secondary">Edit Customer</router-link> -->
|
||||
<router-link v-if="oil.auto_status != 3"
|
||||
:to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }">
|
||||
<button class="btn btn-primary btn-sm">Preauthorize</button>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }">
|
||||
<button class="btn btn-secondary btn-sm">Finalize</button>
|
||||
</router-link>
|
||||
<router-link v-if="oil.auto_status == 3"
|
||||
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }">
|
||||
<button class="btn btn-secondary btn-sm">Finalize</button>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }">
|
||||
<button class="btn btn-success btn-sm">
|
||||
Print Ticket
|
||||
</button>
|
||||
</router-link>
|
||||
<div class="grid grid-cols-2 gap-1 justify-items-end">
|
||||
<router-link v-if="oil.auto_status != 3" :to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }" class="btn btn-xs btn-warning btn-outline w-full">Auth</router-link>
|
||||
<router-link v-if="oil.auto_status == 3" :to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }" class="btn btn-xs btn-accent btn-outline w-full">Final</router-link>
|
||||
<router-link v-else :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }" class="btn btn-xs btn-accent btn-outline w-full">Final</router-link>
|
||||
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }" class="btn btn-xs btn-success btn-outline w-full">Print</router-link>
|
||||
<router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }" class="btn btn-xs btn-info btn-outline w-full">Edit</router-link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -141,14 +191,23 @@
|
||||
</div>
|
||||
|
||||
<!-- MOBILE VIEW: Cards -->
|
||||
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||
<div class="lg:hidden space-y-4 px-4 pb-4">
|
||||
<div v-for="oil in sortedDeliveries" :key="oil.id" class="mobile-card"
|
||||
:class="{ 'mobile-card-urgent': oil.auto_status == 3 }">
|
||||
<div class="p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-base font-bold">{{ oil.customer_full_name }}</h2>
|
||||
<p class="text-xs text-base-content/60">{{ oil.customer_address }}, {{ oil.customer_town }}</p>
|
||||
<div class="flex items-start gap-1 mt-1">
|
||||
<a :href="getMapLink(oil)" target="_blank" class="text-primary mt-0.5" title="View on Map">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<p class="text-xs text-base-content/60">{{ formatAddressStr(oil) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-bold">{{ oil.days_since_last_fill }}</div>
|
||||
@@ -160,12 +219,27 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Usage Factor</p>
|
||||
<div class="text-sm font-medium">{{ oil.house_factor }}</div>
|
||||
<div class="text-sm font-medium font-mono">{{ Number(oil.house_factor).toFixed(4) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Hot Water Tank</p>
|
||||
<div class="text-sm font-medium">{{ oil.hot_water_summer ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Confidence</p>
|
||||
<span class="badge badge-sm" :class="getConfidenceBadge(oil.confidence_score)">
|
||||
{{ oil.confidence_score ?? 20 }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Days Remaining</p>
|
||||
<div v-if="oil.days_remaining >= 999" class="text-sm text-base-content/40">N/A</div>
|
||||
<div v-else class="text-sm font-bold" :class="{
|
||||
'text-error': oil.days_remaining <= 7,
|
||||
'text-warning': oil.days_remaining > 7 && oil.days_remaining <= 14,
|
||||
'text-success': oil.days_remaining > 14
|
||||
}">{{ oil.days_remaining }} days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p class="text-xs text-base-content/50 mb-1">Tank Level</p>
|
||||
@@ -185,18 +259,18 @@
|
||||
|
||||
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||
<router-link :to="{ name: 'customerEdit', params: { id: oil.customer_id } }"
|
||||
class="btn btn-sm btn-ghost flex-1">Edit</router-link>
|
||||
class="btn btn-sm btn-info btn-outline flex-1">Edit</router-link>
|
||||
<router-link v-if="oil.auto_status != 3" :to="{ name: 'payAutoAuthorize', params: { id: oil['id'] } }"
|
||||
class="flex-1">
|
||||
<button class="btn btn-primary btn-sm btn-outline w-full">Auth</button>
|
||||
<button class="btn btn-warning btn-sm btn-outline w-full">Auth</button>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: oil['id'] } }" class="flex-1">
|
||||
<button class="btn btn-secondary btn-sm btn-outline w-full">Finalize</button>
|
||||
<button class="btn btn-accent btn-sm btn-outline w-full">Finalize</button>
|
||||
</router-link>
|
||||
<router-link v-if="oil.auto_status == 3"
|
||||
:to="{ name: 'finalizeTicketAuto', params: { id: oil.open_ticket_id || oil['id'] } }"
|
||||
class="flex-1">
|
||||
<button class="btn btn-secondary btn-sm btn-outline w-full">Finalize</button>
|
||||
<button class="btn btn-accent btn-sm btn-outline w-full">Finalize</button>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'TicketAuto', params: { id: oil['id'] } }" class="flex-1">
|
||||
<button class="btn btn-success btn-sm btn-outline w-full">Ticket</button>
|
||||
@@ -216,19 +290,38 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import { AutoDelivery } from '../../types/models'
|
||||
import { formatAddress, getGoogleMapsLink } from '../../utils/addressUtils'
|
||||
|
||||
// Reactive data
|
||||
const user = ref(null)
|
||||
const deliveries = ref<AutoDelivery[]>([])
|
||||
// --- NEW: Data properties for sorting ---
|
||||
const sortKey = ref<'tank_level_percent' | 'hot_water_summer' | keyof AutoDelivery>('tank_level_percent')
|
||||
const sortKey = ref<'tank_level_percent' | 'hot_water_summer' | 'confidence_score' | 'days_remaining' | keyof AutoDelivery>('tank_level_percent')
|
||||
const sortAsc = ref(true)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Computed properties
|
||||
// --- NEW: Computed property to handle sorting ---
|
||||
// Computed: urgent count (<=7 days remaining)
|
||||
const urgentCount = computed((): number => {
|
||||
return deliveries.value.filter(d => d.days_remaining != null && d.days_remaining <= 7 && d.days_remaining < 999).length
|
||||
})
|
||||
|
||||
// Computed property to handle sorting AND searching
|
||||
const sortedDeliveries = computed((): AutoDelivery[] => {
|
||||
// First, filter by search query
|
||||
let filtered = deliveries.value;
|
||||
|
||||
if (searchQuery.value && searchQuery.value.trim() !== '') {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
filtered = filtered.filter(item => {
|
||||
const name = item.customer_full_name?.toLowerCase() || '';
|
||||
const town = item.customer_town?.toLowerCase() || '';
|
||||
const addr = item.customer_address?.toLowerCase() || '';
|
||||
return name.includes(query) || town.includes(query) || addr.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a copy to avoid mutating the original array
|
||||
const sorted = [...deliveries.value];
|
||||
const sorted = [...filtered];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
// First, prioritize auto_status = 3 to be at the top
|
||||
@@ -250,7 +343,6 @@ const sortedDeliveries = computed((): AutoDelivery[] => {
|
||||
} else {
|
||||
valA = a[sortKey.value as keyof AutoDelivery];
|
||||
valB = b[sortKey.value as keyof AutoDelivery];
|
||||
// Special handling for hot_water_summer to ensure it's number
|
||||
if (sortKey.value === 'hot_water_summer') {
|
||||
valA = valA || 0;
|
||||
valB = valB || 0;
|
||||
@@ -258,8 +350,8 @@ const sortedDeliveries = computed((): AutoDelivery[] => {
|
||||
}
|
||||
|
||||
// Handle nulls or different types if necessary
|
||||
if (valA === null) return 1;
|
||||
if (valB === null) return -1;
|
||||
if (valA === null || valA === undefined) return 1;
|
||||
if (valB === null || valB === undefined) return -1;
|
||||
|
||||
// Comparison logic
|
||||
if (valA < valB) {
|
||||
@@ -274,6 +366,22 @@ const sortedDeliveries = computed((): AutoDelivery[] => {
|
||||
return sorted;
|
||||
})
|
||||
|
||||
// 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();
|
||||
@@ -281,26 +389,37 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
// Functions
|
||||
// --- NEW: Method to handle sorting ---
|
||||
const sortBy = (key: keyof AutoDelivery | 'tank_level_percent' | 'hot_water_summer') => {
|
||||
const sortBy = (key: keyof AutoDelivery | 'tank_level_percent' | 'hot_water_summer' | 'confidence_score' | 'days_remaining') => {
|
||||
if (sortKey.value === key) {
|
||||
// If clicking the same key, reverse the direction
|
||||
sortAsc.value = !sortAsc.value;
|
||||
} else {
|
||||
// If clicking a new key, set it and default to ascending
|
||||
sortKey.value = key;
|
||||
sortAsc.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- NEW: Helper method for percentage calculation ---
|
||||
const getTankLevelPercentage = (oil: AutoDelivery): number => {
|
||||
if (!oil.tank_size || oil.tank_size === 0 || oil.last_fill === null) {
|
||||
return 0; // Return 0 if tank size is invalid or it's a new customer
|
||||
return 0;
|
||||
}
|
||||
return (oil.estimated_gallons_left / oil.tank_size) * 100;
|
||||
}
|
||||
|
||||
const getConfidenceBadge = (score: number | null | undefined): string => {
|
||||
const s = score ?? 20
|
||||
if (s >= 70) return 'badge-success'
|
||||
if (s >= 40) return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
|
||||
const formatAddressStr = (oil: AutoDelivery): string => {
|
||||
return formatAddress(oil.customer_address, oil.customer_town, oil.customer_state, oil.customer_zip);
|
||||
}
|
||||
|
||||
const getMapLink = (oil: AutoDelivery): string => {
|
||||
return getGoogleMapsLink(oil.customer_address, oil.customer_town, oil.customer_state, oil.customer_zip);
|
||||
}
|
||||
|
||||
const userStatus = () => {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
|
||||
@@ -29,26 +29,33 @@
|
||||
<div class="text-xl font-bold">{{ autoTicket.customer_full_name }}</div>
|
||||
<div class="text-sm text-gray-400">Account: {{ autoTicket.account_number }}</div>
|
||||
</div>
|
||||
<router-link v-if="customer && customer.id" :to="{ name: 'customerProfile', params: { id: customer.id } }" class="btn btn-secondary btn-sm">
|
||||
<router-link v-if="customer && customer.id" :to="{ name: 'customerProfile', params: { id: customer.id } }"
|
||||
class="btn btn-secondary btn-sm">
|
||||
View Profile
|
||||
</router-link>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-start gap-2">
|
||||
<a :href="getMapLink(autoTicket)" target="_blank"
|
||||
class="btn btn-ghost btn-xs btn-circle text-primary mt-1" title="View on Map">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<div>{{ autoTicket.customer_address }}</div>
|
||||
<div v-if="autoTicket.customer_apt && autoTicket.customer_apt !== 'None'">Apt: {{ autoTicket.customer_apt }}</div>
|
||||
<div v-if="autoTicket.customer_apt && autoTicket.customer_apt !== 'None'">Apt: {{
|
||||
autoTicket.customer_apt }}</div>
|
||||
<div>
|
||||
{{ autoTicket.customer_town }},
|
||||
<span v-if="autoTicket.customer_state == 0">Massachusetts</span>
|
||||
<span v-else-if="autoTicket.customer_state == 1">Rhode Island</span>
|
||||
<span v-else-if="autoTicket.customer_state == 2">New Hampshire</span>
|
||||
<span v-else-if="autoTicket.customer_state == 3">Maine</span>
|
||||
<span v-else-if="autoTicket.customer_state == 4">Vermont</span>
|
||||
<span v-else-if="autoTicket.customer_state == 5">Connecticut</span>
|
||||
<span v-else-if="autoTicket.customer_state == 6">New York</span>
|
||||
<span v-else>Unknown state</span>
|
||||
<span>{{ getStateName(autoTicket.customer_state) }}</span>
|
||||
{{ autoTicket.customer_zip }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mt-1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mt-1 ml-8">
|
||||
Auto Delivery
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,14 +66,14 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<div class="font-bold text-sm">Payment Status</div>
|
||||
<div class="badge badge-lg"
|
||||
:class="{
|
||||
<div class="badge badge-lg" :class="{
|
||||
'badge-success': autoTicket.payment_status === PAYMENT_STATUS.PAID,
|
||||
'badge-info': autoTicket.payment_status === PAYMENT_STATUS.PRE_AUTHORIZED,
|
||||
'badge-error': autoTicket.payment_status === PAYMENT_STATUS.UNPAID || autoTicket.payment_status == null,
|
||||
'badge-warning': [PAYMENT_STATUS.PROCESSING, PAYMENT_STATUS.FAILED].includes(autoTicket.payment_status)
|
||||
}">
|
||||
<span v-if="autoTicket.payment_status === PAYMENT_STATUS.UNPAID || autoTicket.payment_status == null">Unpaid</span>
|
||||
<span
|
||||
v-if="autoTicket.payment_status === PAYMENT_STATUS.UNPAID || autoTicket.payment_status == null">Unpaid</span>
|
||||
<span v-else-if="autoTicket.payment_status === PAYMENT_STATUS.PRE_AUTHORIZED">Pre-authorized</span>
|
||||
<span v-else-if="autoTicket.payment_status === PAYMENT_STATUS.PROCESSING">Processing</span>
|
||||
<span v-else-if="autoTicket.payment_status === PAYMENT_STATUS.PAID">Paid</span>
|
||||
@@ -141,7 +148,8 @@
|
||||
<div class="flex justify-between">
|
||||
<span>Type:</span>
|
||||
<span :class="getTypeColor(transaction.transaction_type)">
|
||||
{{ transaction.transaction_type === 0 ? 'Charge' : transaction.transaction_type === 1 ? 'Auth' : transaction.transaction_type === 2 ? 'Capture' : 'Other' }}
|
||||
{{ transaction.transaction_type === 0 ? 'Charge' : transaction.transaction_type === 1 ? 'Auth' :
|
||||
transaction.transaction_type === 2 ? 'Capture' : 'Other' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -214,7 +222,8 @@
|
||||
<p>{{ userCard.card_number }}</p>
|
||||
<p>
|
||||
Exp:
|
||||
<span v-if="Number(userCard.expiration_month) < 10">0</span>{{ userCard.expiration_month }} / {{ userCard.expiration_year }}
|
||||
<span v-if="Number(userCard.expiration_month) < 10">0</span>{{ userCard.expiration_month }} / {{
|
||||
userCard.expiration_year }}
|
||||
</p>
|
||||
<p>CVV: {{ userCard.security_number }}</p>
|
||||
</div>
|
||||
@@ -226,7 +235,8 @@
|
||||
<div class="p-4 border rounded-md">
|
||||
<label class="label-text font-bold">Notes</label>
|
||||
<div class="prose prose-sm mt-4 max-w-none">
|
||||
<blockquote class="text-gray-400">Auto delivery processed automatically based on tank levels.</blockquote>
|
||||
<blockquote class="text-gray-400">Auto delivery processed automatically based on tank levels.
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,6 +259,8 @@ import {
|
||||
getPaymentStatusLabel,
|
||||
getTransactionStatusLabel
|
||||
} from '../../constants/status';
|
||||
import { STATE_ID_TO_NAME } from '../../constants/states';
|
||||
import { getGoogleMapsLink } from '../../utils/addressUtils';
|
||||
|
||||
import Header from '../../layouts/headers/headerauth.vue'
|
||||
import SideBar from '../../layouts/sidebar/sidebar.vue'
|
||||
@@ -294,6 +306,20 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Helper methods for template
|
||||
getStateName(stateId: number) {
|
||||
return STATE_ID_TO_NAME[stateId] || 'Unknown state';
|
||||
},
|
||||
getMapLink(ticket: any) {
|
||||
if (!ticket) return '#';
|
||||
return getGoogleMapsLink(
|
||||
ticket.customer_address,
|
||||
ticket.customer_town,
|
||||
ticket.customer_state,
|
||||
ticket.customer_zip
|
||||
);
|
||||
},
|
||||
|
||||
format_date(value: string) {
|
||||
if (value) {
|
||||
return dayjs(String(value)).format('LLLL')
|
||||
@@ -402,5 +428,4 @@ export default defineComponent({
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<ul>
|
||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||
<li><router-link :to="{ name: 'customer' }">Customers</router-link></li>
|
||||
<li v-if="customer.id"><router-link :to="{ name: 'customerProfile', params: { id: customer.id } }">Profile</router-link></li>
|
||||
<li v-if="customer.id"><router-link
|
||||
:to="{ name: 'customerProfile', params: { id: customer.id } }">Profile</router-link></li>
|
||||
<li>Add Credit Card</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -55,66 +56,77 @@
|
||||
<!-- Name -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Name on Card</span></label>
|
||||
<input v-model="CardForm.name_on_card" type="text" placeholder="Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.name_on_card.$error }" />
|
||||
<span v-if="v$.CardForm.name_on_card.$error" class="text-red-500 text-xs mt-1">A valid name_on_card is required.</span>
|
||||
<input v-model="form.name_on_card" type="text" placeholder="Name"
|
||||
class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.form.name_on_card.$error }" />
|
||||
<span v-if="v$.form.name_on_card.$error" class="text-red-500 text-xs mt-1">A valid name_on_card is
|
||||
required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Number -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Card Number</span></label>
|
||||
<input v-model="CardForm.card_number" type="text" placeholder="•••• •••• •••• ••••" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.card_number.$error }" />
|
||||
<span v-if="v$.CardForm.card_number.$error" class="text-red-500 text-xs mt-1">A valid card number is required.</span>
|
||||
<input v-model="form.card_number" type="text" placeholder="•••• •••• •••• ••••"
|
||||
class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.form.card_number.$error }" />
|
||||
<span v-if="v$.form.card_number.$error" class="text-red-500 text-xs mt-1">A valid card number is
|
||||
required.</span>
|
||||
</div>
|
||||
|
||||
<!-- CVV (Security Code) -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">CVV</span></label>
|
||||
<input v-model="CardForm.cvv" type="text" placeholder="123" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.cvv.$error }" />
|
||||
<span v-if="v$.CardForm.cvv.$error" class="text-red-500 text-xs mt-1">CVV is required.</span>
|
||||
<input v-model="form.cvv" type="text" placeholder="123" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.cvv.$error }" />
|
||||
<span v-if="v$.form.cvv.$error" class="text-red-500 text-xs mt-1">CVV is required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Expiration -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Expiration Date</span></label>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="CardForm.expiration_month" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_month.$error }">
|
||||
<select v-model="form.expiration_month" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.expiration_month.$error }">
|
||||
<option disabled value="">Month</option>
|
||||
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
|
||||
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="CardForm.expiration_year" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_year.$error }">
|
||||
<select v-model="form.expiration_year" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.expiration_year.$error }">
|
||||
<option disabled value="">Year</option>
|
||||
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
|
||||
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear()
|
||||
+ y - 1 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<span v-if="v$.CardForm.expiration_month.$error || v$.CardForm.expiration_year.$error" class="text-red-500 text-xs mt-1">Both month and year are required.</span>
|
||||
<span v-if="v$.form.expiration_month.$error || v$.form.expiration_year.$error"
|
||||
class="text-red-500 text-xs mt-1">Both month and year are required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Type dropdown -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Card Type</span></label>
|
||||
<select v-model="CardForm.type_of_card" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.type_of_card.$error }">
|
||||
<select v-model="form.type_of_card" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.type_of_card.$error }">
|
||||
<option disabled value="">Select Type</option>
|
||||
<option>Visa</option>
|
||||
<option>MasterCard</option>
|
||||
<option>Discover</option>
|
||||
<option>American Express</option>
|
||||
</select>
|
||||
<span v-if="v$.CardForm.type_of_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<span v-if="v$.form.type_of_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Billing Zip Code input -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Billing Zip Code</span></label>
|
||||
<input v-model="CardForm.zip_code" type="text" placeholder="12345" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.zip_code.$error }" />
|
||||
<span v-if="v$.CardForm.zip_code.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.zip_code" type="text" placeholder="12345"
|
||||
class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.form.zip_code.$error }" />
|
||||
<span v-if="v$.form.zip_code.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- --- FIX: Main Card Checkbox --- -->
|
||||
<!-- Main Card Checkbox -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<span class="label-text font-bold">Set as Main Card for this customer</span>
|
||||
<!-- `v-model` binds the checkbox's checked state to the boolean `CardForm.main_card` -->
|
||||
<input v-model="CardForm.main_card" type="checkbox" class="checkbox checkbox-sm" />
|
||||
<input v-model="form.main_card" type="checkbox" class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -133,27 +145,31 @@
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { minLength, required } from "@vuelidate/validators";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AddCardCreate',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
customer: {} as any,
|
||||
isLoading: false,
|
||||
isLoadingAuthorize: true,
|
||||
authorizeCheck: { profile_exists: false, has_payment_methods: false, missing_components: [] as string[], valid_for_charging: false },
|
||||
CardForm: {
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, minLength } from "@vuelidate/validators"
|
||||
import { paymentService } from '../../services/paymentService'
|
||||
import { customerService } from '../../services/customerService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const customer = ref<any>({})
|
||||
const isLoading = ref(false)
|
||||
const isLoadingAuthorize = ref(true)
|
||||
const authorizeCheck = ref({
|
||||
profile_exists: false,
|
||||
has_payment_methods: false,
|
||||
missing_components: [] as string[],
|
||||
valid_for_charging: false
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
main_card: false,
|
||||
card_number: '',
|
||||
expiration_month: '',
|
||||
@@ -162,12 +178,11 @@ export default defineComponent({
|
||||
type_of_card: '',
|
||||
zip_code: '',
|
||||
name_on_card: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
CardForm: {
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
card_number: { required, minLength: minLength(13) },
|
||||
expiration_month: { required },
|
||||
expiration_year: { required },
|
||||
@@ -176,142 +191,112 @@ export default defineComponent({
|
||||
zip_code: { required, minLength: minLength(5) },
|
||||
name_on_card: { required },
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
this.getCustomer(this.$route.params.id);
|
||||
},
|
||||
methods: {
|
||||
userStatus() {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) { this.user = response.data.user; }
|
||||
})
|
||||
.catch(() => { this.user = null; });
|
||||
},
|
||||
async checkAuthorizeAccount() {
|
||||
if (!this.customer.id) return;
|
||||
}))
|
||||
|
||||
this.isLoadingAuthorize = true;
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
async function getCustomer(userId: string | number) {
|
||||
try {
|
||||
const response = await customerService.getById(Number(userId))
|
||||
customer.value = response.data
|
||||
checkAuthorizeAccount()
|
||||
} catch (error) {
|
||||
notify({ title: "Error", text: "Could not find customer", type: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthorizeAccount() {
|
||||
if (!customer.value.id) return
|
||||
isLoadingAuthorize.value = true
|
||||
|
||||
try {
|
||||
const path = `${import.meta.env.VITE_AUTHORIZE_URL}/user/check-authorize-account/${this.customer.id}`;
|
||||
const response = await axios.get(path, { headers: authHeader() });
|
||||
this.authorizeCheck = response.data;
|
||||
const response = await paymentService.checkAuthorizeAccount(customer.value.id)
|
||||
authorizeCheck.value = response.data as any
|
||||
} catch (error) {
|
||||
console.error("Failed to check authorize account:", error);
|
||||
notify({ title: "Error", text: "Could not check payment account status.", type: "error" });
|
||||
// Set default error state
|
||||
this.authorizeCheck = {
|
||||
console.error("Failed to check authorize account:", error)
|
||||
notify({ title: "Error", text: "Could not check payment account status.", type: "error" })
|
||||
authorizeCheck.value = {
|
||||
profile_exists: false,
|
||||
has_payment_methods: false,
|
||||
missing_components: ['api_error'],
|
||||
valid_for_charging: false
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
this.isLoadingAuthorize = false;
|
||||
isLoadingAuthorize.value = false
|
||||
}
|
||||
},
|
||||
getCustomer(user_id: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/customer/${user_id}`;
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
this.customer = response.data;
|
||||
this.checkAuthorizeAccount();
|
||||
})
|
||||
.catch(() => {
|
||||
notify({ title: "Error", text: "Could not find customer", type: "error" });
|
||||
});
|
||||
},
|
||||
async onSubmit() {
|
||||
this.v$.$validate();
|
||||
if (this.v$.$error) {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields.", type: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
if (!isValid) {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields.", type: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
// --- STEP 1: PREPARE PAYLOADS FOR BOTH SERVICES ---
|
||||
// Payload for your Flask backend (it takes all the raw details for your DB)
|
||||
isLoading.value = true
|
||||
|
||||
// Payload for Flask backend
|
||||
const flaskPayload = {
|
||||
card_number: this.CardForm.card_number,
|
||||
expiration_month: this.CardForm.expiration_month,
|
||||
expiration_year: this.CardForm.expiration_year,
|
||||
type_of_card: this.CardForm.type_of_card,
|
||||
security_number: this.CardForm.cvv, // Your Flask app expects 'security_number'
|
||||
main_card: this.CardForm.main_card,
|
||||
zip_code: this.CardForm.zip_code,
|
||||
name_on_card: this.CardForm.name_on_card, // Your Flask app expects 'name_on_card'
|
||||
};
|
||||
card_number: form.card_number,
|
||||
expiration_month: form.expiration_month,
|
||||
expiration_year: form.expiration_year,
|
||||
type_of_card: form.type_of_card,
|
||||
security_number: form.cvv,
|
||||
main_card: form.main_card,
|
||||
zip_code: form.zip_code,
|
||||
name_on_card: form.name_on_card,
|
||||
}
|
||||
|
||||
// Payload for your FastAPI backend (it only needs the essentials for Authorize.Net)
|
||||
// Payload for FastAPI backend (Authorize.Net)
|
||||
const fastapiPayload = {
|
||||
card_number: this.CardForm.card_number.replace(/\s/g, ''),
|
||||
expiration_date: `${this.CardForm.expiration_year}-${this.CardForm.expiration_month}`,
|
||||
cvv: this.CardForm.cvv,
|
||||
main_card: this.CardForm.main_card, // Send this to FastAPI as well
|
||||
};
|
||||
card_number: form.card_number.replace(/\s/g, ''),
|
||||
expiration_date: `${form.expiration_year}-${form.expiration_month}`,
|
||||
cvv: form.cvv,
|
||||
main_card: form.main_card,
|
||||
}
|
||||
|
||||
// --- STEP 2: CRITICAL CALL - SAVE CARD TO LOCAL DATABASE VIA FLASK ---
|
||||
let card_id: number;
|
||||
// Step 1: Save to local database via Flask
|
||||
let cardId: number
|
||||
try {
|
||||
const flaskPath = `${import.meta.env.VITE_BASE_URL}/payment/card/create/${this.customer.id}`;
|
||||
console.log("Attempting to save card to local DB via Flask:", flaskPath);
|
||||
const flaskResponse = await axios.post(flaskPath, flaskPayload, { withCredentials: true, headers: authHeader() });
|
||||
|
||||
if (!flaskResponse.data.ok) {
|
||||
// If the primary save fails, stop everything and show an error.
|
||||
throw new Error(flaskResponse.data.error || "Failed to save card.");
|
||||
const response = await paymentService.createCard(customer.value.id, flaskPayload as any)
|
||||
if (!(response.data as any).ok) {
|
||||
throw new Error((response.data as any).error || "Failed to save card.")
|
||||
}
|
||||
card_id = flaskResponse.data.card_id;
|
||||
console.log("Card successfully saved to local database via Flask with ID:", card_id);
|
||||
|
||||
cardId = (response.data as any).card_id
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.error || "A critical error occurred while saving the card.";
|
||||
notify({ title: "Error", text: errorMessage, type: "error" });
|
||||
this.isLoading = false; // Stop loading spinner
|
||||
return; // End the function here
|
||||
const errorMessage = error.response?.data?.error || "A critical error occurred while saving the card."
|
||||
notify({ title: "Error", text: errorMessage, type: "error" })
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// --- CHECK IF AUTHORIZE.NET PROFILE EXISTS ---
|
||||
if (!this.authorizeCheck.profile_exists) {
|
||||
console.log("Skipping Authorize.Net tokenization as no profile exists for customer.");
|
||||
// Show success and redirect (card saved locally without tokenization)
|
||||
notify({ title: "Success", text: "Credit card has been saved.", type: "success" });
|
||||
this.isLoading = false;
|
||||
this.$router.push({ name: "customerProfile", params: { id: this.customer.id } });
|
||||
return;
|
||||
// Check if Authorize.Net profile exists
|
||||
if (!authorizeCheck.value.profile_exists) {
|
||||
notify({ title: "Success", text: "Credit card has been saved.", type: "success" })
|
||||
isLoading.value = false
|
||||
router.push({ name: "customerProfile", params: { id: customer.value.id } })
|
||||
return
|
||||
}
|
||||
|
||||
// --- STEP 3: BEST-EFFORT CALL - TOKENIZE CARD VIA AUTHORIZE
|
||||
// Step 2: Tokenize card via Authorize.Net
|
||||
try {
|
||||
const fastapiPath = `${import.meta.env.VITE_AUTHORIZE_URL}/api/payments/customers/${this.customer.id}/cards`;
|
||||
console.log("Attempting to tokenize card with Authorize.Net via FastAPI:", fastapiPath);
|
||||
const fastapiResponse = await axios.post(fastapiPath, fastapiPayload, { withCredentials: true, headers: authHeader() });
|
||||
console.log("Card successfully tokenized with Authorize.Net via FastAPI.");
|
||||
|
||||
// --- STEP 4: UPDATE LOCAL CARD WITH PAYMENT_PROFILE_ID ---
|
||||
const payment_profile_id = fastapiResponse.data.payment_profile_id;
|
||||
console.log("Updating local card with payment_profile_id:", payment_profile_id);
|
||||
const updatePath = `${import.meta.env.VITE_BASE_URL}/payment/card/update_payment_profile/${card_id}`;
|
||||
await axios.put(updatePath, { auth_net_payment_profile_id: payment_profile_id }, { withCredentials: true, headers: authHeader() });
|
||||
console.log("Card successfully updated with payment_profile_id.");
|
||||
|
||||
const response = await paymentService.createAuthorizeCard(customer.value.id, fastapiPayload)
|
||||
const paymentProfileId = (response.data as any).payment_profile_id
|
||||
// Update local card with payment_profile_id
|
||||
await paymentService.updatePaymentProfile(cardId, { auth_net_payment_profile_id: paymentProfileId })
|
||||
} catch (error: any) {
|
||||
// If this fails, we just log it for the developers. We DON'T show an error to the user.
|
||||
console.warn("NON-CRITICAL-ERROR: Tokenization with Authorize.Net failed, but the card was saved locally.", error.response?.data || error.message);
|
||||
// Card is saved but without payment_profile_id, which is ok as nullable.
|
||||
console.warn("NON-CRITICAL-ERROR: Tokenization with Authorize.Net failed, but the card was saved locally.", error.response?.data || error.message)
|
||||
}
|
||||
|
||||
// --- STEP 4: ALWAYS SHOW SUCCESS AND REDIRECT ---
|
||||
// This code runs as long as the first (Flask) call was successful.
|
||||
notify({ title: "Success", text: "Credit card has been saved.", type: "success" });
|
||||
this.isLoading = false;
|
||||
this.$router.push({ name: "customerProfile", params: { id: this.customer.id } });
|
||||
},
|
||||
},
|
||||
});
|
||||
// Always show success and redirect
|
||||
notify({ title: "Success", text: "Credit card has been saved.", type: "success" })
|
||||
isLoading.value = false
|
||||
router.push({ name: "customerProfile", params: { id: customer.value.id } })
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
getCustomer(route.params.id as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<ul>
|
||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||
<li><router-link :to="{ name: 'customer' }">Customers</router-link></li>
|
||||
<li v-if="customer.id"><router-link :to="{ name: 'customerProfile', params: { id: customer.id } }">Profile</router-link></li>
|
||||
<li v-if="customer.id"><router-link
|
||||
:to="{ name: 'customerProfile', params: { id: customer.id } }">Profile</router-link></li>
|
||||
<li>Edit Credit Card</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -21,7 +22,8 @@
|
||||
<div class="text-xl font-bold">{{ customer.customer_first_name }} {{ customer.customer_last_name }}</div>
|
||||
<div class="text-sm text-gray-400">Account: {{ customer.account_number }}</div>
|
||||
</div>
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: customer.id } }" class="btn btn-secondary btn-sm">
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: customer.id } }"
|
||||
class="btn btn-secondary btn-sm">
|
||||
View Profile
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -35,7 +37,8 @@
|
||||
<span v-else-if="customer.customer_state == 4">Vermont</span>
|
||||
<span v-else-if="customer.customer_state == 5">Connecticut</span>
|
||||
<span v-else-if="customer.customer_state == 6">New York</span>
|
||||
<span v-else>Unknown state</span> {{ customer.customer_zip }}</div>
|
||||
<span v-else>Unknown state</span> {{ customer.customer_zip }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,64 +63,74 @@
|
||||
<!-- Name on Card -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Name on Card</span></label>
|
||||
<input v-model="CardForm.name_on_card" type="text" placeholder="" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.name_on_card.$error }" />
|
||||
<span v-if="v$.CardForm.name_on_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.name_on_card" type="text" placeholder="" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.name_on_card.$error }" />
|
||||
<span v-if="v$.form.name_on_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Number -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Card Number</span></label>
|
||||
<input v-model="CardForm.card_number" type="text" placeholder="" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.card_number.$error }" />
|
||||
<span v-if="v$.CardForm.card_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.card_number" type="text" placeholder="" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.card_number.$error }" />
|
||||
<span v-if="v$.form.card_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Expiration -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Expiration</span></label>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="CardForm.expiration_month" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_month.$error }">
|
||||
<select v-model="form.expiration_month" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.expiration_month.$error }">
|
||||
<option disabled value="">MM</option>
|
||||
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}</option>
|
||||
<option v-for="m in 12" :key="m" :value="String(m).padStart(2, '0')">{{ String(m).padStart(2, '0') }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="CardForm.expiration_year" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.expiration_year.$error }">
|
||||
<select v-model="form.expiration_year" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.expiration_year.$error }">
|
||||
<option disabled value="">YYYY</option>
|
||||
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear() + y - 1 }}</option>
|
||||
<option v-for="y in 10" :key="y" :value="new Date().getFullYear() + y - 1">{{ new Date().getFullYear()
|
||||
+ y - 1 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<span v-if="v$.CardForm.expiration_month.$error || v$.CardForm.expiration_year.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<span v-if="v$.form.expiration_month.$error || v$.form.expiration_year.$error"
|
||||
class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Security Number (CVV) -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">CVV</span></label>
|
||||
<input v-model="CardForm.security_number" type="text" placeholder="" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CardForm.security_number.$error }" />
|
||||
<span v-if="v$.CardForm.security_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.security_number" type="text" placeholder=""
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.security_number.$error }" />
|
||||
<span v-if="v$.form.security_number.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Type -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Card Type</span></label>
|
||||
<select v-model="CardForm.type_of_card" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CardForm.type_of_card.$error }">
|
||||
<select v-model="form.type_of_card" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.type_of_card.$error }">
|
||||
<option disabled value="">Select Type</option>
|
||||
<option>Visa</option>
|
||||
<option>MasterCard</option>
|
||||
<option>Discover</option>
|
||||
<option>American Express</option>
|
||||
</select>
|
||||
<span v-if="v$.CardForm.type_of_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<span v-if="v$.form.type_of_card.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
|
||||
<!-- Billing Zip Code -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Billing Zip Code</span></label>
|
||||
<input v-model="CardForm.zip_code" type="text" placeholder="" class="input input-bordered input-sm w-full" />
|
||||
<input v-model="form.zip_code" type="text" placeholder="" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Main Card Checkbox -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<span class="label-text font-bold">Set as Main Card</span>
|
||||
<input v-model="CardForm.main_card" type="checkbox" class="checkbox checkbox-sm" />
|
||||
<input v-model="form.main_card" type="checkbox" class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,29 +149,31 @@
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { minLength, required } from "@vuelidate/validators";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, minLength } from "@vuelidate/validators"
|
||||
import { paymentService } from '../../services/paymentService'
|
||||
import { customerService } from '../../services/customerService'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EditCard',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null as any,
|
||||
customer: {} as any,
|
||||
card: {} as any, // To store original card details for display
|
||||
isLoading: false,
|
||||
isLoadingAuthorize: true,
|
||||
authorizeCheck: { profile_exists: false, has_payment_methods: false, missing_components: [] as string[], valid_for_charging: false },
|
||||
// --- REFACTORED: Simplified, flat form object ---
|
||||
CardForm: {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const customer = ref<any>({})
|
||||
const card = ref<any>({})
|
||||
const isLoading = ref(false)
|
||||
const isLoadingAuthorize = ref(true)
|
||||
const authorizeCheck = ref({
|
||||
profile_exists: false,
|
||||
has_payment_methods: false,
|
||||
missing_components: [] as string[],
|
||||
valid_for_charging: false
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name_on_card: '',
|
||||
expiration_month: '',
|
||||
expiration_year: '',
|
||||
@@ -167,13 +182,11 @@ export default defineComponent({
|
||||
card_number: '',
|
||||
zip_code: '',
|
||||
main_card: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
// --- REFACTORED: Validation points to the flat form object ---
|
||||
CardForm: {
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
name_on_card: { required, minLength: minLength(1) },
|
||||
expiration_month: { required },
|
||||
expiration_year: { required },
|
||||
@@ -181,173 +194,131 @@ export default defineComponent({
|
||||
type_of_card: { required },
|
||||
card_number: { required, minLength: minLength(1) },
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
this.getCard(this.$route.params.id);
|
||||
},
|
||||
methods: {
|
||||
userStatus() {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) { this.user = response.data.user; }
|
||||
})
|
||||
.catch(() => { this.user = null; });
|
||||
},
|
||||
getCustomer(userid: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/customer/${userid}`;
|
||||
axios.get(path, { headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
this.customer = response.data;
|
||||
this.checkAuthorizeAccount();
|
||||
});
|
||||
},
|
||||
async checkAuthorizeAccount() {
|
||||
if (!this.customer.id) return;
|
||||
}))
|
||||
|
||||
this.isLoadingAuthorize = true;
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
async function getCustomer(userId: string | number) {
|
||||
try {
|
||||
const response = await customerService.getById(Number(userId))
|
||||
customer.value = response.data
|
||||
checkAuthorizeAccount()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch customer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthorizeAccount() {
|
||||
if (!customer.value.id) return
|
||||
isLoadingAuthorize.value = true
|
||||
|
||||
try {
|
||||
const path = `${import.meta.env.VITE_AUTHORIZE_URL}/user/check-authorize-account/${this.customer.id}`;
|
||||
const response = await axios.get(path, { headers: authHeader() });
|
||||
this.authorizeCheck = response.data;
|
||||
const response = await paymentService.checkAuthorizeAccount(customer.value.id)
|
||||
authorizeCheck.value = response.data as any
|
||||
} catch (error) {
|
||||
console.error("Failed to check authorize account:", error);
|
||||
notify({ title: "Error", text: "Could not check payment account status.", type: "error" });
|
||||
// Set default error state
|
||||
this.authorizeCheck = {
|
||||
console.error("Failed to check authorize account:", error)
|
||||
notify({ title: "Error", text: "Could not check payment account status.", type: "error" })
|
||||
authorizeCheck.value = {
|
||||
profile_exists: false,
|
||||
has_payment_methods: false,
|
||||
missing_components: ['api_error'],
|
||||
valid_for_charging: false
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
this.isLoadingAuthorize = false;
|
||||
isLoadingAuthorize.value = false
|
||||
}
|
||||
},
|
||||
getCard(card_id: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/payment/card/${card_id}`;
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
this.card = response.data; // Store original details for display
|
||||
|
||||
// Populate the flat form object for editing
|
||||
this.CardForm.name_on_card = response.data.name_on_card;
|
||||
|
||||
// --- FIX IS HERE ---
|
||||
// Convert the month number (e.g., 8) to a zero-padded string ("08") to match the <option> value.
|
||||
this.CardForm.expiration_month = String(response.data.expiration_month).padStart(2, '0');
|
||||
|
||||
// Convert the year number (e.g., 2025) to a string ("2025") for consistency.
|
||||
this.CardForm.expiration_year = String(response.data.expiration_year);
|
||||
// --- END FIX ---
|
||||
|
||||
this.CardForm.type_of_card = response.data.type_of_card;
|
||||
this.CardForm.security_number = response.data.security_number;
|
||||
this.CardForm.main_card = response.data.main_card;
|
||||
this.CardForm.card_number = response.data.card_number;
|
||||
this.CardForm.zip_code = response.data.zip_code;
|
||||
|
||||
if (response.data.user_id) {
|
||||
this.getCustomer(response.data.user_id);
|
||||
}
|
||||
});
|
||||
},
|
||||
editCard(payload: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/payment/card/edit/${this.$route.params.id}`;
|
||||
|
||||
// REMOVE the payload manipulation. Send the form data directly.
|
||||
// The 'payload' object (which is this.CardForm) is already in the correct format.
|
||||
axios.put(path, payload, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.$router.push({ name: "customerProfile", params: { id: this.customer.id } });
|
||||
} else {
|
||||
// You should notify the user here as well
|
||||
|
||||
console.error("Failed to edit card:", response.data.error);
|
||||
}
|
||||
})
|
||||
.catch(console.log("error"));
|
||||
},
|
||||
async onSubmit() {
|
||||
this.v$.$validate();
|
||||
if (this.v$.$error) {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields.", type: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
async function getCard(cardId: string | number) {
|
||||
try {
|
||||
const response = await paymentService.getCardById(Number(cardId))
|
||||
const data = response.data as any
|
||||
card.value = data
|
||||
|
||||
// --- STEP 1: PREPARE PAYLOADS FOR BOTH SERVICES ---
|
||||
// Payload for your Flask backend (it takes all the raw details for your DB)
|
||||
// Populate form
|
||||
form.name_on_card = data.name_on_card
|
||||
form.expiration_month = String(data.expiration_month).padStart(2, '0')
|
||||
form.expiration_year = String(data.expiration_year)
|
||||
form.type_of_card = data.type_of_card
|
||||
form.security_number = data.security_number
|
||||
form.main_card = data.main_card
|
||||
form.card_number = data.card_number
|
||||
form.zip_code = data.zip_code
|
||||
|
||||
if (data.user_id) {
|
||||
getCustomer(data.user_id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch card:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
if (!isValid) {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields.", type: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
// Payload for Flask backend
|
||||
const flaskPayload = {
|
||||
card_number: this.CardForm.card_number,
|
||||
expiration_month: this.CardForm.expiration_month,
|
||||
expiration_year: this.CardForm.expiration_year,
|
||||
type_of_card: this.CardForm.type_of_card,
|
||||
security_number: this.CardForm.security_number,
|
||||
main_card: this.CardForm.main_card,
|
||||
zip_code: this.CardForm.zip_code,
|
||||
name_on_card: this.CardForm.name_on_card,
|
||||
};
|
||||
card_number: form.card_number,
|
||||
expiration_month: form.expiration_month,
|
||||
expiration_year: form.expiration_year,
|
||||
type_of_card: form.type_of_card,
|
||||
security_number: form.security_number,
|
||||
main_card: form.main_card,
|
||||
zip_code: form.zip_code,
|
||||
name_on_card: form.name_on_card,
|
||||
}
|
||||
|
||||
// Payload for your FastAPI backend (it only needs the essentials for Authorize.Net)
|
||||
// Payload for FastAPI backend (Authorize.Net)
|
||||
const fastapiPayload = {
|
||||
card_number: this.CardForm.card_number.replace(/\s/g, ''),
|
||||
expiration_date: `${this.CardForm.expiration_year}-${this.CardForm.expiration_month}`,
|
||||
cvv: this.CardForm.security_number,
|
||||
main_card: this.CardForm.main_card,
|
||||
};
|
||||
card_number: form.card_number.replace(/\s/g, ''),
|
||||
expiration_date: `${form.expiration_year}-${form.expiration_month}`,
|
||||
cvv: form.security_number,
|
||||
main_card: form.main_card,
|
||||
}
|
||||
|
||||
// --- STEP 2: CRITICAL CALL - UPDATE CARD TO LOCAL DATABASE VIA FLASK ---
|
||||
// Step 1: Update card in local database via Flask
|
||||
try {
|
||||
const flaskPath = `${import.meta.env.VITE_BASE_URL}/payment/card/edit/${this.$route.params.id}`;
|
||||
console.log("Attempting to update card to local DB via Flask:", flaskPath);
|
||||
const flaskResponse = await axios.put(flaskPath, flaskPayload, { withCredentials: true, headers: authHeader() });
|
||||
|
||||
if (!flaskResponse.data.ok) {
|
||||
throw new Error(flaskResponse.data.error || "Failed to update card.");
|
||||
const response = await paymentService.updateCard(Number(route.params.id), flaskPayload)
|
||||
if (!(response.data as any).ok) {
|
||||
throw new Error((response.data as any).error || "Failed to update card.")
|
||||
}
|
||||
console.log("Card successfully updated to local database via Flask with ID:", this.$route.params.id);
|
||||
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.error || "A critical error occurred while updating the card.";
|
||||
notify({ title: "Error", text: errorMessage, type: "error" });
|
||||
this.isLoading = false;
|
||||
return;
|
||||
const errorMessage = error.response?.data?.error || "A critical error occurred while updating the card."
|
||||
notify({ title: "Error", text: errorMessage, type: "error" })
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// --- CHECK IF AUTHORIZE.NET PROFILE EXISTS ---
|
||||
if (!this.authorizeCheck.profile_exists) {
|
||||
console.log("Skipping Authorize.Net tokenization as no profile exists for customer.");
|
||||
// Show success and redirect (card updated locally without tokenization)
|
||||
notify({ title: "Success", text: "Credit card has been updated.", type: "success" });
|
||||
this.isLoading = false;
|
||||
this.$router.push({ name: "customerProfile", params: { id: this.customer.id } });
|
||||
return;
|
||||
// Check if Authorize.Net profile exists
|
||||
if (!authorizeCheck.value.profile_exists) {
|
||||
notify({ title: "Success", text: "Credit card has been updated.", type: "success" })
|
||||
isLoading.value = false
|
||||
router.push({ name: "customerProfile", params: { id: customer.value.id } })
|
||||
return
|
||||
}
|
||||
|
||||
// --- STEP 3: BEST-EFFORT CALL - TOKENIZE/UPDATE CARD VIA AUTHORIZE
|
||||
// Step 2: Update card tokenization via Authorize.Net
|
||||
try {
|
||||
const fastapiPath = `${import.meta.env.VITE_AUTHORIZE_URL}/api/payments/customers/${this.customer.id}/cards/${this.$route.params.id}`;
|
||||
console.log("Attempting to update card tokenization with Authorize.Net via FastAPI:", fastapiPath);
|
||||
await axios.put(fastapiPath, fastapiPayload, { withCredentials: true, headers: authHeader() });
|
||||
console.log("Card successfully updated with Authorize.Net via FastAPI.");
|
||||
|
||||
await paymentService.updateAuthorizeCard(customer.value.id, Number(route.params.id), fastapiPayload)
|
||||
} catch (error: any) {
|
||||
// If this fails, we just log it for the developers. We DON'T show an error to the user.
|
||||
console.warn("NON-CRITICAL-ERROR: Authorize.Net update failed, but the card was updated locally.", error.response?.data || error.message);
|
||||
// Card is updated but Authorize.Net profile may not be current, which is ok.
|
||||
console.warn("NON-CRITICAL-ERROR: Authorize.Net update failed, but the card was updated locally.", error.response?.data || error.message)
|
||||
}
|
||||
|
||||
// --- STEP 4: ALWAYS SHOW SUCCESS AND REDIRECT ---
|
||||
notify({ title: "Success", text: "Credit card has been updated.", type: "success" });
|
||||
this.isLoading = false;
|
||||
this.$router.push({ name: "customerProfile", params: { id: this.customer.id } });
|
||||
},
|
||||
},
|
||||
});
|
||||
// Always show success and redirect
|
||||
notify({ title: "Success", text: "Credit card has been updated.", type: "success" })
|
||||
isLoading.value = false
|
||||
router.push({ name: "customerProfile", params: { id: customer.value.id } })
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
getCard(route.params.id as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,63 +1,74 @@
|
||||
<!-- src/pages/card/home.vue -->
|
||||
<template>
|
||||
<div class="flex">
|
||||
|
||||
<div class=" w-full px-10 ">
|
||||
<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>
|
||||
<router-link :to="{ name: 'customer' }">
|
||||
Customers
|
||||
</router-link>
|
||||
</li>
|
||||
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
|
||||
<li>Cards</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<router-link :to="{ name: 'customerCreate' }">
|
||||
<button class=" btn bg-blue-700 btn-sm">Create Customer</button>
|
||||
</router-link>
|
||||
|
||||
<!-- 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="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
Payment Cards
|
||||
</h1>
|
||||
<p class="text-base-content/60 mt-1 ml-13">Manage customer payment cards</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<!-- head -->
|
||||
<!-- Quick Stats -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="stat-pill">
|
||||
<span class="stat-pill-value">{{ cards.length }}</span>
|
||||
<span class="stat-pill-label">Total Cards</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<div class="modern-table-card">
|
||||
<!-- DESKTOP VIEW: Table -->
|
||||
<div class="overflow-x-auto hidden xl:block">
|
||||
<table class="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Card Type</th>
|
||||
<th>Card Number</th>
|
||||
<th>Card ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Type</th>
|
||||
<th>Last 4</th>
|
||||
<th>Expiration</th>
|
||||
<th>Main Card</th>
|
||||
<th></th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- row 1 -->
|
||||
<tr v-for="card in cards" :key="card['id']">
|
||||
<tr v-for="card in cards" :key="card.id" class="table-row-hover">
|
||||
<td>{{ card.id }}</td>
|
||||
<td>{{ card.customer_name || 'N/A' }}</td>
|
||||
<td><span class="badge badge-ghost badge-sm">{{ card.type_of_card }}</span></td>
|
||||
<td>{{ card.card_number ? card.card_number.slice(-4) : 'XXXX' }}</td>
|
||||
<td>{{ card.expiration_month }}/{{ card.expiration_year }}</td>
|
||||
<td>
|
||||
<router-link :to="{ name: 'cardview', params: { id: card['id'] } }">
|
||||
{{ card['name_on_card'] }}
|
||||
</router-link>
|
||||
<span v-if="card.main_card" class="badge badge-primary badge-sm">Main</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">-</span>
|
||||
</td>
|
||||
<td>{{ card['type_of_card'] }}</td>
|
||||
<td>{{ card['name_on_card'] }}</td>
|
||||
<td>{{ card['expiration_month'] }} / {{ card['expiration_year'] }}</td>
|
||||
<td>{{ card['main_card'] }} </td>
|
||||
<td class="flex gap-5">
|
||||
<router-link :to="{ name: 'cardview', params: { id: card['id'] } }">
|
||||
Oil
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'cardedit', params: { id: card['id'] } }">
|
||||
Service
|
||||
</router-link>
|
||||
|
||||
<div @click="removeCard(card['id'])">x
|
||||
Remove Card
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<router-link :to="{ name: 'editCard', params: { id: card.id } }"
|
||||
class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<button @click.prevent="deleteCard(card.id)"
|
||||
class="btn btn-sm btn-error btn-outline">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -65,137 +76,105 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<pagination @paginate="getPage" :records="recordsLength" v-model="page" :per-page="10" :options="options" class="mt-10">
|
||||
</pagination>
|
||||
<div class="flex justify-center mb-10"> {{ recordsLength }} items Found</div>
|
||||
|
||||
|
||||
<!-- MOBILE VIEW: Cards -->
|
||||
<div class="xl:hidden space-y-4 px-4 pb-4">
|
||||
<div v-for="card in cards" :key="card.id" class="mobile-card">
|
||||
<div class="p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-base font-bold">{{ card.type_of_card }}</h2>
|
||||
<p class="text-xs text-base-content/60">ID: #{{ card.id }}</p>
|
||||
</div>
|
||||
<span v-if="card.main_card" class="badge badge-primary badge-sm">Main</span>
|
||||
</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">Last 4</p>
|
||||
<p>{{ card.card_number ? card.card_number.slice(-4) : 'XXXX' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Expires</p>
|
||||
<p>{{ card.expiration_month }}/{{ card.expiration_year }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||
<router-link :to="{ name: 'editCard', params: { id: card.id } }"
|
||||
class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||
<button @click.prevent="deleteCard(card.id)"
|
||||
class="btn btn-sm btn-error btn-outline flex-1">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { paymentService } from '../../services/paymentService'
|
||||
|
||||
import { defineComponent, markRaw } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import authHeader from '../../services/auth.header'
|
||||
import Header from '../../layouts/headers/headerauth.vue'
|
||||
import PaginationComp from '../../components/pagination.vue'
|
||||
import SideBar from '../../layouts/sidebar/sidebar.vue'
|
||||
// Types
|
||||
interface Card {
|
||||
id: number
|
||||
customer_name?: string
|
||||
type_of_card: string
|
||||
card_number: string
|
||||
expiration_month: string
|
||||
expiration_year: string
|
||||
main_card: boolean
|
||||
}
|
||||
|
||||
// State
|
||||
const cards = ref<Card[]>([])
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CardHome',
|
||||
|
||||
components: {
|
||||
Header,
|
||||
SideBar,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
token: null,
|
||||
user: null,
|
||||
customer: null,
|
||||
customer_id: null,
|
||||
cards: [],
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
recordsLength: 0,
|
||||
options: {
|
||||
edgeNavigation: false,
|
||||
format: false,
|
||||
template: markRaw(PaginationComp)
|
||||
// Methods
|
||||
async function getCards() {
|
||||
try {
|
||||
const response = await paymentService.getAllCards(1)
|
||||
if ((response.data as any).cards) {
|
||||
cards.value = (response.data as any).cards
|
||||
} else if (Array.isArray(response.data)) {
|
||||
cards.value = response.data as Card[]
|
||||
} else {
|
||||
cards.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cards:', error)
|
||||
cards.value = []
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.getCustomer(this.$route.params.id);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getPage(this.page)
|
||||
},
|
||||
methods: {
|
||||
getPage: function (page: any) {
|
||||
// we simulate an api call that fetch the records from a backend
|
||||
this.get_all_cards(page)
|
||||
},
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
getCustomer(user_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/customer/" + user_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
})
|
||||
.then((response: any) => {
|
||||
this.customer = response.data;
|
||||
this.customer_id = response.data.user_id;
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
async function deleteCard(cardId: number) {
|
||||
try {
|
||||
const response = await paymentService.removeCard(cardId)
|
||||
if ((response.data as any).ok) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Could not find customer",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
get_all_cards(page: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/payment/cards/all/' + page;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.cards = response.data
|
||||
})
|
||||
},
|
||||
|
||||
removeCard(card_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/payment/cards/remove/' + card_id;
|
||||
axios({
|
||||
method: 'delete',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Deletion",
|
||||
text: "Card Removed",
|
||||
title: "Success",
|
||||
text: "Card removed successfully",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
getCards()
|
||||
} else {
|
||||
notify({
|
||||
title: "Failure",
|
||||
text: "Error removing card",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Failure",
|
||||
text: "Error removing card",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
getCards()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -111,13 +111,13 @@
|
||||
<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-warning btn-outline" title="New Delivery">
|
||||
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>
|
||||
<span class="hidden 2xl:inline ml-1">Deliv</span>
|
||||
Delivery
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
|
||||
@@ -127,11 +127,11 @@
|
||||
<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>
|
||||
<span class="hidden 2xl:inline ml-1">Svc</span>
|
||||
Service
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||
class="btn btn-xs btn-success btn-outline">View</router-link>
|
||||
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>
|
||||
@@ -194,7 +194,7 @@
|
||||
|
||||
<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-warning btn-outline flex-1">
|
||||
class="btn btn-sm btn-success btn-outline flex-1">
|
||||
Delivery
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: person.id } }"
|
||||
@@ -206,7 +206,7 @@
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-success btn-outline flex-1">
|
||||
class="btn btn-sm btn-neutral btn-outline flex-1">
|
||||
View
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- src/pages/customer/profile/TankEstimation.vue -->
|
||||
<template>
|
||||
<div class="bg-base-100 rounded-lg p-4 border">
|
||||
<div class="card-glass p-4 border-0">
|
||||
<h3 class="font-semibold mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -48,20 +48,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confidence Score -->
|
||||
<div v-if="estimation.confidence_score != null">
|
||||
<label class="label-text font-medium">Estimation Confidence</label>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:value="estimation.confidence_score"
|
||||
max="100"
|
||||
:class="{
|
||||
'progress-success': estimation.confidence_score >= 70,
|
||||
'progress-warning': estimation.confidence_score >= 40 && estimation.confidence_score < 70,
|
||||
'progress-error': estimation.confidence_score < 40
|
||||
}"
|
||||
></progress>
|
||||
<span class="text-sm font-mono whitespace-nowrap">{{ estimation.confidence_score }}%</span>
|
||||
</div>
|
||||
<div v-if="estimation.k_factor_source" class="text-xs text-gray-500 mt-1">
|
||||
Source: <span class="capitalize">{{ estimation.k_factor_source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Days Remaining -->
|
||||
<div v-if="estimation.days_remaining != null">
|
||||
<label class="label-text font-medium">Days Until Empty</label>
|
||||
<div class="mt-1">
|
||||
<span v-if="estimation.days_remaining >= 999" class="text-lg font-mono text-base-content/40">N/A</span>
|
||||
<span v-else class="text-lg font-mono font-bold" :class="{
|
||||
'text-error': estimation.days_remaining <= 7,
|
||||
'text-warning': estimation.days_remaining > 7 && estimation.days_remaining <= 14,
|
||||
'text-success': estimation.days_remaining > 14
|
||||
}">{{ estimation.days_remaining }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Information -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label-text font-medium">Usage Factor</label>
|
||||
<div class="text-lg font-mono">{{ getScalingFactorCategory(estimation.scaling_factor) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ formatScalingFactor(estimation.scaling_factor) }} gallons per degree day</div>
|
||||
<div class="text-lg font-mono">{{ getScalingFactorCategory(sliderValue) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ sliderValue.toFixed(4) }} gal/degree day</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label-text font-medium">Daily Usage</label>
|
||||
<div class="text-lg font-mono">{{ calculateDailyUsage() }}</div>
|
||||
<div class="text-lg font-mono">{{ computedGallonsPerDay }}</div>
|
||||
<div class="text-xs text-gray-500">Gallons per day</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- K-Factor Slider -->
|
||||
<div class="pt-2 border-t">
|
||||
<label class="label-text font-medium">Adjust Usage Factor (K-Factor)</label>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<span class="text-xs text-gray-500 w-8">0.01</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.01"
|
||||
max="1.00"
|
||||
step="0.001"
|
||||
class="range range-sm range-primary flex-1"
|
||||
v-model.number="sliderValue"
|
||||
@input="onSliderInput"
|
||||
/>
|
||||
<span class="text-xs text-gray-500 w-8">1.00</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<span class="text-xs text-gray-500">{{ sliderValue.toFixed(4) }} gal/degree day</span>
|
||||
<span v-if="saving" class="text-xs text-info">Saving...</span>
|
||||
<span v-else-if="saveSuccess" class="text-xs text-success">Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +135,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { customerService } from '../../../services/customerService'
|
||||
import { deliveryService } from '../../../services/deliveryService'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -94,6 +151,12 @@ interface FuelEstimation {
|
||||
estimated_gallons: number;
|
||||
tank_size: number;
|
||||
scaling_factor: number | null;
|
||||
confidence_score: number | null;
|
||||
k_factor_source: string | null;
|
||||
days_remaining: number | null;
|
||||
gallons_per_day: number | null;
|
||||
avg_hdd: number | null;
|
||||
hot_water_summer: number | null;
|
||||
last_5_deliveries: Array<{
|
||||
fill_date: string;
|
||||
gallons_delivered: number;
|
||||
@@ -112,6 +175,20 @@ const props = defineProps<{
|
||||
const estimation = ref<FuelEstimation | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const isAutomatic = ref(false)
|
||||
const sliderValue = ref(0.12)
|
||||
const saving = ref(false)
|
||||
const saveSuccess = ref(false)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let saveSuccessTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Computed: real-time gallons per day from slider
|
||||
const computedGallonsPerDay = computed(() => {
|
||||
const avgHdd = estimation.value?.avg_hdd ?? 20
|
||||
const hotWater = estimation.value?.hot_water_summer === 1 ? 1.0 : 0
|
||||
const daily = sliderValue.value * avgHdd + hotWater
|
||||
return daily.toFixed(1)
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
@@ -138,15 +215,12 @@ const fetchEstimation = async () => {
|
||||
// First check if customer is automatic
|
||||
console.log('Checking customer type')
|
||||
const customerResponse = await customerService.getById(props.customerId)
|
||||
// customerResponse.data might be { customer: ... } or flat, depending on backend.
|
||||
// Assuming backend returns { customer: ... } compatible with profile.vue fix
|
||||
const customerData = customerResponse.data.customer || customerResponse.data;
|
||||
const isAutomatic = customerData.customer_automatic === 1
|
||||
|
||||
console.log('Customer automatic status:', isAutomatic, customerData)
|
||||
isAutomatic.value = customerData.customer_automatic === 1
|
||||
console.log('Customer automatic status:', isAutomatic.value, customerData)
|
||||
|
||||
let response: AxiosResponse<any>;
|
||||
if (isAutomatic) {
|
||||
if (isAutomatic.value) {
|
||||
console.log('Fetching automatic data')
|
||||
response = await deliveryService.auto.getByCustomer(props.customerId)
|
||||
} else {
|
||||
@@ -159,7 +233,7 @@ const fetchEstimation = async () => {
|
||||
error.value = response.data.error
|
||||
console.error('API returned error:', response.data.error)
|
||||
} else {
|
||||
if (isAutomatic) {
|
||||
if (isAutomatic.value) {
|
||||
// Transform automatic delivery data to match our interface
|
||||
if (response.data && response.data.id) {
|
||||
const autoData = response.data
|
||||
@@ -167,13 +241,19 @@ const fetchEstimation = async () => {
|
||||
estimation.value = {
|
||||
id: autoData.id,
|
||||
customer_id: autoData.customer_id,
|
||||
total_deliveries: 0, // Not available in auto data
|
||||
total_deliveries: 0,
|
||||
customer_full_name: autoData.customer_full_name,
|
||||
account_number: autoData.account_number,
|
||||
address: autoData.customer_address,
|
||||
estimated_gallons: autoData.estimated_gallons_left,
|
||||
tank_size: autoData.tank_size,
|
||||
scaling_factor: autoData.house_factor,
|
||||
confidence_score: autoData.confidence_score ?? null,
|
||||
k_factor_source: autoData.k_factor_source ?? null,
|
||||
days_remaining: autoData.days_remaining ?? null,
|
||||
gallons_per_day: autoData.gallons_per_day ?? null,
|
||||
avg_hdd: autoData.avg_hdd ?? null,
|
||||
hot_water_summer: autoData.hot_water_summer ?? null,
|
||||
last_5_deliveries: [],
|
||||
last_fill: autoData.last_fill
|
||||
}
|
||||
@@ -186,6 +266,11 @@ const fetchEstimation = async () => {
|
||||
console.log('Setting customer estimation:', response.data)
|
||||
estimation.value = response.data
|
||||
}
|
||||
|
||||
// Initialize slider from loaded data
|
||||
if (estimation.value?.scaling_factor != null) {
|
||||
sliderValue.value = Math.max(0.01, Math.min(1.0, estimation.value.scaling_factor))
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorObj = err as AxiosError<any>;
|
||||
@@ -209,22 +294,36 @@ const getTankLevelPercentage = (): number => {
|
||||
return (estimation.value.estimated_gallons / estimation.value.tank_size) * 100
|
||||
}
|
||||
|
||||
const calculateDailyUsage = (): string => {
|
||||
if (!estimation.value || !estimation.value.scaling_factor) {
|
||||
return 'N/A'
|
||||
}
|
||||
// For a typical day with ~20 degree days (moderate winter day)
|
||||
const typicalDegreeDays = 20
|
||||
const dailyUsage = estimation.value.scaling_factor * typicalDegreeDays
|
||||
return dailyUsage.toFixed(1)
|
||||
// Slider input handler with debounce
|
||||
const onSliderInput = () => {
|
||||
saveSuccess.value = false
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => saveHouseFactor(), 500)
|
||||
}
|
||||
|
||||
const formatScalingFactor = (scalingFactor: number | null): string => {
|
||||
if (scalingFactor === null || scalingFactor === undefined) {
|
||||
return 'N/A'
|
||||
const saveHouseFactor = async () => {
|
||||
saving.value = true
|
||||
saveSuccess.value = false
|
||||
try {
|
||||
if (isAutomatic.value) {
|
||||
await deliveryService.auto.updateHouseFactor(props.customerId, { house_factor: sliderValue.value })
|
||||
} else {
|
||||
await deliveryService.auto.updateCustomerHouseFactor(props.customerId, { house_factor: sliderValue.value })
|
||||
}
|
||||
return scalingFactor.toFixed(2)
|
||||
saveSuccess.value = true
|
||||
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
|
||||
saveSuccessTimer = setTimeout(() => { saveSuccess.value = false }, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to save house factor:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
|
||||
})
|
||||
|
||||
const getScalingFactorCategory = (scalingFactor: number | null): string => {
|
||||
if (scalingFactor === null || scalingFactor === undefined) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- src/pages/customer/profile/profile.vue -->
|
||||
<template>
|
||||
<div class="w-full min-h-screen bg-base-200 px-4 md:px-10">
|
||||
<div class="w-full min-h-screen px-4 md:px-10">
|
||||
<!-- ... breadcrumbs ... -->
|
||||
|
||||
<div v-if="customer && customer.id" class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
|
||||
<div v-if="customer && customer.id" class="mt-6">
|
||||
|
||||
<!-- Current Plan Status Banner - Same as ServicePlanEdit.vue -->
|
||||
<div v-if="servicePlan && servicePlan.contract_plan > 0" class="alert alert-info mb-6"
|
||||
@@ -40,12 +40,15 @@
|
||||
class="xl:col-span-7" :customer="customer" />
|
||||
|
||||
<!-- You can add a placeholder for when the map isn't ready -->
|
||||
<div v-else class="xl:col-span-7 bg-base-100 rounded-lg flex justify-center items-center">
|
||||
<div v-else class="xl:col-span-7 card-glass flex justify-center items-center">
|
||||
<p class="text-gray-400">Location not available...</p>
|
||||
</div>
|
||||
|
||||
<ProfileSummary class="xl:col-span-5" :customer="customer" :automatic_status="automatic_status"
|
||||
<div class="xl:col-span-5 space-y-6">
|
||||
<ProfileSummary :customer="customer" :automatic_status="automatic_status"
|
||||
:customer_description="customer_description.description" @toggle-automatic="userAutomatic" />
|
||||
<CustomerStats :stats="customer_stats" :last_delivery="customer_last_delivery" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HistoryTabs :deliveries="deliveries" :autodeliveries="autodeliveries" :service-calls="serviceCalls"
|
||||
@@ -57,7 +60,7 @@
|
||||
<!-- FIX: Changed `lg:` to `xl:` -->
|
||||
<div class="xl:col-span-4 space-y-6">
|
||||
<!-- Authorize.net Account Status Box -->
|
||||
<div v-if="customer.id" class="bg-base-100 rounded-lg p-4 border">
|
||||
<div v-if="customer.id" class="card-glass p-4">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-center gap-3 mb-3 md:mb-0">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -101,14 +104,14 @@
|
||||
<TankEstimation :customer-id="customer.id" />
|
||||
|
||||
<CustomerComments :comments="comments" @add-comment="onSubmitSocial" @delete-comment="deleteCustomerSocial" />
|
||||
<CustomerStats :stats="customer_stats" :last_delivery="customer_last_delivery" />
|
||||
<TankInfo :customer_id="customer.id" :tank="customer_tank" :description="customer_description" />
|
||||
|
||||
<TankInfo :customer_id="customer.id" :tank="customer_tank" :description="customer_description" :estimation="autoEstimation" />
|
||||
<EquipmentParts :parts="currentParts" @open-parts-modal="openPartsModal" />
|
||||
<CreditCards :cards="credit_cards" :count="credit_cards_count" :user_id="customer.id"
|
||||
:auth_net_profile_id="customer.auth_net_profile_id" @edit-card="editCard" @remove-card="removeCard" />
|
||||
|
||||
<!-- Automatic Delivery Actions Box -->
|
||||
<div v-if="automatic_status === 1 && autodeliveries.length > 0" class="bg-base-100 rounded-lg p-4 border">
|
||||
<div v-if="automatic_status === 1 && autodeliveries.length > 0" class="card-glass p-4">
|
||||
<h3 class="font-semibold mb-4">Automatic Delivery Actions</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<router-link v-if="autodeliveries[0].auto_status != 3"
|
||||
@@ -354,6 +357,7 @@ const autodeliveries = ref([] as AutomaticDelivery[])
|
||||
const serviceCalls = ref([] as ServiceCall[])
|
||||
const transactions = ref([] as AuthorizeTransaction[])
|
||||
// --- END OF UPDATES ---
|
||||
const autoEstimation = ref<{ confidence_score: number; k_factor_source: string; days_remaining: number } | undefined>(undefined)
|
||||
const automatic_response = ref(0)
|
||||
const credit_cards_count = ref(0)
|
||||
const customer = ref({ id: 0, user_id: null as number | null, customer_first_name: '', customer_last_name: '', customer_town: '', customer_address: '', customer_state: 0, customer_zip: '', customer_apt: '', customer_home_type: 0, customer_phone_number: '', customer_latitude: 0, customer_longitude: 0, correct_address: true, account_number: '', auth_net_profile_id: null })
|
||||
@@ -555,6 +559,18 @@ const getCustomerAutoDelivery = (userid: number) => {
|
||||
deliveryService.auto.getProfileDeliveries(userid).then((response: AxiosResponse<any>) => {
|
||||
autodeliveries.value = response.data || []
|
||||
})
|
||||
// Also fetch the auto delivery record for estimation data
|
||||
deliveryService.auto.getByCustomer(userid).then((response: AxiosResponse<any>) => {
|
||||
if (response.data && response.data.id) {
|
||||
autoEstimation.value = {
|
||||
confidence_score: response.data.confidence_score ?? 20,
|
||||
k_factor_source: response.data.k_factor_source ?? 'default',
|
||||
days_remaining: response.data.days_remaining ?? 999
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
autoEstimation.value = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const getCustomerDelivery = (userid: number, delivery_page: number) => {
|
||||
@@ -1026,3 +1042,9 @@ const getAccountStatusMessage = (): string => {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-glass {
|
||||
@apply bg-gradient-to-br from-neutral/90 to-neutral/70 backdrop-blur-sm rounded-xl shadow-lg border border-base-content/5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<!-- src/pages/customer/profile/profile/AutomaticDeliveries.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title">Automatic Delivery History</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="overflow-x-auto">
|
||||
<div>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-lg mx-4 mb-4 mt-4">
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -32,7 +29,6 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<!-- src/pages/customer/profile/profile/CreditCards.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-glass">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="card-title flex justify-between items-center">
|
||||
<h2>Credit Cards</h2>
|
||||
<h2 class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-success">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
Credit Cards
|
||||
</h2>
|
||||
<router-link :to="{ name: 'cardadd', params: { id: user_id } }">
|
||||
<button class="btn btn-xs btn-outline btn-success">Add New</button>
|
||||
</router-link>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<!-- src/pages/customer/profile/profile/CustomerComments.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-glass">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title">Comments & Notes</h2>
|
||||
<h2 class="card-title flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-accent">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
</svg>
|
||||
</div>
|
||||
Comments & Notes
|
||||
</h2>
|
||||
|
||||
<!-- Styled Form for Adding a New Comment -->
|
||||
<form class="mt-4" @submit.prevent="handleSubmit">
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<!-- src/pages/customer/profile/profile/CustomerStats.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-glass">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title">Stats</h2>
|
||||
<h2 class="card-title flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-info/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-info">
|
||||
<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>
|
||||
</div>
|
||||
Stats
|
||||
</h2>
|
||||
<div class="text-sm mt-2 space-y-1">
|
||||
<div class="flex justify-between"><span>Total Deliveries:</span> <strong>{{ stats.oil_deliveries }}</strong></div>
|
||||
<div class="flex justify-between"><span>Total Gallons:</span> <strong>{{ stats.oil_total_gallons }}</strong></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- src/pages/customer/profile/profile/DeliveriesTable.vue -->
|
||||
<template>
|
||||
<div v-if="!deliveries || deliveries.length === 0" class="text-center p-10">
|
||||
<p>No will-call delivery history found.</p>
|
||||
<p>No will-call orders found.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- DESKTOP TABLE -->
|
||||
<div class="overflow-x-auto hidden lg:block">
|
||||
<div class="overflow-x-auto hidden lg:block bg-base-100 rounded-lg">
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<!-- src/pages/customer/profile/profile/EquipmentParts.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-glass">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="card-title flex justify-between items-center">
|
||||
<h2>Equipment Parts</h2>
|
||||
<h2 class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-warning">
|
||||
<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>
|
||||
</div>
|
||||
Equipment Parts
|
||||
</h2>
|
||||
<button @click="$emit('open-parts-modal')" class="btn btn-xs btn-outline btn-success">
|
||||
Edit
|
||||
</button>
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
<template>
|
||||
<div role="tablist" class="tabs tabs-lifted">
|
||||
<a role="tab" class="tab [--tab-bg:oklch(var(--b1))] text-base-content" :class="{ 'tab-active': activeTab === 'deliveries' }" @click="activeTab = 'deliveries'">
|
||||
Will-Call Deliveries
|
||||
Will Call
|
||||
</a>
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-4" v-show="activeTab === 'deliveries'">
|
||||
<div role="tabpanel" class="tab-content card-glass rounded-box p-4" v-show="activeTab === 'deliveries'">
|
||||
<DeliveriesTable :deliveries="deliveries" />
|
||||
</div>
|
||||
|
||||
<a role="tab" class="tab [--tab-bg:oklch(var(--b1))] text-base-content" :class="{ 'tab-active': activeTab === 'service' }" @click="activeTab = 'service'">
|
||||
Service History
|
||||
Service
|
||||
</a>
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-4" v-show="activeTab === 'service'">
|
||||
<div role="tabpanel" class="tab-content card-glass rounded-box p-4" v-show="activeTab === 'service'">
|
||||
<ServiceCallsTable :service-calls="serviceCalls" @open-service-modal="(service: ServiceCall) => $emit('openServiceModal', service)" />
|
||||
</div>
|
||||
|
||||
<a role="tab" class="tab [--tab-bg:oklch(var(--b1))] text-base-content" :class="{ 'tab-active': activeTab === 'transactions' }" @click="activeTab = 'transactions'">
|
||||
Transactions
|
||||
</a>
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-4" v-show="activeTab === 'transactions'">
|
||||
<div role="tabpanel" class="tab-content card-glass rounded-box p-4" v-show="activeTab === 'transactions'">
|
||||
<TransactionsTable :transactions="transactions" />
|
||||
</div>
|
||||
|
||||
<a role="tab" class="tab [--tab-bg:oklch(var(--b1))] text-base-content" :class="{ 'tab-active': activeTab === 'automatic' }" @click="activeTab = 'automatic'" >
|
||||
Automatic Deliveries
|
||||
Automatic
|
||||
</a>
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-4" v-show="activeTab === 'automatic'">
|
||||
<div role="tabpanel" class="tab-content card-glass rounded-box p-4" v-show="activeTab === 'automatic'">
|
||||
<AutomaticDeliveries :deliveries="autodeliveries" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- src/pages/customer/profile/profile/ProfileMap.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-glass h-full">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title text-2xl mb-4">{{ customer.account_number }}</h2>
|
||||
<div class="rounded-lg overflow-hidden h-full min-h-[400px]">
|
||||
<div class="rounded-lg overflow-hidden min-h-[500px] h-[500px] z-0 relative">
|
||||
<l-map ref="map" v-model:zoom="zoom" :center="[customer.customer_latitude, customer.customer_longitude]">
|
||||
<l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="OpenStreetMap"></l-tile-layer>
|
||||
</l-map>
|
||||
|
||||
@@ -1,74 +1,97 @@
|
||||
<!-- src/pages/customer/profile/profile/ProfileSummary.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="card-glass h-fit">
|
||||
<div class="card-body p-4 sm:p-5">
|
||||
<!-- Action Buttons - Two Row Layout -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<!-- Row 1 -->
|
||||
<router-link :to="{ name: 'deliveryCreate', params: { id: customer.id } }"
|
||||
class="btn btn-primary min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="btn btn-primary btn-sm h-auto py-2 flex flex-col items-center justify-center gap-1 hover:scale-105 transition-transform">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium ">Delivery</span>
|
||||
<span class="text-[10px] uppercase font-bold">Delivery</span>
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'CalenderCustomer', params: { id: customer.id } }"
|
||||
class="btn btn-info min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="btn btn-info btn-sm h-auto py-2 flex flex-col items-center justify-center gap-1 hover:scale-105 transition-transform">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium ">Service</span>
|
||||
<span class="text-[10px] uppercase font-bold">Service</span>
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'customerEdit', params: { id: customer.id } }"
|
||||
class="btn btn-secondary min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="btn btn-secondary btn-sm h-auto py-2 flex flex-col items-center justify-center gap-1 hover:scale-105 transition-transform">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium ">Edit</span>
|
||||
<span class="text-[10px] uppercase font-bold">Edit</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<router-link :to="{ name: 'servicePlanEdit', params: { id: customer.id } }"
|
||||
class="btn btn-accent min-h-[3rem] flex flex-col items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="btn btn-accent btn-sm h-auto py-2 flex flex-col items-center justify-center gap-1 hover:scale-105 transition-transform">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium ">Contract</span>
|
||||
<span class="text-[10px] uppercase font-bold">Contract</span>
|
||||
</router-link>
|
||||
|
||||
<button @click="$emit('toggleAutomatic', customer.id)"
|
||||
class="btn min-h-[3rem] flex flex-col items-center justify-center gap-1 col-span-2"
|
||||
class="btn btn-sm h-auto py-2 flex flex-col items-center justify-center gap-1 col-span-2 hover:scale-105 transition-transform"
|
||||
:class="automatic_status === 1 ? 'btn-success' : 'btn-warning'">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium ">{{ automatic_status === 1 ? 'Set to Will Call' : 'Set to Auto' }}</span>
|
||||
<span class="text-[10px] uppercase font-bold">{{ automatic_status === 1 ? 'Set to Will Call' : 'Set to Auto' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider my-4"></div>
|
||||
<div class="divider my-3"></div>
|
||||
|
||||
<!-- Customer Details -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title text-lg">{{ customer.customer_first_name }} {{ customer.customer_last_name }}</h2>
|
||||
<span class="badge" :class="automatic_status === 1 ? 'badge-success' : 'badge-ghost'">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h2 class="text-xl font-bold text-base-content">
|
||||
{{ customer.customer_first_name }} {{ customer.customer_last_name }}
|
||||
</h2>
|
||||
<span class="badge badge-sm" :class="automatic_status === 1 ? 'badge-success' : 'badge-ghost'">
|
||||
{{ automatic_status === 1 ? 'Automatic' : 'Will Call' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-error font-semibold mt-2" v-if="!customer.correct_address">
|
||||
<div class="text-error text-xs font-semibold mt-1" v-if="!customer.correct_address">
|
||||
Possible Incorrect Address!
|
||||
</div>
|
||||
<div class="mt-4 space-y-2 text-sm">
|
||||
<div class="mt-2 space-y-1 text-sm bg-base-100/30 p-3 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 font-medium opacity-80">
|
||||
<p>{{ customer.customer_address }}<span v-if="customer.customer_apt">, {{ customer.customer_apt }}</span></p>
|
||||
<p>{{ customer.customer_town }}, {{ stateName(customer.customer_state) }} {{ customer.customer_zip }}</p>
|
||||
<p class="pt-2">{{ customer.customer_phone_number }}</p>
|
||||
<p><span class="badge badge-outline badge-sm">{{ homeTypeName(customer.customer_home_type) }}</span></p>
|
||||
<hr class="my-2" v-if="customer_description">
|
||||
<p v-if="customer_description" class="text-sm">{{ customer_description }}</p>
|
||||
</div>
|
||||
<a :href="googleMapsUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-circle btn-xs btn-ghost hover:btn-primary transition-colors"
|
||||
title="Open in Google Maps">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" 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>
|
||||
</div>
|
||||
<div class="divider my-1 opacity-10"></div>
|
||||
<p class="flex items-center gap-2">
|
||||
<svg class="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
|
||||
{{ customer.customer_phone_number }}
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<svg class="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
<span class="badge badge-outline badge-xs">{{ homeTypeName(customer.customer_home_type) }}</span>
|
||||
</p>
|
||||
<div v-if="customer_description">
|
||||
<div class="divider my-1 opacity-10"></div>
|
||||
<p class="text-xs italic opacity-70">{{ customer_description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,6 +99,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
customer_first_name: string;
|
||||
@@ -96,9 +121,15 @@ interface Props {
|
||||
customer_description?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
defineEmits(['toggleAutomatic']);
|
||||
|
||||
const stateName = (id: number) => ['MA', 'RI', 'NH', 'ME', 'VT', 'CT', 'NY'][id] || 'N/A';
|
||||
const homeTypeName = (id: number) => ['Residential', 'Apartment', 'Condo', 'Commercial', 'Business', 'Construction', 'Container'][id] || 'Unknown';
|
||||
|
||||
const googleMapsUrl = computed(() => {
|
||||
const address = `${props.customer.customer_address}${props.customer.customer_apt ? ', ' + props.customer.customer_apt : ''}, ${props.customer.customer_town}, ${stateName(props.customer.customer_state)} ${props.customer.customer_zip}`;
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!serviceCalls || serviceCalls.length === 0" class="text-center p-10">
|
||||
<p>No service call history found for this customer.</p>
|
||||
<p>No service calls found.</p>
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div v-else class="overflow-x-auto bg-base-100 rounded-lg">
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<!-- src/pages/customer/profile/profile/TankInfo.vue -->
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-glass">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="card-title flex justify-between items-center">
|
||||
<h2>Tank Info</h2>
|
||||
<h2 class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-primary">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
Tank Info
|
||||
</h2>
|
||||
<router-link :to="{ name: 'TankEdit', params: { id: customer_id } }" class="btn btn-xs btn-outline btn-success">
|
||||
Edit
|
||||
</router-link>
|
||||
@@ -19,6 +26,30 @@
|
||||
<div class="flex justify-between"><span>Location:</span> <strong class="badge" :class="tank.outside_or_inside ? '' : 'badge-warning'">{{ tank.outside_or_inside ? 'Inside' : 'Outside' }}</strong></div>
|
||||
<div class="flex justify-between"><span>Size:</span> <strong>{{ tank.tank_size }} Gallons</strong></div>
|
||||
<div class="flex justify-between"><span>Fill Location:</span> <strong>{{ description.fill_location }}</strong></div>
|
||||
|
||||
<!-- Estimation data (when available) -->
|
||||
<template v-if="estimation">
|
||||
<div class="divider my-1"></div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Confidence:</span>
|
||||
<span class="badge badge-sm" :class="getConfidenceBadge(estimation.confidence_score)">
|
||||
{{ estimation.confidence_score ?? 20 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>K-Factor Source:</span>
|
||||
<strong class="capitalize">{{ estimation.k_factor_source || 'default' }}</strong>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Days Remaining:</span>
|
||||
<span v-if="estimation.days_remaining >= 999" class="text-base-content/40">N/A</span>
|
||||
<strong v-else :class="{
|
||||
'text-error': estimation.days_remaining <= 7,
|
||||
'text-warning': estimation.days_remaining > 7 && estimation.days_remaining <= 14,
|
||||
'text-success': estimation.days_remaining > 14
|
||||
}">{{ estimation.days_remaining }} days</strong>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,6 +59,15 @@
|
||||
defineProps({
|
||||
customer_id: { type: Number, required: true },
|
||||
tank: { type: Object, required: true },
|
||||
description: { type: Object, required: true }
|
||||
description: { type: Object, required: true },
|
||||
estimation: { type: Object, default: null }
|
||||
});
|
||||
|
||||
const getConfidenceBadge = (score: number | null | undefined): string => {
|
||||
const s = score ?? 20
|
||||
if (s >= 70) return 'badge-success'
|
||||
if (s >= 40) return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- src/pages/customer/profile/profile/TransactionsTable.vue -->
|
||||
<template>
|
||||
<div v-if="!transactions || transactions.length === 0" class="text-center p-10">
|
||||
<p>No transaction history found.</p>
|
||||
<p>No transactions found.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- DESKTOP TABLE -->
|
||||
<div class="overflow-x-auto hidden lg:block">
|
||||
<div class="overflow-x-auto hidden lg:block bg-base-100 rounded-lg">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -161,13 +161,46 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Fees -->
|
||||
<!-- Fees with Tier Selection -->
|
||||
<div class="p-4">
|
||||
<label class="label-text font-bold">Fees & Options</label>
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<div class="form-control"><label class="label cursor-pointer gap-2"><span class="label-text">Emergency</span><input v-model="formDelivery.emergency" type="checkbox" class="checkbox checkbox-xs" /></label></div>
|
||||
<div class="form-control"><label class="label cursor-pointer gap-2"><span class="label-text">Prime</span><input v-model="formDelivery.prime" type="checkbox" class="checkbox checkbox-xs" /></label></div>
|
||||
<div class="form-control"><label class="label cursor-pointer gap-2"><span class="label-text">Same Day</span><input v-model="formDelivery.same_day" type="checkbox" class="checkbox checkbox-xs" /></label></div>
|
||||
<label class="label-text font-bold">Service Fees (Select Tier)</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-3">
|
||||
<!-- Same Day Tier -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Same Day</span></label>
|
||||
<select v-model.number="formDelivery.pricing_tier_same_day" class="select select-bordered select-sm w-full">
|
||||
<option :value="0">None</option>
|
||||
<option :value="1">Tier 1 ({{ formatCurrency(tierPricing.same_day_tier1) }})</option>
|
||||
<option :value="2">Tier 2 ({{ formatCurrency(tierPricing.same_day_tier2) }})</option>
|
||||
<option :value="3">Tier 3 ({{ formatCurrency(tierPricing.same_day_tier3) }})</option>
|
||||
<option :value="4">Tier 4 ({{ formatCurrency(tierPricing.same_day_tier4) }})</option>
|
||||
<option :value="5">Tier 5 ({{ formatCurrency(tierPricing.same_day_tier5) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Prime Tier -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Prime</span></label>
|
||||
<select v-model.number="formDelivery.pricing_tier_prime" class="select select-bordered select-sm w-full">
|
||||
<option :value="0">None</option>
|
||||
<option :value="1">Tier 1 ({{ formatCurrency(tierPricing.prime_tier1) }})</option>
|
||||
<option :value="2">Tier 2 ({{ formatCurrency(tierPricing.prime_tier2) }})</option>
|
||||
<option :value="3">Tier 3 ({{ formatCurrency(tierPricing.prime_tier3) }})</option>
|
||||
<option :value="4">Tier 4 ({{ formatCurrency(tierPricing.prime_tier4) }})</option>
|
||||
<option :value="5">Tier 5 ({{ formatCurrency(tierPricing.prime_tier5) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Emergency Tier -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Emergency</span></label>
|
||||
<select v-model.number="formDelivery.pricing_tier_emergency" class="select select-bordered select-sm w-full">
|
||||
<option :value="0">None</option>
|
||||
<option :value="1">Tier 1 ({{ formatCurrency(tierPricing.emergency_tier1) }})</option>
|
||||
<option :value="2">Tier 2 ({{ formatCurrency(tierPricing.emergency_tier2) }})</option>
|
||||
<option :value="3">Tier 3 ({{ formatCurrency(tierPricing.emergency_tier3) }})</option>
|
||||
<option :value="4">Tier 4 ({{ formatCurrency(tierPricing.emergency_tier4) }})</option>
|
||||
<option :value="5">Tier 5 ({{ formatCurrency(tierPricing.emergency_tier5) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,7 +377,11 @@ interface DeliveryFormData {
|
||||
credit_card_id?: number;
|
||||
promo_id?: number;
|
||||
driver_employee_id?: number;
|
||||
payment_type?: number; // Added for API payload construction
|
||||
payment_type?: number;
|
||||
// Tier fields
|
||||
pricing_tier_same_day?: number;
|
||||
pricing_tier_prime?: number;
|
||||
pricing_tier_emergency?: number;
|
||||
}
|
||||
// Simplified Quick Add Card form data
|
||||
interface CardFormData {
|
||||
@@ -380,6 +417,9 @@ const formDelivery = ref({
|
||||
prime: false,
|
||||
emergency: false,
|
||||
same_day: false,
|
||||
pricing_tier_same_day: 0,
|
||||
pricing_tier_prime: 0,
|
||||
pricing_tier_emergency: 0,
|
||||
credit: false,
|
||||
cash: false,
|
||||
check: false,
|
||||
@@ -388,6 +428,13 @@ const formDelivery = ref({
|
||||
promo_id: 0,
|
||||
driver_employee_id: 0,
|
||||
} as DeliveryFormData)
|
||||
|
||||
// Tier pricing from API
|
||||
const tierPricing = ref({
|
||||
same_day_tier1: 0, same_day_tier2: 0, same_day_tier3: 0, same_day_tier4: 0, same_day_tier5: 0,
|
||||
prime_tier1: 0, prime_tier2: 0, prime_tier3: 0, prime_tier4: 0, prime_tier5: 0,
|
||||
emergency_tier1: 0, emergency_tier2: 0, emergency_tier3: 0, emergency_tier4: 0, emergency_tier5: 0,
|
||||
})
|
||||
// Simplified formCard data
|
||||
const formCard = ref({
|
||||
card_number: '',
|
||||
@@ -509,6 +556,14 @@ const isPricingTierSelected = (tierGallons: number | string): boolean => {
|
||||
return selectedGallons === tierNum;
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number | string | undefined) => {
|
||||
if (value === undefined || value === null) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(Number(value));
|
||||
}
|
||||
|
||||
const getPricingTiers = async () => {
|
||||
try {
|
||||
const response = await queryService.getOilPriceTiers();
|
||||
@@ -601,6 +656,11 @@ const proceedWithSubmission = async () => {
|
||||
else if(formDelivery.value.check) paymentType = 3;
|
||||
else if(formDelivery.value.other) paymentType = 4;
|
||||
|
||||
// Update boolean flags based on tier selection for backward compatibility and logic
|
||||
formDelivery.value.same_day = (formDelivery.value.pricing_tier_same_day || 0) > 0;
|
||||
formDelivery.value.prime = (formDelivery.value.pricing_tier_prime || 0) > 0;
|
||||
formDelivery.value.emergency = (formDelivery.value.pricing_tier_emergency || 0) > 0;
|
||||
|
||||
const payload = {
|
||||
...formDelivery.value,
|
||||
payment_type: paymentType,
|
||||
@@ -744,6 +804,33 @@ onMounted(() => {
|
||||
getDriversList()
|
||||
getPromos()
|
||||
getPricingTiers()
|
||||
|
||||
// Fetch full pricing table to populate tier dropdowns
|
||||
queryService.getOilPriceTable().then((response: any) => {
|
||||
const data = response.data.pricing;
|
||||
if (data) {
|
||||
tierPricing.value = {
|
||||
same_day_tier1: data.price_same_day_tier1 || data.price_same_day,
|
||||
same_day_tier2: data.price_same_day_tier2,
|
||||
same_day_tier3: data.price_same_day_tier3,
|
||||
same_day_tier4: data.price_same_day_tier4,
|
||||
same_day_tier5: data.price_same_day_tier5,
|
||||
|
||||
prime_tier1: data.price_prime_tier1 || data.price_prime,
|
||||
prime_tier2: data.price_prime_tier2,
|
||||
prime_tier3: data.price_prime_tier3,
|
||||
prime_tier4: data.price_prime_tier4,
|
||||
prime_tier5: data.price_prime_tier5,
|
||||
|
||||
emergency_tier1: data.price_emergency_tier1 || data.price_emergency,
|
||||
emergency_tier2: data.price_emergency_tier2,
|
||||
emergency_tier3: data.price_emergency_tier3,
|
||||
emergency_tier4: data.price_emergency_tier4,
|
||||
emergency_tier5: data.price_emergency_tier5,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const customerId = route.params.id;
|
||||
getCustomer(customerId)
|
||||
getPaymentCards(customerId);
|
||||
|
||||
@@ -154,13 +154,46 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Fees & Options -->
|
||||
<!-- Fees & Options (Tier Selection) -->
|
||||
<div class="p-4 rounded-md space-y-2">
|
||||
<label class="label-text font-bold">Fees & Options</label>
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<div class="form-control"><label class="label cursor-pointer gap-2"><span class="label-text">Emergency</span><input v-model="CreateOilOrderForm.basicInfo.emergency" type="checkbox" class="checkbox checkbox-xs" /></label></div>
|
||||
<div class="form-control"><label class="label cursor-pointer gap-2"><span class="label-text">Prime</span><input v-model="CreateOilOrderForm.basicInfo.prime" type="checkbox" class="checkbox checkbox-xs" /></label></div>
|
||||
<div class="form-control"><label class="label cursor-pointer gap-2"><span class="label-text">Same Day</span><input v-model="CreateOilOrderForm.basicInfo.same_day" type="checkbox" class="checkbox checkbox-xs" /></label></div>
|
||||
<label class="label-text font-bold">Fees & Options (Select Tier)</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-3">
|
||||
<!-- Same Day Tier -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Same Day</span></label>
|
||||
<select v-model.number="CreateOilOrderForm.basicInfo.pricing_tier_same_day" class="select select-bordered select-sm w-full">
|
||||
<option :value="0">None</option>
|
||||
<option :value="1">Tier 1 ({{ formatCurrency(tierPricing.same_day_tier1) }})</option>
|
||||
<option :value="2">Tier 2 ({{ formatCurrency(tierPricing.same_day_tier2) }})</option>
|
||||
<option :value="3">Tier 3 ({{ formatCurrency(tierPricing.same_day_tier3) }})</option>
|
||||
<option :value="4">Tier 4 ({{ formatCurrency(tierPricing.same_day_tier4) }})</option>
|
||||
<option :value="5">Tier 5 ({{ formatCurrency(tierPricing.same_day_tier5) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Prime Tier -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Prime</span></label>
|
||||
<select v-model.number="CreateOilOrderForm.basicInfo.pricing_tier_prime" class="select select-bordered select-sm w-full">
|
||||
<option :value="0">None</option>
|
||||
<option :value="1">Tier 1 ({{ formatCurrency(tierPricing.prime_tier1) }})</option>
|
||||
<option :value="2">Tier 2 ({{ formatCurrency(tierPricing.prime_tier2) }})</option>
|
||||
<option :value="3">Tier 3 ({{ formatCurrency(tierPricing.prime_tier3) }})</option>
|
||||
<option :value="4">Tier 4 ({{ formatCurrency(tierPricing.prime_tier4) }})</option>
|
||||
<option :value="5">Tier 5 ({{ formatCurrency(tierPricing.prime_tier5) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Emergency Tier -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Emergency</span></label>
|
||||
<select v-model.number="CreateOilOrderForm.basicInfo.pricing_tier_emergency" class="select select-bordered select-sm w-full">
|
||||
<option :value="0">None</option>
|
||||
<option :value="1">Tier 1 ({{ formatCurrency(tierPricing.emergency_tier1) }})</option>
|
||||
<option :value="2">Tier 2 ({{ formatCurrency(tierPricing.emergency_tier2) }})</option>
|
||||
<option :value="3">Tier 3 ({{ formatCurrency(tierPricing.emergency_tier3) }})</option>
|
||||
<option :value="4">Tier 4 ({{ formatCurrency(tierPricing.emergency_tier4) }})</option>
|
||||
<option :value="5">Tier 5 ({{ formatCurrency(tierPricing.emergency_tier5) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +292,11 @@ import adminService from '../../services/adminService';
|
||||
import queryService from '../../services/queryService';
|
||||
|
||||
// Interfaces to describe the shape of your data
|
||||
interface DeliveryOrder { id: string; customer_id: number; payment_type: number; payment_card_id: number; gallons_ordered: number; customer_asked_for_fill: boolean | number; delivery_status: number; driver_employee_id: number; promo_id: number; expected_delivery_date: string; when_ordered: string; prime: boolean | number; emergency: boolean | number; same_day: boolean | number; dispatcher_notes: string; }
|
||||
// Interfaces to describe the shape of your data
|
||||
interface DeliveryOrder {
|
||||
id: string; customer_id: number; payment_type: number; payment_card_id: number; gallons_ordered: number; customer_asked_for_fill: boolean | number; delivery_status: number; driver_employee_id: number; promo_id: number; expected_delivery_date: string; when_ordered: string; prime: boolean | number; emergency: boolean | number; same_day: boolean | number; dispatcher_notes: string;
|
||||
pricing_tier_same_day?: number; pricing_tier_prime?: number; pricing_tier_emergency?: number;
|
||||
}
|
||||
interface PricingTier { gallons: number; price: string | number; }
|
||||
|
||||
const router = useRouter()
|
||||
@@ -287,6 +324,9 @@ const CreateOilOrderForm = ref({
|
||||
prime: false,
|
||||
emergency: false,
|
||||
same_day: false,
|
||||
pricing_tier_same_day: 0,
|
||||
pricing_tier_prime: 0,
|
||||
pricing_tier_emergency: 0,
|
||||
delivery_status: 0,
|
||||
driver_employee_id: 0,
|
||||
dispatcher_notes_taken: '',
|
||||
@@ -300,6 +340,13 @@ const CreateOilOrderForm = ref({
|
||||
},
|
||||
})
|
||||
|
||||
// Tier pricing from API
|
||||
const tierPricing = ref({
|
||||
same_day_tier1: 0, same_day_tier2: 0, same_day_tier3: 0, same_day_tier4: 0, same_day_tier5: 0,
|
||||
prime_tier1: 0, prime_tier2: 0, prime_tier3: 0, prime_tier4: 0, prime_tier5: 0,
|
||||
emergency_tier1: 0, emergency_tier2: 0, emergency_tier3: 0, emergency_tier4: 0, emergency_tier5: 0,
|
||||
})
|
||||
|
||||
// Computed
|
||||
const stateName = computed((): string => {
|
||||
if (customer.value && customer.value.customer_state !== undefined) {
|
||||
@@ -321,6 +368,14 @@ const isPricingTierSelected = computed(() => {
|
||||
};
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number | string | undefined) => {
|
||||
if (value === undefined || value === null) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(Number(value));
|
||||
}
|
||||
|
||||
const isAnyPaymentMethodSelected = computed((): boolean => {
|
||||
return !!(CreateOilOrderForm.value.basicInfo?.credit || CreateOilOrderForm.value.basicInfo?.cash || CreateOilOrderForm.value.basicInfo?.check || CreateOilOrderForm.value.basicInfo?.other);
|
||||
})
|
||||
@@ -384,9 +439,16 @@ const getDeliveryOrder = async (deliveryId: string) => {
|
||||
customer_asked_for_fill: !!deliveryOrder.value.customer_asked_for_fill,
|
||||
created_delivery_date: deliveryOrder.value.when_ordered,
|
||||
expected_delivery_date: deliveryOrder.value.expected_delivery_date,
|
||||
// Set tiers from delivery order
|
||||
pricing_tier_same_day: deliveryOrder.value.pricing_tier_same_day || (deliveryOrder.value.same_day ? 1 : 0),
|
||||
pricing_tier_prime: deliveryOrder.value.pricing_tier_prime || (deliveryOrder.value.prime ? 1 : 0),
|
||||
pricing_tier_emergency: deliveryOrder.value.pricing_tier_emergency || (deliveryOrder.value.emergency ? 1 : 0),
|
||||
|
||||
// Set boolean flags for type compatibility and logic
|
||||
same_day: !!deliveryOrder.value.same_day,
|
||||
prime: !!deliveryOrder.value.prime,
|
||||
emergency: !!deliveryOrder.value.emergency,
|
||||
same_day: !!deliveryOrder.value.same_day,
|
||||
|
||||
delivery_status: deliveryOrder.value.delivery_status,
|
||||
driver_employee_id: deliveryOrder.value.driver_employee_id || 0,
|
||||
dispatcher_notes_taken: deliveryOrder.value.dispatcher_notes,
|
||||
@@ -475,6 +537,31 @@ const getPricingTiers = async () => {
|
||||
gallons: parseInt(gallons, 10),
|
||||
price: price as string | number,
|
||||
}));
|
||||
|
||||
// Fetch full pricing table for dropdowns
|
||||
const tableResponse = await queryService.getOilPriceTable();
|
||||
const data = tableResponse.data.pricing;
|
||||
if (data) {
|
||||
tierPricing.value = {
|
||||
same_day_tier1: data.price_same_day_tier1 || data.price_same_day,
|
||||
same_day_tier2: data.price_same_day_tier2,
|
||||
same_day_tier3: data.price_same_day_tier3,
|
||||
same_day_tier4: data.price_same_day_tier4,
|
||||
same_day_tier5: data.price_same_day_tier5,
|
||||
|
||||
prime_tier1: data.price_prime_tier1 || data.price_prime,
|
||||
prime_tier2: data.price_prime_tier2,
|
||||
prime_tier3: data.price_prime_tier3,
|
||||
prime_tier4: data.price_prime_tier4,
|
||||
prime_tier5: data.price_prime_tier5,
|
||||
|
||||
emergency_tier1: data.price_emergency_tier1 || data.price_emergency,
|
||||
emergency_tier2: data.price_emergency_tier2,
|
||||
emergency_tier3: data.price_emergency_tier3,
|
||||
emergency_tier4: data.price_emergency_tier4,
|
||||
emergency_tier5: data.price_emergency_tier5,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
notify({ title: "Pricing Error", text: "Could not retrieve today's pricing.", type: "error" });
|
||||
}
|
||||
@@ -534,6 +621,11 @@ const onSubmit = async () => {
|
||||
else if (formInfo.other) paymentType = 4;
|
||||
else if (formInfo.check) paymentType = 3;
|
||||
|
||||
// Update boolean flags based on tier selection
|
||||
formInfo.same_day = formInfo.pricing_tier_same_day > 0;
|
||||
formInfo.prime = formInfo.pricing_tier_prime > 0;
|
||||
formInfo.emergency = formInfo.pricing_tier_emergency > 0;
|
||||
|
||||
// The payload now automatically includes all the restored fields
|
||||
const payload = {
|
||||
...formInfo,
|
||||
|
||||
@@ -90,9 +90,9 @@
|
||||
</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 v-else 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>
|
||||
@@ -100,7 +100,7 @@
|
||||
<span v-if="oil.emergency" class="badge badge-error badge-xs">EMERGENCY</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="text-sm">
|
||||
<span v-if="oil.payment_type == 0">Cash</span>
|
||||
<span v-else-if="oil.payment_type == 1">CC</span>
|
||||
<span v-else-if="oil.payment_type == 2">Cash/CC</span>
|
||||
@@ -109,7 +109,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>
|
||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="!isFinalizedStatus(oil.delivery_status)" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||
@@ -169,7 +169,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>
|
||||
@@ -474,7 +474,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;
|
||||
|
||||
@@ -33,9 +33,24 @@
|
||||
<span class="badge badge-primary">{{ deliveries.length }} Deliveries</span>
|
||||
<span class="badge badge-secondary">{{ uniqueTowns.length }} Towns</span>
|
||||
<span class="badge badge-accent">{{ mappedCount }} Mapped</span>
|
||||
<span class="badge badge-success">{{ grandTotal.toLocaleString() }} Gallons</span>
|
||||
<span v-if="unmappedCount > 0" class="badge badge-warning">{{ unmappedCount }} Without Coordinates</span>
|
||||
</div>
|
||||
|
||||
<!-- Town Breakdown Bar -->
|
||||
<div v-if="!loading && townTotals.length > 0" class="mb-4 overflow-x-auto">
|
||||
<div class="flex gap-2 pb-2">
|
||||
<div
|
||||
v-for="total in townTotals"
|
||||
:key="total.town"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-base-200 text-sm whitespace-nowrap"
|
||||
>
|
||||
<span class="font-semibold">{{ total.town }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-base-content/10 text-xs font-mono">{{ total.gallons }} gal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
@@ -200,6 +215,28 @@ const uniqueTowns = computed(() =>
|
||||
[...new Set(deliveries.value.map(d => d.town))]
|
||||
);
|
||||
|
||||
interface TownTotal {
|
||||
town: string;
|
||||
gallons: number;
|
||||
}
|
||||
|
||||
const townTotals = computed((): TownTotal[] => {
|
||||
const totals: Record<string, number> = {};
|
||||
for (const delivery of deliveries.value) {
|
||||
const town = delivery.town || 'Unknown';
|
||||
// For fills, estimate 200 gallons; otherwise use ordered gallons
|
||||
const gallons = delivery.isFill ? 200 : (delivery.gallonsOrdered || 0);
|
||||
totals[town] = (totals[town] || 0) + gallons;
|
||||
}
|
||||
return Object.entries(totals)
|
||||
.map(([town, gallons]) => ({ town, gallons }))
|
||||
.sort((a, b) => b.gallons - a.gallons);
|
||||
});
|
||||
|
||||
const grandTotal = computed(() => {
|
||||
return townTotals.value.reduce((sum, t) => sum + t.gallons, 0);
|
||||
});
|
||||
|
||||
const groupedByTown = computed(() => {
|
||||
const groups: Record<string, DeliveryMapItem[]> = {};
|
||||
for (const delivery of deliveries.value) {
|
||||
|
||||
@@ -87,8 +87,18 @@
|
||||
</div>
|
||||
<!-- Fees -->
|
||||
<div class="text-sm space-y-1 border-t border-base-100 pt-3">
|
||||
<div v-if="deliveryOrder.prime == 1" class="flex justify-between"><span>Prime Fee</span> <span>${{ Number(pricing.price_prime).toFixed(2) }}</span></div>
|
||||
<div v-if="deliveryOrder.same_day === 1" class="flex justify-between"><span>Same Day Fee</span> <span>${{ Number(pricing.price_same_day).toFixed(2) }}</span></div>
|
||||
<div v-if="deliveryOrder.prime == 1" class="flex justify-between">
|
||||
<span>Prime Fee (Tier {{ deliveryOrder.pricing_tier_prime || 1 }})</span>
|
||||
<span>${{ Number(getTierPrice('prime', deliveryOrder.pricing_tier_prime)).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="deliveryOrder.same_day == 1" class="flex justify-between">
|
||||
<span>Same Day Fee (Tier {{ deliveryOrder.pricing_tier_same_day || 1 }})</span>
|
||||
<span>${{ Number(getTierPrice('same_day', deliveryOrder.pricing_tier_same_day)).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="deliveryOrder.emergency == 1" class="flex justify-between">
|
||||
<span>Emergency Fee (Tier {{ deliveryOrder.pricing_tier_emergency || 1 }})</span>
|
||||
<span>${{ Number(getTierPrice('emergency', deliveryOrder.pricing_tier_emergency)).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Payment -->
|
||||
<div class="border-t border-base-100 pt-3">
|
||||
@@ -319,14 +329,15 @@ const deliveryOrder = ref({
|
||||
customer_price: '',
|
||||
prime: 0,
|
||||
same_day: 0,
|
||||
emergency: 0,
|
||||
pricing_tier_same_day: 0,
|
||||
pricing_tier_prime: 0,
|
||||
pricing_tier_emergency: 0,
|
||||
payment_type: 0,
|
||||
payment_card_id: '',
|
||||
promo_id: null,
|
||||
})
|
||||
const pricing = ref({
|
||||
price_prime: 0,
|
||||
price_same_day: 0,
|
||||
})
|
||||
const pricing = ref({} as any)
|
||||
const promo_active = ref(false)
|
||||
const promo = ref({
|
||||
name_of_promotion: '',
|
||||
@@ -356,11 +367,28 @@ const finalChargeAmount = computed((): number => {
|
||||
return 0;
|
||||
}
|
||||
let total = gallons * pricePerGallon;
|
||||
if (deliveryOrder.value.prime === 1) total += Number(pricing.value.price_prime);
|
||||
if (deliveryOrder.value.same_day === 1) total += Number(pricing.value.price_same_day);
|
||||
|
||||
if (deliveryOrder.value.prime === 1) {
|
||||
total += Number(getTierPrice('prime', deliveryOrder.value.pricing_tier_prime));
|
||||
}
|
||||
if (deliveryOrder.value.same_day === 1) {
|
||||
total += Number(getTierPrice('same_day', deliveryOrder.value.pricing_tier_same_day));
|
||||
}
|
||||
if (deliveryOrder.value.emergency === 1) {
|
||||
total += Number(getTierPrice('emergency', deliveryOrder.value.pricing_tier_emergency));
|
||||
}
|
||||
|
||||
return total;
|
||||
})
|
||||
|
||||
const getTierPrice = (serviceType: string, tier: number | undefined) => {
|
||||
const tierNum = tier || 1;
|
||||
const key = `price_${serviceType}_tier${tierNum}`;
|
||||
// Fallback to legacy single price if tier specific not found, though API should provide all
|
||||
const price = pricing.value[key] !== undefined ? pricing.value[key] : pricing.value[`price_${serviceType}`];
|
||||
return Number(price || 0);
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
const deliveryId = route.params.id;
|
||||
|
||||
@@ -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">
|
||||
<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;
|
||||
|
||||
@@ -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 delivered deliveries awaiting finalization</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">
|
||||
<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-success">Delivered</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 v-else 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>
|
||||
@@ -129,7 +162,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>
|
||||
@@ -237,6 +270,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()
|
||||
@@ -394,7 +443,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;
|
||||
|
||||
@@ -33,18 +33,29 @@
|
||||
</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 finalized 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>Ticket #</th>
|
||||
<th>Name</th>
|
||||
<th>Customer</th>
|
||||
<th>Status</th>
|
||||
<th>Town / Address</th>
|
||||
<th>Address</th>
|
||||
<th>Gallons Delivered</th>
|
||||
<th>Date</th>
|
||||
<th>Options</th>
|
||||
@@ -53,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">
|
||||
<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-success">Finalized</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 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_delivered }} gal</span>
|
||||
<span class="text-success font-mono font-bold">{{ oil.gallons_delivered }} 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>
|
||||
@@ -81,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>
|
||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||
</div>
|
||||
@@ -128,7 +162,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">
|
||||
{{ oil.gallons_delivered }} gal
|
||||
</p>
|
||||
</div>
|
||||
@@ -235,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()
|
||||
@@ -392,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;
|
||||
|
||||
@@ -33,18 +33,29 @@
|
||||
</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 deliveries with issues</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>Ticket #</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>
|
||||
@@ -53,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">
|
||||
<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">Issue</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>
|
||||
@@ -81,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>
|
||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||
</div>
|
||||
@@ -128,7 +162,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 +270,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 +443,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;
|
||||
|
||||
@@ -35,15 +35,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 pending deliveries awaiting payment or credit approval</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>Payment</th>
|
||||
<th>Options</th>
|
||||
@@ -52,13 +62,23 @@
|
||||
</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">
|
||||
<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" :class="{
|
||||
@@ -69,20 +89,33 @@
|
||||
}">
|
||||
<span v-if="oil.delivery_status == 0">Waiting</span>
|
||||
<span v-else-if="oil.delivery_status == 1">Cancelled</span>
|
||||
<span v-else-if="oil.delivery_status == 2">Out_for_Delivery</span>
|
||||
<span v-else-if="oil.delivery_status == 2">Out for Delivery</span>
|
||||
<span v-else-if="oil.delivery_status == 10">Finalized</span>
|
||||
<span v-else>N/A</span>
|
||||
</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 v-else class="text-success font-mono font-bold">{{ oil.gallons_ordered }} gal</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="text-sm">
|
||||
<span v-if="oil.payment_type == 0">Cash</span>
|
||||
<span v-else-if="oil.payment_type == 1">CC</span>
|
||||
<span v-else-if="oil.payment_type == 2">Cash/CC</span>
|
||||
@@ -98,7 +131,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>
|
||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||
@@ -111,7 +144,7 @@
|
||||
</div>
|
||||
|
||||
<!-- MOBILE VIEW: Cards -->
|
||||
<div class="xl:hidden space-y-4">
|
||||
<div v-if="deliveries.length > 0" class="xl:hidden space-y-4">
|
||||
<template v-for="oil in deliveries" :key="oil.id">
|
||||
<div
|
||||
v-if="oil.id"
|
||||
@@ -157,7 +190,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>
|
||||
@@ -274,6 +307,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()
|
||||
@@ -431,7 +480,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;
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<router-link :to="{ name: 'deliveryMap' }" class="btn btn-outline btn-primary gap-2">
|
||||
<router-link :to="{ name: 'deliveryMap' }" class="btn btn-outline btn-info gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z" />
|
||||
</svg>
|
||||
@@ -126,7 +126,8 @@
|
||||
</th>
|
||||
<th>Customer</th>
|
||||
<th class="w-32">Status</th>
|
||||
<th>Location</th>
|
||||
<th>Address</th>
|
||||
|
||||
<th class="w-28">
|
||||
<button @click="toggleSort('gallons')" class="sort-header">
|
||||
<span>Gallons</span>
|
||||
@@ -149,18 +150,27 @@
|
||||
<span class="font-mono text-sm font-semibold text-base-content/70">#{{ oil.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<router-link
|
||||
v-if="oil.customer_id"
|
||||
:to="{ name: 'customerProfile', params: { id: oil.customer_id } }"
|
||||
class="font-semibold hover:text-primary transition-colors"
|
||||
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 class="font-semibold">{{ oil.customer_name }}</span>
|
||||
<div v-else>
|
||||
<p class="font-bold text-success">{{ oil.customer_name }}</p>
|
||||
<div class="text-xs font-mono opacity-60">#{{ oil.id }}</div>
|
||||
</div>
|
||||
<!-- Special Tags -->
|
||||
<div class="flex gap-1 mt-0.5">
|
||||
<div class="flex gap-1 mt-1">
|
||||
<span v-if="oil.emergency" class="special-tag tag-emergency">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
@@ -180,8 +190,6 @@
|
||||
SAME DAY
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="status-badge" :class="getStatusClass(oil.delivery_status)">
|
||||
@@ -190,14 +198,19 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<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 text-base-content/40 mt-0.5 flex-shrink-0">
|
||||
<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>
|
||||
<div>
|
||||
<div class="font-medium">{{ oil.customer_town }}</div>
|
||||
<div class="text-sm text-base-content/60">{{ oil.customer_address }}</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -208,14 +221,13 @@
|
||||
</svg>
|
||||
FILL
|
||||
</div>
|
||||
<div v-else class="gallons-badge">
|
||||
{{ oil.gallons_ordered }}
|
||||
<span class="text-xs text-base-content/50">gal</span>
|
||||
<div v-else class="text-success font-mono font-bold">
|
||||
{{ oil.gallons_ordered }} gal
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-ghost" title="View">
|
||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-xs btn-neutral btn-outline" title="View">
|
||||
View
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-xs btn-info btn-outline" title="Edit">
|
||||
@@ -289,7 +301,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">Gallons</span>
|
||||
<p class="font-medium">
|
||||
<p class="font-bold text-success">
|
||||
<span v-if="oil.customer_asked_for_fill == 1" class="badge badge-info">FILL</span>
|
||||
<span v-else>{{ oil.gallons_ordered }} gal</span>
|
||||
</p>
|
||||
@@ -298,7 +310,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-3 border-t border-base-content/10">
|
||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-ghost flex-1">
|
||||
<router-link :to="{ name: 'deliveryOrder', params: { id: oil.id } }" class="btn btn-sm btn-neutral btn-outline flex-1">
|
||||
View
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'deliveryEdit', params: { id: oil.id } }" class="btn btn-sm btn-info btn-outline flex-1">
|
||||
@@ -492,6 +504,22 @@ const get_totals = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -650,7 +678,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;
|
||||
|
||||
@@ -87,31 +87,53 @@
|
||||
<!-- Main Table Card -->
|
||||
<div class="modern-table-card">
|
||||
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredDeliveries.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">{{ searchQuery || filterTown ? 'Try adjusting your search or filter' : 'No deliveries scheduled for tomorrow' }}</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 class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="oil in filteredDeliveries" :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">
|
||||
<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>
|
||||
<!-- Special Tags -->
|
||||
<div class="flex gap-1 mt-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>
|
||||
<span class="badge badge-sm" :class="{
|
||||
@@ -122,7 +144,7 @@
|
||||
}">
|
||||
<span v-if="oil.delivery_status == 0">Waiting</span>
|
||||
<span v-else-if="oil.delivery_status == 1">Cancelled</span>
|
||||
<span v-else-if="oil.delivery_status == 2">Out_for_Delivery</span>
|
||||
<span v-else-if="oil.delivery_status == 2">Out for Delivery</span>
|
||||
<span v-else-if="oil.delivery_status == 3">Tomorrow</span>
|
||||
<span v-else-if="oil.delivery_status == 4">Partial</span>
|
||||
<span v-else-if="oil.delivery_status == 5">Issue</span>
|
||||
@@ -130,22 +152,29 @@
|
||||
</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>
|
||||
</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>
|
||||
<span class="text-success font-mono font-bold">{{ oil.gallons_ordered }} gal</span>
|
||||
</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>
|
||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" v-if="oil.delivery_status != 10" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||
@@ -206,7 +235,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>
|
||||
@@ -430,6 +459,22 @@ const printTicket = 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()
|
||||
|
||||
@@ -87,17 +87,25 @@
|
||||
<!-- Main Table Card -->
|
||||
<div class="modern-table-card">
|
||||
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredDeliveries.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">{{ searchQuery || filterTown ? 'Try adjusting your search or filter' : 'No deliveries are waiting for dispatch' }}</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>
|
||||
@@ -106,13 +114,23 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="oil in filteredDeliveries" :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">
|
||||
<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-warning">
|
||||
@@ -120,14 +138,27 @@
|
||||
</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 v-else 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>
|
||||
@@ -136,7 +167,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>
|
||||
<router-link :to="{ name: 'finalizeTicket', params: { id: oil.id } }" class="btn btn-xs btn-accent btn-outline">Finalize</router-link>
|
||||
<router-link :to="{ name: 'Ticket', params: { id: oil.id } }" class="btn btn-xs btn-success btn-outline">Print</router-link>
|
||||
@@ -184,7 +215,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>
|
||||
@@ -345,6 +376,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()
|
||||
@@ -503,7 +550,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;
|
||||
|
||||
@@ -27,18 +27,19 @@
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-white text-sm font-bold mb-2">Enter New Password</label>
|
||||
<input v-model="ChangePasswordForm.new_password" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="password" type="password" placeholder="Password" />
|
||||
<span v-if="v$.ChangePasswordForm.new_password.$error" class="text-red-600 text-center">
|
||||
{{ v$.ChangePasswordForm.new_password.$errors[0].$message }}
|
||||
<input v-model="form.new_password" class="rounded w-full py-2 px-3 input-primary text-black" id="password"
|
||||
type="password" placeholder="Password" :class="{ 'input-error': v$.form.new_password.$error }" />
|
||||
<span v-if="v$.form.new_password.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.new_password.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-white text-sm font-bold mb-2">Confirm New Password</label>
|
||||
<input v-model="ChangePasswordForm.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password" />
|
||||
<span v-if="v$.ChangePasswordForm.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.ChangePasswordForm.password_confirm.$errors[0].$message }}
|
||||
<input v-model="form.password_confirm" class="rounded w-full py-2 px-3 input-primary text-black"
|
||||
id="passwordtwo" type="password" autocomplete="off" placeholder="Confirm Password"
|
||||
:class="{ 'input-error': v$.form.password_confirm.$error }" />
|
||||
<span v-if="v$.form.password_confirm.$error" class="text-red-600 text-center">
|
||||
{{ v$.form.password_confirm.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
@@ -54,142 +55,124 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import axios from "axios";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { required, minLength, helpers } from "@vuelidate/validators";
|
||||
import Header from "../../layouts/headers/headerauth.vue";
|
||||
import authHeader from "../../services/auth.header";
|
||||
import {Employee} from '../../types/models';
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, minLength, helpers } from "@vuelidate/validators"
|
||||
import { Employee } from '../../types/models'
|
||||
import { adminService } from '../../services/adminService'
|
||||
import { authService } from '../../services/authService'
|
||||
|
||||
export default defineComponent({
|
||||
name: "EmployeeChangePassword",
|
||||
components: {
|
||||
Header,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
user_admin: 0,
|
||||
loaded: false,
|
||||
employee: {} as Employee,
|
||||
ChangePasswordForm: {
|
||||
new_password: "",
|
||||
password_confirm: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
ChangePasswordForm: {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const employee = ref<Employee | null>(null)
|
||||
const form = reactive({
|
||||
new_password: '',
|
||||
password_confirm: '',
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
new_password: { required, minLength: minLength(6) },
|
||||
password_confirm: { required, minLength: minLength(6), sameAsPassword: helpers.withMessage('Passwords must match', (value: string) => value === this.ChangePasswordForm.new_password) },
|
||||
password_confirm: {
|
||||
required,
|
||||
minLength: minLength(6),
|
||||
sameAsPassword: helpers.withMessage('Passwords must match', (value: string) => value === form.new_password)
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
},
|
||||
mounted() {
|
||||
this.getEmployee();
|
||||
},
|
||||
methods: {
|
||||
userStatus() {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
async function checkUserStatus() {
|
||||
try {
|
||||
const response = await authService.whoami()
|
||||
if (!(response.data as any).ok) {
|
||||
router.push({ name: "login" })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.$router.push({ name: "login" });
|
||||
});
|
||||
},
|
||||
getEmployee() {
|
||||
const employeeId = this.$route.params.id;
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/byid/${employeeId}`;
|
||||
axios.get(path, { headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
this.employee = response.data;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Failed to fetch employee:", error);
|
||||
} catch (error) {
|
||||
router.push({ name: "login" })
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmployee() {
|
||||
const employeeId = route.params.id as string
|
||||
try {
|
||||
const response = await adminService.employees.getByIdAlt(Number(employeeId))
|
||||
employee.value = response.data as Employee
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch employee:", error)
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Failed to load employee data",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
getEmployeeTypeName(typeId: number | string | undefined): string {
|
||||
if (typeId === undefined) return 'Unknown Role';
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getEmployeeTypeName(typeId: number | string | undefined): string {
|
||||
if (typeId === undefined) return 'Unknown Role'
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'0': 'Owner', '1': 'Manager', '2': 'Secretary', '3': 'Office',
|
||||
'4': 'Driver', '5': 'Service Tech', '6': 'Contractor', '7': 'Cash Driver', '8': 'Driver/Tech'
|
||||
};
|
||||
return typeMap[String(typeId)] || 'Unknown Role';
|
||||
},
|
||||
sendWordRequest(payLoad: { employee_id: string; new_password: string; password_confirm: string }) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/auth/admin-change-password";
|
||||
axios({
|
||||
method: "post",
|
||||
url: path,
|
||||
data: payLoad,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
console.log(response)
|
||||
if (response.data.ok) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Password changed successfully",
|
||||
type: "success",
|
||||
});
|
||||
this.$router.push({ name: "employee" });
|
||||
}
|
||||
if (response.data.error) {
|
||||
notify({
|
||||
title: "Authorization Error",
|
||||
text: response.data.error,
|
||||
type: "error",
|
||||
});
|
||||
return typeMap[String(typeId)] || 'Unknown Role'
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Invalid Credentials.",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
onSubmit() {
|
||||
const payLoad = {
|
||||
employee_id: this.$route.params.id as string,
|
||||
new_password: this.ChangePasswordForm.new_password,
|
||||
password_confirm: this.ChangePasswordForm.password_confirm,
|
||||
};
|
||||
|
||||
this.v$.$validate(); // checks all inputs
|
||||
if (this.v$.$invalid) {
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
|
||||
if (!isValid) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Form Failure",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
this.sendWordRequest(payLoad);
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await authService.adminChangePassword({
|
||||
user_id: Number(route.params.id),
|
||||
new_password: form.new_password,
|
||||
})
|
||||
|
||||
const data = response.data as any
|
||||
|
||||
if (data.ok) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Password changed successfully",
|
||||
type: "success",
|
||||
})
|
||||
router.push({ name: "employee" })
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
notify({
|
||||
title: "Authorization Error",
|
||||
text: data.error,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Authorization",
|
||||
text: "Invalid Credentials.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
checkUserStatus()
|
||||
getEmployee()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -27,37 +27,44 @@
|
||||
<!-- First Name -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">First Name</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_first_name.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
|
||||
<input v-model="form.employee_first_name" type="text" placeholder="First Name"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_first_name.$error }" />
|
||||
<span v-if="v$.form.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_first_name.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Last Name -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Last Name</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_last_name.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
|
||||
<input v-model="form.employee_last_name" type="text" placeholder="Last Name"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_last_name.$error }" />
|
||||
<span v-if="v$.form.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_last_name.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Phone Number -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Phone Number</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_phone_number" @input="acceptNumber()" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_phone_number.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
|
||||
<input v-model="form.employee_phone_number" @input="acceptNumber" type="text" placeholder="Phone Number"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_phone_number.$error }" />
|
||||
<span v-if="v$.form.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_phone_number.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Employee Type -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Employee Role</span></label>
|
||||
<select v-model="CreateEmployeeForm.employee_type" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_type.$error }">
|
||||
<select v-model="form.employee_type" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.employee_type.$error }">
|
||||
<option disabled :value="0">Select a role</option>
|
||||
<option v-for="employee in employList" :key="employee.value" :value="employee.value">
|
||||
{{ employee.text }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.CreateEmployeeForm.employee_type.$error" class="text-red-500 text-xs mt-1">
|
||||
<span v-if="v$.form.employee_type.$error" class="text-red-500 text-xs mt-1">
|
||||
Role is required.
|
||||
</span>
|
||||
</div>
|
||||
@@ -72,44 +79,51 @@
|
||||
<!-- Street Address -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Street Address</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_address.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_address.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
|
||||
<input v-model="form.employee_address" type="text" placeholder="Street Address"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_address.$error }" />
|
||||
<span v-if="v$.form.employee_address.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_address.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Apt, Suite, etc. -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_apt.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_apt.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.employee_apt" type="text" placeholder="Apt, suite, unit..."
|
||||
class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<!-- Town -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Town</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_town.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_town.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
|
||||
<input v-model="form.employee_town" type="text" placeholder="Town"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_town.$error }" />
|
||||
<span v-if="v$.form.employee_town.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_town.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- State -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">State</span></label>
|
||||
<select v-model="CreateEmployeeForm.employee_state" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_state.$error }">
|
||||
<select v-model="form.employee_state" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.employee_state.$error }">
|
||||
<option disabled :value="0">Select a state</option>
|
||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||
{{ state.text }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.CreateEmployeeForm.employee_state.$error" class="text-red-500 text-xs mt-1">
|
||||
<span v-if="v$.form.employee_state.$error" class="text-red-500 text-xs mt-1">
|
||||
State is required.
|
||||
</span>
|
||||
</div>
|
||||
<!-- Zip Code -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Zip Code</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_zip.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
|
||||
<input v-model="form.employee_zip" type="text" placeholder="Zip Code"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_zip.$error }" />
|
||||
<span v-if="v$.form.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_zip.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,24 +136,28 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Birthday</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_birthday" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_birthday.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.employee_birthday" type="date" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_birthday.$error }" />
|
||||
<span v-if="v$.form.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Start Date</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_start_date" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_start_date.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.employee_start_date" type="date" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_start_date.$error }" />
|
||||
<span v-if="v$.form.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">End Date (Optional)</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_end_date" type="date" class="input input-bordered input-sm w-full" />
|
||||
<input v-model="form.employee_end_date" type="date" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUBMIT BUTTON -->
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Create Employee</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? 'Creating...' : 'Create Employee' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -148,118 +166,123 @@
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { minLength, required } from "@vuelidate/validators";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, minLength } from "@vuelidate/validators"
|
||||
import { adminService } from '../../services/adminService'
|
||||
import { queryService } from '../../services/queryService'
|
||||
|
||||
interface SelectOption {
|
||||
text: string;
|
||||
value: number;
|
||||
text: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EmployeeCreate',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
stateList: [] as SelectOption[],
|
||||
employList: [] as SelectOption[],
|
||||
// --- REFACTORED: Simplified, flat form object ---
|
||||
CreateEmployeeForm: {
|
||||
employee_last_name: "",
|
||||
employee_first_name: "",
|
||||
employee_town: "",
|
||||
employee_address: "",
|
||||
employee_apt: "",
|
||||
employee_zip: "",
|
||||
employee_birthday: "",
|
||||
employee_phone_number: "",
|
||||
employee_start_date: "",
|
||||
employee_end_date: "",
|
||||
// --- FIX: Initialized as a number for proper v-model binding ---
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const isSubmitting = ref(false)
|
||||
const stateList = ref<SelectOption[]>([])
|
||||
const employList = ref<SelectOption[]>([])
|
||||
const form = reactive({
|
||||
employee_first_name: '',
|
||||
employee_last_name: '',
|
||||
employee_town: '',
|
||||
employee_address: '',
|
||||
employee_apt: '',
|
||||
employee_zip: '',
|
||||
employee_birthday: '',
|
||||
employee_phone_number: '',
|
||||
employee_start_date: '',
|
||||
employee_end_date: '',
|
||||
employee_type: 0,
|
||||
employee_state: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
// --- REFACTORED: Validation rules point to the flat form object ---
|
||||
CreateEmployeeForm: {
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
employee_last_name: { required, minLength: minLength(1) },
|
||||
employee_first_name: { required, minLength: minLength(1) },
|
||||
employee_town: { required, minLength: minLength(1) },
|
||||
employee_type: { required },
|
||||
employee_zip: { required, minLength: minLength(5) },
|
||||
employee_state: { required },
|
||||
employee_apt: { required },
|
||||
employee_address: { required },
|
||||
employee_birthday: { required },
|
||||
employee_phone_number: { required },
|
||||
employee_start_date: { required },
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
},
|
||||
mounted() {
|
||||
this.getEmployeeTypeList();
|
||||
this.getStatesList();
|
||||
},
|
||||
methods: {
|
||||
acceptNumber() {
|
||||
const x = this.CreateEmployeeForm.employee_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
function acceptNumber() {
|
||||
const x = form.employee_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/)
|
||||
if (x) {
|
||||
this.CreateEmployeeForm.employee_phone_number = !x[2] ? x[1] : `(${x[1]}) ${x[2]}${x[3] ? `-${x[3]}` : ''}`;
|
||||
form.employee_phone_number = !x[2] ? x[1] : `(${x[1]}) ${x[2]}${x[3] ? `-${x[3]}` : ''}`
|
||||
}
|
||||
},
|
||||
userStatus() {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) { this.user = response.data.user; }
|
||||
})
|
||||
.catch(() => { this.user = null; });
|
||||
},
|
||||
CreateItem(payload: any) {
|
||||
const path = import.meta.env.VITE_BASE_URL + "/employee/create";
|
||||
axios.post(path, payload, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
const employee_id = response.data['user_id'];
|
||||
this.$router.push({ name: "employeeProfile", params: { id: employee_id } });
|
||||
}
|
||||
|
||||
async function getEmployeeTypeList() {
|
||||
try {
|
||||
const response = await queryService.getEmployeeTypes()
|
||||
const data = response.data as any
|
||||
if (data && data.employee_types) {
|
||||
employList.value = data.employee_types
|
||||
} else if (Array.isArray(data)) {
|
||||
employList.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch employee types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatesList() {
|
||||
try {
|
||||
const response = await queryService.getStates()
|
||||
const data = response.data as any
|
||||
if (data && data.states) {
|
||||
stateList.value = data.states
|
||||
} else if (Array.isArray(data)) {
|
||||
stateList.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch states:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
if (!isValid) {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const response = await adminService.employees.create(form)
|
||||
if ((response.data as any).ok) {
|
||||
const employeeId = (response.data as any).user_id
|
||||
router.push({ name: "employeeProfile", params: { id: employeeId } })
|
||||
} else {
|
||||
console.error("Failed to create employee:", response.data.error);
|
||||
// Optionally, show a notification to the user
|
||||
notify({ title: "Error", text: (response.data as any).error || "Failed to create employee", type: "error" })
|
||||
}
|
||||
});
|
||||
},
|
||||
onSubmit() {
|
||||
this.v$.$validate(); // Trigger validation
|
||||
if (!this.v$.$error) {
|
||||
this.CreateItem(this.CreateEmployeeForm);
|
||||
} else {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" });
|
||||
} catch (error) {
|
||||
notify({ title: "Error", text: "Failed to create employee", type: "error" })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
},
|
||||
getEmployeeTypeList() {
|
||||
const path = import.meta.env.VITE_BASE_URL + "/query/employeetype";
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => { this.employList = response.data; });
|
||||
},
|
||||
getStatesList() {
|
||||
const path = import.meta.env.VITE_BASE_URL + "/query/states";
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => { this.stateList = response.data; });
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
getEmployeeTypeList()
|
||||
getStatesList()
|
||||
})
|
||||
</script>
|
||||
@@ -15,7 +15,8 @@
|
||||
<h1 class="text-3xl font-bold">
|
||||
Edit Employee
|
||||
</h1>
|
||||
<router-link v-if="employee_id" :to="{ name: 'employeeProfile', params: { id: employee_id } }" class="btn btn-secondary btn-sm mt-2 sm:mt-0">
|
||||
<router-link v-if="employeeId" :to="{ name: 'employeeProfile', params: { id: employeeId } }"
|
||||
class="btn btn-secondary btn-sm mt-2 sm:mt-0">
|
||||
Back to Profile
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -32,37 +33,44 @@
|
||||
<!-- First Name -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">First Name</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_first_name" type="text" placeholder="First Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_first_name.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_first_name.$errors[0].$message }}
|
||||
<input v-model="form.employee_first_name" type="text" placeholder="First Name"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_first_name.$error }" />
|
||||
<span v-if="v$.form.employee_first_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_first_name.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Last Name -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Last Name</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_last_name" type="text" placeholder="Last Name" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_last_name.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_last_name.$errors[0].$message }}
|
||||
<input v-model="form.employee_last_name" type="text" placeholder="Last Name"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_last_name.$error }" />
|
||||
<span v-if="v$.form.employee_last_name.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_last_name.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Phone Number -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Phone Number</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_phone_number" @input="acceptNumber()" type="text" placeholder="Phone Number" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_phone_number.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_phone_number.$errors[0].$message }}
|
||||
<input v-model="form.employee_phone_number" @input="acceptNumber" type="text" placeholder="Phone Number"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_phone_number.$error }" />
|
||||
<span v-if="v$.form.employee_phone_number.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_phone_number.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Employee Type -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Employee Role</span></label>
|
||||
<select v-model="CreateEmployeeForm.employee_type" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_type.$error }">
|
||||
<select v-model="form.employee_type" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.employee_type.$error }">
|
||||
<option disabled :value="0">Select a role</option>
|
||||
<option v-for="employee in employList" :key="employee.value" :value="employee.value">
|
||||
{{ employee.text }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.CreateEmployeeForm.employee_type.$error" class="text-red-500 text-xs mt-1">
|
||||
<span v-if="v$.form.employee_type.$error" class="text-red-500 text-xs mt-1">
|
||||
Role is required.
|
||||
</span>
|
||||
</div>
|
||||
@@ -77,43 +85,51 @@
|
||||
<!-- Street Address -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Street Address</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_address" type="text" placeholder="Street Address" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_address.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_address.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_address.$errors[0].$message }}
|
||||
<input v-model="form.employee_address" type="text" placeholder="Street Address"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_address.$error }" />
|
||||
<span v-if="v$.form.employee_address.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_address.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Apt, Suite, etc. -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Apt, Suite, etc. (Optional)</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_apt" type="text" placeholder="Apt, suite, unit..." class="input input-bordered input-sm w-full" />
|
||||
<input v-model="form.employee_apt" type="text" placeholder="Apt, suite, unit..."
|
||||
class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<!-- Town -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Town</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_town" type="text" placeholder="Town" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_town.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_town.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_town.$errors[0].$message }}
|
||||
<input v-model="form.employee_town" type="text" placeholder="Town"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_town.$error }" />
|
||||
<span v-if="v$.form.employee_town.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_town.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- State -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">State</span></label>
|
||||
<select v-model="CreateEmployeeForm.employee_state" class="select select-bordered select-sm w-full" :class="{ 'select-error': v$.CreateEmployeeForm.employee_state.$error }">
|
||||
<select v-model="form.employee_state" class="select select-bordered select-sm w-full"
|
||||
:class="{ 'select-error': v$.form.employee_state.$error }">
|
||||
<option disabled :value="0">Select a state</option>
|
||||
<option v-for="state in stateList" :key="state.value" :value="state.value">
|
||||
{{ state.text }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.CreateEmployeeForm.employee_state.$error" class="text-red-500 text-xs mt-1">
|
||||
<span v-if="v$.form.employee_state.$error" class="text-red-500 text-xs mt-1">
|
||||
State is required.
|
||||
</span>
|
||||
</div>
|
||||
<!-- Zip Code -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Zip Code</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_zip" type="text" placeholder="Zip Code" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_zip.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.CreateEmployeeForm.employee_zip.$errors[0].$message }}
|
||||
<input v-model="form.employee_zip" type="text" placeholder="Zip Code"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_zip.$error }" />
|
||||
<span v-if="v$.form.employee_zip.$error" class="text-red-500 text-xs mt-1">
|
||||
{{ v$.form.employee_zip.$errors[0].$message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,36 +142,40 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Birthday</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_birthday" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_birthday.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.employee_birthday" type="date" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_birthday.$error }" />
|
||||
<span v-if="v$.form.employee_birthday.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Start Date</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_start_date" type="date" class="input input-bordered input-sm w-full" :class="{ 'input-error': v$.CreateEmployeeForm.employee_start_date.$error }" />
|
||||
<span v-if="v$.CreateEmployeeForm.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
<input v-model="form.employee_start_date" type="date" class="input input-bordered input-sm w-full"
|
||||
:class="{ 'input-error': v$.form.employee_start_date.$error }" />
|
||||
<span v-if="v$.form.employee_start_date.$error" class="text-red-500 text-xs mt-1">Required.</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">End Date (Optional)</span></label>
|
||||
<input v-model="CreateEmployeeForm.employee_end_date" type="date" class="input input-bordered input-sm w-full" />
|
||||
<input v-model="form.employee_end_date" type="date" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 4: Fired or Current -->
|
||||
<!-- SECTION 4: Active Status -->
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">Fired or Current</h2>
|
||||
<h2 class="text-lg font-bold">Employment Status</h2>
|
||||
<div class="divider mt-2 mb-4"></div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Active Employee</span>
|
||||
<input v-model="CreateEmployeeForm.active" type="checkbox" class="checkbox checkbox-primary" />
|
||||
<input v-model="form.active" type="checkbox" class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUBMIT BUTTON -->
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -163,50 +183,48 @@
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import useValidate from "@vuelidate/core";
|
||||
import { minLength, required } from "@vuelidate/validators";
|
||||
import { notify } from "@kyvg/vue3-notification";
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { required, minLength } from "@vuelidate/validators"
|
||||
import { adminService } from '../../services/adminService'
|
||||
import { queryService } from '../../services/queryService'
|
||||
|
||||
interface SelectOption {
|
||||
text: string;
|
||||
value: number;
|
||||
text: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EmployeeEdit',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
v$: useValidate(),
|
||||
user: null,
|
||||
stateList: [] as SelectOption[],
|
||||
employList: [] as SelectOption[],
|
||||
employee_id: this.$route.params.id as string,
|
||||
CreateEmployeeForm: {
|
||||
employee_last_name: "",
|
||||
employee_first_name: "",
|
||||
employee_town: "",
|
||||
employee_address: "",
|
||||
employee_apt: "",
|
||||
employee_zip: "",
|
||||
employee_birthday: "",
|
||||
employee_phone_number: "",
|
||||
employee_start_date: "",
|
||||
employee_end_date: "",
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const employeeId = ref<string>(route.params.id as string)
|
||||
const isSubmitting = ref(false)
|
||||
const stateList = ref<SelectOption[]>([])
|
||||
const employList = ref<SelectOption[]>([])
|
||||
const form = reactive({
|
||||
employee_first_name: '',
|
||||
employee_last_name: '',
|
||||
employee_town: '',
|
||||
employee_address: '',
|
||||
employee_apt: '',
|
||||
employee_zip: '',
|
||||
employee_birthday: '',
|
||||
employee_phone_number: '',
|
||||
employee_start_date: '',
|
||||
employee_end_date: '',
|
||||
employee_type: 0,
|
||||
employee_state: 0,
|
||||
active: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
CreateEmployeeForm: {
|
||||
})
|
||||
|
||||
// Validation rules
|
||||
const rules = computed(() => ({
|
||||
form: {
|
||||
employee_last_name: { required, minLength: minLength(1) },
|
||||
employee_first_name: { required, minLength: minLength(1) },
|
||||
employee_town: { required, minLength: minLength(1) },
|
||||
@@ -218,88 +236,98 @@ export default defineComponent({
|
||||
employee_phone_number: { required },
|
||||
employee_start_date: { required },
|
||||
},
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
this.getEmployee(this.employee_id);
|
||||
},
|
||||
mounted() {
|
||||
this.getEmployeeTypeList();
|
||||
this.getStatesList();
|
||||
},
|
||||
methods: {
|
||||
// --- FIX APPLIED HERE ---
|
||||
acceptNumber() {
|
||||
const x = this.CreateEmployeeForm.employee_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/);
|
||||
// This 'if' block ensures 'x' is not null before we use it.
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, { form })
|
||||
|
||||
// Methods
|
||||
function acceptNumber() {
|
||||
const x = form.employee_phone_number.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/)
|
||||
if (x) {
|
||||
this.CreateEmployeeForm.employee_phone_number = !x[2] ? x[1] : `(${x[1]}) ${x[2]}${x[3] ? `-${x[3]}` : ''}`;
|
||||
form.employee_phone_number = !x[2] ? x[1] : `(${x[1]}) ${x[2]}${x[3] ? `-${x[3]}` : ''}`
|
||||
}
|
||||
},
|
||||
userStatus() {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => { this.user = null; });
|
||||
},
|
||||
EditEmployee(payload: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/edit/${this.employee_id}`;
|
||||
// Convert active from boolean to integer for API
|
||||
const apiPayload = { ...payload, active: payload.active ? 1 : 0 };
|
||||
axios.post(path, apiPayload, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.$router.push({ name: "employeeProfile", params: { id: this.employee_id } });
|
||||
|
||||
async function getEmployee(userId: string) {
|
||||
try {
|
||||
const response = await adminService.employees.getById(Number(userId))
|
||||
const data = response.data as any
|
||||
// Populate form with data
|
||||
form.employee_first_name = data.employee_first_name || ''
|
||||
form.employee_last_name = data.employee_last_name || ''
|
||||
form.employee_town = data.employee_town || ''
|
||||
form.employee_address = data.employee_address || ''
|
||||
form.employee_apt = data.employee_apt || ''
|
||||
form.employee_zip = data.employee_zip || ''
|
||||
form.employee_birthday = data.employee_birthday || ''
|
||||
form.employee_phone_number = data.employee_phone_number || ''
|
||||
form.employee_start_date = data.employee_start_date || ''
|
||||
form.employee_end_date = data.employee_end_date || ''
|
||||
form.employee_type = data.employee_type || 0
|
||||
form.employee_state = data.employee_state || 0
|
||||
form.active = data.active === 1
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch employee:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmployeeTypeList() {
|
||||
try {
|
||||
const response = await queryService.getEmployeeTypes()
|
||||
const data = response.data as any
|
||||
if (data && data.employee_types) {
|
||||
employList.value = data.employee_types
|
||||
} else if (Array.isArray(data)) {
|
||||
employList.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch employee types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatesList() {
|
||||
try {
|
||||
const response = await queryService.getStates()
|
||||
const data = response.data as any
|
||||
if (data && data.states) {
|
||||
stateList.value = data.states
|
||||
} else if (Array.isArray(data)) {
|
||||
stateList.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch states:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await v$.value.$validate()
|
||||
if (!isValid) {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const apiPayload = { ...form, active: form.active ? 1 : 0 }
|
||||
const response = await adminService.employees.update(Number(employeeId.value), apiPayload)
|
||||
if ((response.data as any).ok) {
|
||||
router.push({ name: "employeeProfile", params: { id: employeeId.value } })
|
||||
} else {
|
||||
console.error("Failed to edit employee:", response.data.error);
|
||||
notify({ title: "Error", text: (response.data as any).error || "Failed to update employee", type: "error" })
|
||||
}
|
||||
});
|
||||
},
|
||||
getEmployee(userid: any) {
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/${userid}`;
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
const data = response.data;
|
||||
// Convert active from integer to boolean
|
||||
data.active = data.active === 1;
|
||||
this.CreateEmployeeForm = data;
|
||||
});
|
||||
},
|
||||
onSubmit() {
|
||||
this.v$.$validate();
|
||||
if (!this.v$.$error) {
|
||||
this.EditEmployee(this.CreateEmployeeForm);
|
||||
} else {
|
||||
notify({ title: "Validation Error", text: "Please fill out all required fields correctly.", type: "error" });
|
||||
} catch (error) {
|
||||
notify({ title: "Error", text: "Failed to update employee", type: "error" })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
},
|
||||
getEmployeeTypeList() {
|
||||
const path = import.meta.env.VITE_BASE_URL + "/query/employeetype";
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data && response.data.employee_types) {
|
||||
this.employList = response.data.employee_types;
|
||||
} else {
|
||||
this.employList = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
getStatesList() {
|
||||
const path = import.meta.env.VITE_BASE_URL + "/query/states";
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data && response.data.states) {
|
||||
this.stateList = response.data.states;
|
||||
} else {
|
||||
this.stateList = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
getEmployeeTypeList()
|
||||
getStatesList()
|
||||
getEmployee(employeeId.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
<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" />
|
||||
<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>
|
||||
Employees
|
||||
@@ -37,9 +40,6 @@
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<div class="modern-table-card">
|
||||
|
||||
|
||||
|
||||
<!-- DESKTOP VIEW: Table -->
|
||||
<div class="overflow-x-auto hidden xl:block">
|
||||
<table class="modern-table">
|
||||
@@ -62,10 +62,14 @@
|
||||
<td>{{ person.employee_phone_number }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }"
|
||||
class="btn btn-sm btn-secondary">Edit</router-link>
|
||||
<button v-else class="btn btn-sm btn-disabled">No User</button>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning">Change Password</router-link>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-ghost">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id"
|
||||
:to="{ name: 'employeeChangePassword', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-warning">Change Password</router-link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -97,9 +101,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-3 mt-3 border-t border-base-content/10 flex-wrap">
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }" class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }" class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id" :to="{ name: 'employeeChangePassword', params: { id: person.id } }" class="btn btn-sm btn-warning flex-1">Change Password</router-link>
|
||||
<router-link v-if="person.user_id" :to="{ name: 'employeeEdit', params: { id: person.user_id } }"
|
||||
class="btn btn-sm btn-secondary flex-1">Edit</router-link>
|
||||
<router-link v-if="person.id" :to="{ name: 'employeeProfile', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-ghost flex-1">View</router-link>
|
||||
<router-link v-if="user && user.user_admin === 0 && person.id"
|
||||
:to="{ name: 'employeeChangePassword', params: { id: person.id } }"
|
||||
class="btn btn-sm btn-warning flex-1">Change Password</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,84 +123,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template><script lang="ts">
|
||||
import { defineComponent, markRaw } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import PaginationComp from '../../components/pagination.vue'
|
||||
import {Employee, User} from '../../types/models'
|
||||
</template>
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EmployeeHome',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
user: {} as User,
|
||||
employees: [] as Employee[],
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
recordsLength: 0,
|
||||
options: {
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, markRaw } from 'vue'
|
||||
import { User, Employee } from '../../types/models'
|
||||
import { adminService } from '../../services/adminService'
|
||||
import { authService } from '../../services/authService'
|
||||
import PaginationComp from '../../components/pagination.vue'
|
||||
|
||||
// State
|
||||
const user = ref<User | null>(null)
|
||||
const employees = ref<Employee[]>([])
|
||||
const page = ref(1)
|
||||
const recordsLength = ref(0)
|
||||
const options = {
|
||||
edgeNavigation: false,
|
||||
format: false,
|
||||
template: markRaw(PaginationComp)
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.userStatus();
|
||||
},
|
||||
mounted() {
|
||||
this.getPage(this.page);
|
||||
},
|
||||
methods: {
|
||||
getPage: function (page: number) {
|
||||
this.get_employees(page);
|
||||
},
|
||||
userStatus() {
|
||||
const path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
|
||||
axios.get(path, { withCredentials: true, headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.user = response.data.user;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.user = {} as User;
|
||||
});
|
||||
},
|
||||
// --- METHOD CORRECTED TO MATCH YOUR SIMPLE ARRAY API RESPONSE ---
|
||||
get_employees(page: number) {
|
||||
// Using your original, working URL
|
||||
const path = `${import.meta.env.VITE_BASE_URL}/employee/all/${page}`;
|
||||
|
||||
axios.get(path, { headers: authHeader() })
|
||||
.then((response: any) => {
|
||||
// Fix: Access the .employees property from the response object
|
||||
// The API returns { ok: true, employees: [...] }
|
||||
if (response.data.employees) {
|
||||
this.employees = response.data.employees;
|
||||
// Methods
|
||||
async function userStatus() {
|
||||
try {
|
||||
const response = await authService.whoami()
|
||||
if ((response.data as any).ok) {
|
||||
user.value = (response.data as any).user
|
||||
}
|
||||
} catch (error) {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmployees(pageNum: number) {
|
||||
try {
|
||||
const response = await adminService.employees.getAll(pageNum)
|
||||
if ((response.data as any).employees) {
|
||||
employees.value = (response.data as any).employees
|
||||
} else {
|
||||
// Fallback or empty
|
||||
this.employees = [];
|
||||
employees.value = []
|
||||
}
|
||||
recordsLength.value = employees.value.length
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch employees:", error)
|
||||
employees.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Fix: Set recordsLength based on the array length
|
||||
this.recordsLength = this.employees.length;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Failed to fetch employees:", error);
|
||||
});
|
||||
},
|
||||
getEmployeeTypeName(typeId: number | string | undefined): string {
|
||||
if (typeId === undefined) return 'Unknown Role';
|
||||
function getPage(pageNum: number) {
|
||||
getEmployees(pageNum)
|
||||
}
|
||||
|
||||
function getEmployeeTypeName(typeId: number | string | undefined): string {
|
||||
if (typeId === undefined) return 'Unknown Role'
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'0': 'Owner', '1': 'Manager', '2': 'Secretary', '3': 'Office',
|
||||
'4': 'Driver', '5': 'Service Tech', '6': 'Contractor', '7': 'Cash Driver', '8': 'Driver/Tech'
|
||||
};
|
||||
return typeMap[String(typeId)] || 'Unknown Role';
|
||||
}
|
||||
},
|
||||
return typeMap[String(typeId)] || 'Unknown Role'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
userStatus()
|
||||
getPage(page.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
63
src/pages/info/PricingHistory.vue
Normal file
63
src/pages/info/PricingHistory.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Market Price Trends</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link to="/">Home</router-link></li>
|
||||
<li>Market Trends</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chart Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div class="lg:col-span-3">
|
||||
<PricingHistoryChart :initial-days="30" />
|
||||
</div>
|
||||
|
||||
<!-- Side Info / Legend -->
|
||||
<div class="card bg-base-100 shadow-xl h-fit">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-sm opacity-70 mb-4">Market Indices</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div>
|
||||
<p class="font-bold text-sm">Heating Oil (HO)</p>
|
||||
<p class="text-xs opacity-50">NY Harbor ULSD</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<div>
|
||||
<p class="font-bold text-sm">Crude Oil (CL)</p>
|
||||
<p class="text-xs opacity-50">WTI Crude</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div>
|
||||
<p class="font-bold text-sm">Gasoline (RB)</p>
|
||||
<p class="text-xs opacity-50">RBOB Gasoline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-4"></div>
|
||||
|
||||
<p class="text-xs text-base-content/50">
|
||||
Data sourced from global market indices. Updates every 5 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PricingHistoryChart from '../../components/PricingHistoryChart.vue';
|
||||
</script>
|
||||
@@ -255,6 +255,10 @@ const autoDelivery = ref<AutoDeliveryData>({
|
||||
last_updated: '',
|
||||
tank_height: '',
|
||||
open_ticket_id: null,
|
||||
confidence_score: 20,
|
||||
k_factor_source: 'default',
|
||||
days_remaining: 999,
|
||||
gallons_per_day: 0,
|
||||
})
|
||||
const credit_cards = ref<CreditCardFormData[]>([
|
||||
{
|
||||
|
||||
@@ -324,6 +324,10 @@ const autoDelivery = ref<AutoDeliveryData>({
|
||||
house_factor: 0,
|
||||
auto_status: 0,
|
||||
open_ticket_id: null,
|
||||
confidence_score: 20,
|
||||
k_factor_source: 'default',
|
||||
days_remaining: 999,
|
||||
gallons_per_day: 0,
|
||||
})
|
||||
const autoTicket = ref<AutoTicketData>({
|
||||
id: 0,
|
||||
|
||||
@@ -56,9 +56,8 @@
|
||||
<table class="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Customer</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Address</th>
|
||||
<th>Service Type</th>
|
||||
<th>Description</th>
|
||||
@@ -67,22 +66,41 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Removed @click from tr to avoid conflicting actions -->
|
||||
<tr v-for="service in services" :key="service.id" class="table-row-hover">
|
||||
<td>{{ service.id }}</td>
|
||||
<td class="align-top">
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||
class="group">
|
||||
<div class="font-bold text-base group-hover:text-primary transition-colors">
|
||||
{{ service.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>
|
||||
#{{ service.id }}
|
||||
</div>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium">{{ formatDate(service.scheduled_date) }}</div>
|
||||
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||
class="link link-hover font-medium">
|
||||
{{ service.customer_name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ service.customer_town }}</div>
|
||||
<div class="text-xs opacity-70">{{ service.customer_address }}</div>
|
||||
<td class="align-top">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">{{ service.customer_address }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs opacity-70">{{ service.customer_town }}, {{ getStateAbbr(service.customer_state) }} {{ service.customer_zip }}</span>
|
||||
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${service.customer_address}, ${service.customer_town}, ${getStateAbbr(service.customer_state)} ${service.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 class="badge badge-sm border-0 text-white font-medium shadow-sm"
|
||||
@@ -208,6 +226,23 @@ const selectedServiceForEdit = ref<ServiceCall | null>(null)
|
||||
const wordLimit = ref(50)
|
||||
const expandedIds = ref<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 | undefined): string => {
|
||||
if (stateId === undefined) return 'MA';
|
||||
const id = typeof stateId === 'string' ? parseInt(stateId) : stateId;
|
||||
return STATE_ABBR_MAP[id] || 'MA';
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
userStatus();
|
||||
|
||||
@@ -56,9 +56,8 @@
|
||||
<table class="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Customer</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Address</th>
|
||||
<th>Service Type</th>
|
||||
<th>Description</th>
|
||||
@@ -68,18 +67,41 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="service in services" :key="service.id" class="table-row-hover">
|
||||
<td class="align-top">{{ service.id }}</td>
|
||||
<td class="align-top">
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||
class="group">
|
||||
<div class="font-bold text-base group-hover:text-primary transition-colors">
|
||||
{{ service.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>
|
||||
#{{ service.id }}
|
||||
</div>
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div>{{ formatDate(service.scheduled_date) }}</div>
|
||||
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||
class="link link-hover font-bold">
|
||||
{{ service.customer_name }}
|
||||
</router-link>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">{{ service.customer_address }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs opacity-70">{{ service.customer_town }}, {{ getStateAbbr(service.customer_state) }} {{ service.customer_zip }}</span>
|
||||
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${service.customer_address}, ${service.customer_town}, ${getStateAbbr(service.customer_state)} ${service.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 class="align-top">{{ service.customer_address }}, {{ service.customer_town }}</td>
|
||||
|
||||
<td class="align-top">
|
||||
<span class="badge badge-sm text-white"
|
||||
@@ -203,6 +225,23 @@ const selectedServiceForEdit = ref<ServiceCall | null>(null)
|
||||
const wordLimit = ref(50)
|
||||
const expandedIds = ref<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 | undefined): string => {
|
||||
if (stateId === undefined) return 'MA';
|
||||
const id = typeof stateId === 'string' ? parseInt(stateId) : stateId;
|
||||
return STATE_ABBR_MAP[id] || 'MA';
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (authService) {
|
||||
|
||||
@@ -56,9 +56,8 @@
|
||||
<table class="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Customer</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Address</th>
|
||||
<th>Service Type</th>
|
||||
<th>Description</th>
|
||||
@@ -68,18 +67,41 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="service in services" :key="service.id" class="table-row-hover">
|
||||
<td class="align-top">{{ service.id }}</td>
|
||||
<td class="align-top">
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||
class="group">
|
||||
<div class="font-bold text-base group-hover:text-primary transition-colors">
|
||||
{{ service.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>
|
||||
#{{ service.id }}
|
||||
</div>
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div>{{ formatDate(service.scheduled_date) }}</div>
|
||||
<div class="text-xs opacity-70">{{ formatTime(service.scheduled_date) }}</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<router-link :to="{ name: 'customerProfile', params: { id: service.customer_id } }"
|
||||
class="link link-hover font-bold">
|
||||
{{ service.customer_name }}
|
||||
</router-link>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">{{ service.customer_address }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs opacity-70">{{ service.customer_town }}, {{ getStateAbbr(service.customer_state) }} {{ service.customer_zip }}</span>
|
||||
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${service.customer_address}, ${service.customer_town}, ${getStateAbbr(service.customer_state)} ${service.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 class="align-top">{{ service.customer_address }}, {{ service.customer_town }}</td>
|
||||
|
||||
<!--
|
||||
FIX IS HERE: Replaced the colored text with a styled badge.
|
||||
@@ -208,6 +230,23 @@ const selectedServiceForEdit = ref<ServiceCall | null>(null)
|
||||
const wordLimit = ref(50)
|
||||
const expandedIds = ref<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 | undefined): string => {
|
||||
if (stateId === undefined) return 'MA';
|
||||
const id = typeof stateId === 'string' ? parseInt(stateId) : stateId;
|
||||
return STATE_ABBR_MAP[id] || 'MA';
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (authService) { // Check if imported correctly
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<!-- src/pages/ticket/ticket.vue -->
|
||||
<template>
|
||||
|
||||
<div class=" max-w-5xl text-black bg-white font-mono text-md">
|
||||
<div class="grid grid-cols-12 pt-10">
|
||||
<div class="col-span-6">
|
||||
|
||||
|
||||
<div class="grid grid-cols-12">
|
||||
<div class="col-span-2 pt-2 pl-4">#2 </div>
|
||||
<div class="col-span-2 pt-2"></div>
|
||||
@@ -15,8 +13,6 @@
|
||||
<div class="col-span-3 text-xs pt-3 ">{{ customer.customer_phone_number }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-12 pt-2 pb-2">
|
||||
<div class="col-span-9 pl-5">
|
||||
{{ customer.customer_first_name }} {{ customer.customer_last_name }}
|
||||
@@ -36,28 +32,13 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-12 pl-6 pb-6 gap-10 max-h-32">
|
||||
<div class="col-span-6">
|
||||
<div class="grid grid-cols-12">
|
||||
<div class="col-span-12 ">{{ customer_description.description }}</div>
|
||||
<div class="col-span-12 " v-if="delivery.promo_id !== null">Promo: {{ promo.text_on_ticket
|
||||
}}</div>
|
||||
<div class="col-span-12 "></div>
|
||||
<div class="col-span-12 " v-if="delivery.prime == 1">PRIME</div>
|
||||
<div class="col-span-12 " v-if="delivery.same_day == 1">SAME DAY</div>
|
||||
<div class="col-span-12 " v-if="delivery.emergency == 1">EMERGENCY</div>
|
||||
|
||||
<div class="col-span-12 text-lg" v-if="delivery.payment_type == 0">CASH</div>
|
||||
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 1">Credit Card</div>
|
||||
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 2">Credit Card/Cash
|
||||
</div>
|
||||
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 3">Check</div>
|
||||
<div class="col-span-12 text-lg" v-else-if="delivery.payment_type == 4">Other</div>
|
||||
<div class="col-span-12" v-else></div>
|
||||
<div class="col-span-12 " v-if="delivery.customer_asked_for_fill == 0">
|
||||
{{ delivery.gallons_ordered }}</div>
|
||||
<div class="col-span-12 " v-else>Fill</div>
|
||||
<div class="col-span-12 text-lg">Credit Card</div>
|
||||
<div class="col-span-12" v-if="promo">{{ promo_text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-6 border-2" v-if="delivery.dispatcher_notes">
|
||||
@@ -68,8 +49,6 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-12">
|
||||
<div class="col-span-6 ">
|
||||
<div class="col-span-12 pl-5">Auburn Oil</div>
|
||||
@@ -86,11 +65,10 @@
|
||||
<div class="col-span-12 pl-5">508 426 8800</div>
|
||||
</div>
|
||||
<div class="col-span-6 ">
|
||||
<div v-if="past_deliveries1.length > 1">
|
||||
<div class="col-span-6" v-for="past_delivery in past_deliveries1">
|
||||
<div class="" v-if="past_delivery.gallons_delivered != 0.00">
|
||||
{{ past_delivery.when_delivered }} - {{ past_delivery.gallons_delivered }}
|
||||
</div>
|
||||
<div v-if="past_deliveries.length > 0">
|
||||
<div class="col-span-6" v-for="past_delivery in past_deliveries"
|
||||
:key="past_delivery.fill_date">
|
||||
{{ past_delivery.fill_date }} - {{ past_delivery.gallons_delivered }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -99,45 +77,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="col-span-6 ">
|
||||
<div class="col-span-4 ">
|
||||
<div class="grid grid-cols-12 ">
|
||||
<div class="col-span-12 h-7 pl-4 pt-2">{{ delivery.when_ordered }}</div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-2">{{ delivery.expected_delivery_date }}</div>
|
||||
|
||||
<div class="col-span-12 h-7 pl-4 pt-2" v-if="delivery.customer_asked_for_fill == 0">
|
||||
{{ delivery.gallons_ordered }}</div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-2" v-else></div>
|
||||
|
||||
<div class="col-span-12 h-7 pl-4 pt-2" v-if="promo_active">
|
||||
<div class="flex gap-2">
|
||||
<div class="line-through"> {{ delivery.customer_price }}</div> ({{ promoprice}})
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-2" v-else>{{ delivery.customer_price }}</div>
|
||||
|
||||
|
||||
<div class="col-span-12 h-7 pl-4 pt-4" v-if="delivery.customer_asked_for_fill == 0">
|
||||
<div v-if="promo_active">
|
||||
{{ total_amount_after_discount }}
|
||||
</div>
|
||||
<div v-else> {{ total_amount }}</div>
|
||||
|
||||
</div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-4" v-else></div>
|
||||
|
||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-2"></div>
|
||||
<div class="col-span-12 h-7 pl-4 pt-4"> </div>
|
||||
<div class="col-span-12 h-7 pt-6"></div>
|
||||
<div class="col-span-12 h-7"></div>
|
||||
<div class="col-span-12 h-7 pl-8"></div>
|
||||
@@ -149,39 +98,26 @@
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { deliveryService } from '../../services/deliveryService'
|
||||
import { customerService } from '../../services/customerService'
|
||||
import { adminService } from '../../services/adminService'
|
||||
import { queryService } from '../../services/queryService'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Ticket',
|
||||
|
||||
|
||||
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
user: {
|
||||
user_id: 0,
|
||||
},
|
||||
past_deliveries1: [
|
||||
{
|
||||
gallons_delivered: 0,
|
||||
when_delivered: '',
|
||||
interface PastDelivery {
|
||||
gallons_delivered: number
|
||||
fill_date: string
|
||||
}
|
||||
],
|
||||
past_deliveries2: [
|
||||
{
|
||||
gallons_delivered: 0,
|
||||
when_delivered: '',
|
||||
}
|
||||
],
|
||||
delivery: {
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const loaded = ref(false)
|
||||
const past_deliveries = ref<PastDelivery[]>([])
|
||||
const delivery = ref<any>({
|
||||
id: '',
|
||||
customer_id: 0,
|
||||
customer_name: '',
|
||||
@@ -212,8 +148,15 @@ export default defineComponent({
|
||||
driver_first_name: '',
|
||||
driver_last_name: '',
|
||||
promo_id: 0,
|
||||
},
|
||||
customer: {
|
||||
})
|
||||
const customer_tank = ref<any>({
|
||||
id: 0,
|
||||
last_tank_inspection: null,
|
||||
tank_status: false,
|
||||
outside_or_inside: false,
|
||||
tank_size: 0,
|
||||
})
|
||||
const customer = ref<any>({
|
||||
id: 0,
|
||||
user_id: 0,
|
||||
customer_first_name: '',
|
||||
@@ -226,200 +169,114 @@ export default defineComponent({
|
||||
customer_home_type: 0,
|
||||
customer_phone_number: '',
|
||||
account_number: '',
|
||||
},
|
||||
customer_tank: {
|
||||
id: 0,
|
||||
last_tank_inspection: null,
|
||||
tank_status: false,
|
||||
outside_or_inside: false,
|
||||
tank_size: 0,
|
||||
},
|
||||
customer_description: {
|
||||
})
|
||||
const customer_description = ref<any>({
|
||||
id: 0,
|
||||
customer_id: 0,
|
||||
account_number: '',
|
||||
company_id: '',
|
||||
fill_location: 0,
|
||||
description: '',
|
||||
},
|
||||
promoprice: 0,
|
||||
promo_active: false,
|
||||
promo: {
|
||||
id: 0,
|
||||
name_of_promotion: '',
|
||||
description: '',
|
||||
money_off_delivery: 0,
|
||||
text_on_ticket: ''
|
||||
},
|
||||
priceprime: 0,
|
||||
pricesameday: 0,
|
||||
priceemergency: 0,
|
||||
total_amount: 0,
|
||||
discount: 0,
|
||||
total_amount_after_discount: 0,
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.getOilOrder(this.$route.params.id)
|
||||
this.sumdelivery(this.$route.params.id);
|
||||
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.getOilOrder(this.$route.params.id)
|
||||
this.sumdelivery(this.$route.params.id);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
getOilOrder(delivery_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/delivery/order/" + delivery_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data && response.data.ok) {
|
||||
this.delivery = response.data.delivery;
|
||||
this.getCustomer(this.delivery.customer_id)
|
||||
if (this.delivery.promo_id != null) {
|
||||
this.getPromo(this.delivery.promo_id);
|
||||
this.promo_active = true;
|
||||
this.getPrice(delivery_id)
|
||||
const promo = ref<any>(null)
|
||||
const promo_text = ref('')
|
||||
const todays_price = ref(0)
|
||||
|
||||
// Methods
|
||||
async function getOrder(deliveryId: string | number) {
|
||||
try {
|
||||
const response = await deliveryService.getById(Number(deliveryId))
|
||||
delivery.value = (response.data as any)?.delivery || response.data
|
||||
getCustomer(delivery.value.customer_id)
|
||||
if (delivery.value.promo_id) {
|
||||
getPromo(delivery.value.promo_id)
|
||||
}
|
||||
} else {
|
||||
console.error("API Error:", response.data.error || "Failed to fetch delivery data.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Could not get delivery",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
getCustomerDescription(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/customer/description/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.customer_description = response.data?.description || response.data
|
||||
})
|
||||
},
|
||||
|
||||
sumdelivery(delivery_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/delivery/total/" + delivery_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
this.priceprime = response.data.priceprime;
|
||||
this.pricesameday = response.data.pricesameday;
|
||||
this.priceemergency = response.data.priceemergency;
|
||||
this.total_amount = response.data.total_amount;
|
||||
this.discount = response.data.discount;
|
||||
this.total_amount_after_discount = response.data.total_amount_after_discount;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
}
|
||||
|
||||
async function getCustomer(userId: number) {
|
||||
try {
|
||||
const response = await customerService.getById(userId)
|
||||
customer.value = (response.data as any)?.customer || response.data
|
||||
getPastDeliveries(customer.value.id)
|
||||
getCustomerDescription(customer.value.id)
|
||||
getCustomerTank(customer.value.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch customer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomerTank(userId: number) {
|
||||
try {
|
||||
const response = await customerService.getTank(userId)
|
||||
customer_tank.value = (response.data as any)?.tank || response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tank:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomerDescription(userId: number) {
|
||||
try {
|
||||
const response = await customerService.getDescription(userId)
|
||||
customer_description.value = (response.data as any)?.description || response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch description:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getPromo(promoId: number) {
|
||||
try {
|
||||
const response = await adminService.promos.getById(promoId)
|
||||
promo.value = response.data
|
||||
promo_text.value = promo.value?.text_on_ticket || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch promo:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getTodayPrice() {
|
||||
try {
|
||||
const response = await queryService.getOilPrice()
|
||||
if ((response.data as any).ok) {
|
||||
todays_price.value = (response.data as any).price_for_customer
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Could not get oil pricing",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
getCustomer(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/customer/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.customer = response.data?.customer || response.data
|
||||
this.getPastDeliveries1(this.customer.id)
|
||||
async function getPastDeliveries(userId: number) {
|
||||
try {
|
||||
const response = await deliveryService.getByCustomer(userId)
|
||||
past_deliveries.value = (response.data as any)?.deliveries || response.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch past deliveries:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.getPastDeliveries2(this.customer.id)
|
||||
this.getCustomerDescription(this.customer.id)
|
||||
this.getCustomerTank(this.customer.id)
|
||||
})
|
||||
},
|
||||
getCustomerTank(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/customer/tank/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.customer_tank = response.data?.tank || response.data
|
||||
})
|
||||
},
|
||||
|
||||
getPastDeliveries1(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/delivery/past1/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.past_deliveries1 = response.data?.deliveries || response.data
|
||||
})
|
||||
},
|
||||
getPastDeliveries2(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/delivery/past2/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.past_deliveries2 = response.data?.deliveries || response.data
|
||||
})
|
||||
},
|
||||
|
||||
getPrice(delivery_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/promo/promoprice/" + delivery_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data) {
|
||||
this.promoprice = response.data.price
|
||||
// Watchers
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) {
|
||||
getOrder(newId as string)
|
||||
getTodayPrice()
|
||||
}
|
||||
})
|
||||
},
|
||||
getPromo(promo_id: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/promo/" + promo_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data) {
|
||||
this.promo = response.data?.promo || response.data
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
},
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (route.params.id) {
|
||||
getOrder(route.params.id as string)
|
||||
getTodayPrice()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<!-- src/pages/ticket/ticketauto.vue -->
|
||||
<template>
|
||||
|
||||
<div class=" max-w-5xl text-black bg-white font-mono text-md">
|
||||
<div class="grid grid-cols-12 pt-10">
|
||||
<div class="col-span-6">
|
||||
@@ -67,10 +66,9 @@
|
||||
</div>
|
||||
<div class="col-span-6 ">
|
||||
<div v-if="past_deliveries.length > 0">
|
||||
<div class="col-span-6" v-for="past_delivery in past_deliveries">
|
||||
|
||||
<div class="col-span-6" v-for="past_delivery in past_deliveries"
|
||||
:key="past_delivery.fill_date">
|
||||
{{ past_delivery.fill_date }} - {{ past_delivery.gallons_delivered }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -100,37 +98,25 @@
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
import authHeader from '../../services/auth.header'
|
||||
import Header from '../../layouts/headers/headerauth.vue'
|
||||
import SideBar from '../../layouts/sidebar/sidebar.vue'
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { notify } from "@kyvg/vue3-notification"
|
||||
import { deliveryService } from '../../services/deliveryService'
|
||||
import { customerService } from '../../services/customerService'
|
||||
import { queryService } from '../../services/queryService'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Ticket',
|
||||
|
||||
components: {
|
||||
Header,
|
||||
SideBar,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
user: {
|
||||
user_id: 0,
|
||||
},
|
||||
past_deliveries: [
|
||||
{
|
||||
gallons_delivered: 0,
|
||||
fill_date: '',
|
||||
interface PastDelivery {
|
||||
gallons_delivered: number
|
||||
fill_date: string
|
||||
}
|
||||
],
|
||||
|
||||
delivery: {
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const loaded = ref(false)
|
||||
const past_deliveries = ref<PastDelivery[]>([])
|
||||
const delivery = ref<any>({
|
||||
id: '',
|
||||
customer_id: 0,
|
||||
customer_name: '',
|
||||
@@ -161,15 +147,15 @@ export default defineComponent({
|
||||
driver_first_name: '',
|
||||
driver_last_name: '',
|
||||
promo_id: 0,
|
||||
},
|
||||
customer_tank: {
|
||||
})
|
||||
const customer_tank = ref<any>({
|
||||
id: 0,
|
||||
last_tank_inspection: null,
|
||||
tank_status: false,
|
||||
outside_or_inside: false,
|
||||
tank_size: 0,
|
||||
},
|
||||
customer: {
|
||||
})
|
||||
const customer = ref<any>({
|
||||
id: 0,
|
||||
user_id: 0,
|
||||
customer_first_name: '',
|
||||
@@ -182,131 +168,99 @@ export default defineComponent({
|
||||
customer_home_type: 0,
|
||||
customer_phone_number: '',
|
||||
account_number: '',
|
||||
},
|
||||
customer_description: {
|
||||
})
|
||||
const customer_description = ref<any>({
|
||||
id: 0,
|
||||
customer_id: 0,
|
||||
account_number: '',
|
||||
company_id: '',
|
||||
fill_location: 0,
|
||||
description: '',
|
||||
},
|
||||
|
||||
todays_price: 0,
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.getAutoOrder(this.$route.params.id)
|
||||
this.gettodayprice();
|
||||
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.getAutoOrder(this.$route.params.id)
|
||||
this.gettodayprice();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
getAutoOrder(delivery_id: any) {
|
||||
let path = import.meta.env.VITE_AUTO_URL + "/delivery/delivery/" + delivery_id;
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
this.delivery = response.data?.delivery || response.data;
|
||||
this.getCustomer(this.delivery.customer_id)
|
||||
const todays_price = ref(0)
|
||||
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
// Methods
|
||||
async function getAutoOrder(deliveryId: string | number) {
|
||||
try {
|
||||
const response = await deliveryService.getAutoDelivery(Number(deliveryId))
|
||||
delivery.value = (response.data as any)?.delivery || response.data
|
||||
getCustomer(delivery.value.customer_id)
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Could not get delivery",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
getCustomerTank(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/customer/tank/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.customer_tank = response.data?.tank || response.data
|
||||
})
|
||||
},
|
||||
getCustomerDescription(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/customer/description/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.customer_description = response.data?.description || response.data
|
||||
})
|
||||
},
|
||||
|
||||
gettodayprice() {
|
||||
let path = import.meta.env.VITE_BASE_URL + "/info/price/oil";
|
||||
axios({
|
||||
method: "get",
|
||||
url: path,
|
||||
withCredentials: true,
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then((response: any) => {
|
||||
if (response.data.ok) {
|
||||
|
||||
this.todays_price = response.data.price_for_customer;
|
||||
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
}
|
||||
|
||||
async function getCustomer(userId: number) {
|
||||
try {
|
||||
const response = await customerService.getById(userId)
|
||||
customer.value = (response.data as any)?.customer || response.data
|
||||
getPastDeliveriesAuto(customer.value.id)
|
||||
getCustomerDescription(customer.value.id)
|
||||
getCustomerTank(customer.value.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch customer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomerTank(userId: number) {
|
||||
try {
|
||||
const response = await customerService.getTank(userId)
|
||||
customer_tank.value = (response.data as any)?.tank || response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tank:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomerDescription(userId: number) {
|
||||
try {
|
||||
const response = await customerService.getDescription(userId)
|
||||
customer_description.value = (response.data as any)?.description || response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch description:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getTodayPrice() {
|
||||
try {
|
||||
const response = await queryService.getOilPrice()
|
||||
if ((response.data as any).ok) {
|
||||
todays_price.value = (response.data as any).price_for_customer
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: "Error",
|
||||
text: "Could not get oil pricing",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getCustomer(userid: any) {
|
||||
let path = import.meta.env.VITE_BASE_URL + '/customer/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.customer = response.data?.customer || response.data
|
||||
this.getPastDeliveriesAuto(this.customer.id)
|
||||
this.getCustomerDescription(this.customer.id)
|
||||
this.getCustomerTank(this.customer.id)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
getPastDeliveriesAuto(userid: any) {
|
||||
let path = import.meta.env.VITE_AUTO_URL + '/delivery/all/profile/' + userid;
|
||||
axios({
|
||||
method: 'get',
|
||||
url: path,
|
||||
headers: authHeader(),
|
||||
}).then((response: any) => {
|
||||
this.past_deliveries = response.data?.deliveries || response.data
|
||||
async function getPastDeliveriesAuto(userId: number) {
|
||||
try {
|
||||
const response = await deliveryService.getAutoDeliveriesByCustomer(userId)
|
||||
past_deliveries.value = (response.data as any)?.deliveries || response.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch past deliveries:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) {
|
||||
getAutoOrder(newId as string)
|
||||
getTodayPrice()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
},
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (route.params.id) {
|
||||
getAutoOrder(route.params.id as string)
|
||||
getTodayPrice()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,6 @@ import cardRoutes from '../pages/card/routes.ts';
|
||||
import autoRoutes from '../pages/automatic/routes.ts';
|
||||
import adminRoutes from "../pages/admin/routes.ts";
|
||||
import tickerRoutes from "../pages/ticket/routes.ts";
|
||||
import moneyRoutes from "../pages/money/routes.ts";
|
||||
import serviceRoutes from "../pages/service/routes.ts";
|
||||
import transactionsRoutes from '../pages/transactions/routes.ts';
|
||||
import statsRoutes from '../pages/stats/routes.ts';
|
||||
@@ -18,6 +17,7 @@ import statsRoutes from '../pages/stats/routes.ts';
|
||||
// Import your page components
|
||||
import Home from '../pages/Index.vue';
|
||||
import Error404 from '../pages/error/Error404.vue';
|
||||
import PricingHistory from '../pages/info/PricingHistory.vue';
|
||||
|
||||
// Import layouts
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
@@ -47,7 +47,6 @@ const routes = [
|
||||
path: '/',
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
...protectRoutes(moneyRoutes),
|
||||
...protectRoutes(cardRoutes),
|
||||
...protectRoutes(payRoutes),
|
||||
...protectRoutes(employeeRoutes),
|
||||
@@ -65,6 +64,12 @@ const routes = [
|
||||
component: Home,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/info/pricing-history',
|
||||
name: 'PricingHistory',
|
||||
component: PricingHistory,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/error404',
|
||||
name: 'Error404',
|
||||
|
||||
@@ -28,6 +28,12 @@ const serviceApi = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Scraper Service
|
||||
const scraperApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_SCRAPER_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token
|
||||
function addAuthHeader(config: { headers: { Authorization?: string } }) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -51,6 +57,8 @@ autoApi.interceptors.request.use(addAuthHeader as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serviceApi.interceptors.request.use(addAuthHeader as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
scraperApi.interceptors.request.use(addAuthHeader as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
axios.interceptors.request.use(addAuthHeader as any);
|
||||
|
||||
// Response interceptor - unwrap standardized API responses
|
||||
@@ -95,7 +103,8 @@ api.interceptors.response.use(unwrapResponse, handleResponseError);
|
||||
authorizeApi.interceptors.response.use(unwrapResponse, handleResponseError);
|
||||
autoApi.interceptors.response.use(unwrapResponse, handleResponseError);
|
||||
serviceApi.interceptors.response.use(unwrapResponse, handleResponseError);
|
||||
scraperApi.interceptors.response.use(unwrapResponse, handleResponseError);
|
||||
axios.interceptors.response.use(unwrapResponse, handleResponseError);
|
||||
|
||||
export { api, authorizeApi, autoApi, serviceApi };
|
||||
export { api, authorizeApi, autoApi, serviceApi, scraperApi };
|
||||
export default api;
|
||||
|
||||
@@ -146,7 +146,20 @@ export const deliveryService = {
|
||||
|
||||
estimateGallons: (customerId: number) =>
|
||||
autoApi.get(`/fixstuff_customer/estimate_gallons/customer/${customerId}`),
|
||||
|
||||
updateHouseFactor: (customerId: number, data: { house_factor: number }) =>
|
||||
autoApi.put(`/delivery/auto/customer/${customerId}/house_factor`, data),
|
||||
|
||||
updateCustomerHouseFactor: (customerId: number, data: { house_factor: number }) =>
|
||||
autoApi.put(`/fixstuff_customer/house_factor/${customerId}`, data),
|
||||
},
|
||||
|
||||
// Convenience wrappers for commonly used auto operations
|
||||
getAutoDelivery: (id: number) =>
|
||||
autoApi.get(`/delivery/delivery/${id}`),
|
||||
|
||||
getAutoDeliveriesByCustomer: (customerId: number) =>
|
||||
autoApi.get(`/delivery/all/profile/${customerId}`),
|
||||
};
|
||||
|
||||
export default deliveryService;
|
||||
|
||||
@@ -107,7 +107,24 @@ export const paymentService = {
|
||||
|
||||
linkTransactionToAuto: (transactionId: number, autoId: number, data?: Record<string, unknown>): Promise<AxiosResponse<{ ok: boolean }>> =>
|
||||
authorizeApi.put(`/api/transaction/${transactionId}/update_auto_id/${autoId}`, data ?? {}),
|
||||
|
||||
// Check authorize account status
|
||||
checkAccount: (customerId: number): Promise<AxiosResponse<{ profile_exists: boolean; has_payment_methods: boolean; missing_components: string[]; valid_for_charging: boolean }>> =>
|
||||
authorizeApi.get(`/user/check-authorize-account/${customerId}`),
|
||||
},
|
||||
|
||||
// Convenience wrappers for commonly used operations
|
||||
getCardById: (id: number): Promise<AxiosResponse<CardResponse>> =>
|
||||
api.get(`/payment/card/${id}`),
|
||||
|
||||
checkAuthorizeAccount: (customerId: number): Promise<AxiosResponse<{ profile_exists: boolean; has_payment_methods: boolean; missing_components: string[]; valid_for_charging: boolean }>> =>
|
||||
authorizeApi.get(`/user/check-authorize-account/${customerId}`),
|
||||
|
||||
createAuthorizeCard: (customerId: number, data: TokenizeCardRequest): Promise<AxiosResponse<CardResponse>> =>
|
||||
authorizeApi.post(`/api/payments/customers/${customerId}/cards`, data),
|
||||
|
||||
updateAuthorizeCard: (customerId: number, cardId: number, data: UpdateTokenizedCardRequest): Promise<AxiosResponse<CardResponse>> =>
|
||||
authorizeApi.put(`/api/payments/customers/${customerId}/cards/${cardId}`, data),
|
||||
};
|
||||
|
||||
export default paymentService;
|
||||
|
||||
19
src/services/scraperService.ts
Normal file
19
src/services/scraperService.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
import { AxiosResponse } from '../types/models';
|
||||
import { scraperApi } from './api';
|
||||
|
||||
export interface PriceRecord {
|
||||
company_name: string;
|
||||
town?: string;
|
||||
price_decimal: number;
|
||||
scrape_date: string;
|
||||
zone: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const scraperService = {
|
||||
getLatestPrices(): Promise<AxiosResponse<PriceRecord[]>> {
|
||||
return scraperApi.get('/scraper/prices');
|
||||
}
|
||||
};
|
||||
93
src/stores/oilPrice.ts
Normal file
93
src/stores/oilPrice.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { scraperService, PriceRecord } from '../services/scraperService';
|
||||
import { useNotification } from '@kyvg/vue3-notification';
|
||||
|
||||
export const useOilPriceStore = defineStore('oilPrice', () => {
|
||||
const prices = ref<PriceRecord[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const { notify } = useNotification();
|
||||
let intervalId: number | null = null;
|
||||
|
||||
const fetchPrices = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await scraperService.getLatestPrices();
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
const newPrices = response.data;
|
||||
|
||||
// Check for price changes since last visit
|
||||
checkPriceChange(newPrices);
|
||||
|
||||
prices.value = newPrices;
|
||||
|
||||
// Store current prices for next time
|
||||
localStorage.setItem('last_oil_prices', JSON.stringify(newPrices));
|
||||
localStorage.setItem('last_price_check', new Date().toISOString());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch oil prices:', err);
|
||||
// Don't show error to user as this is a background feature
|
||||
error.value = 'Failed to load oil prices';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (intervalId) return;
|
||||
fetchPrices(); // Initial fetch
|
||||
intervalId = window.setInterval(fetchPrices, 1 * 60 * 1000);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const checkPriceChange = (newPrices: PriceRecord[]) => {
|
||||
const stored = localStorage.getItem('last_oil_prices');
|
||||
if (!stored) return;
|
||||
|
||||
try {
|
||||
const oldPrices: PriceRecord[] = JSON.parse(stored);
|
||||
|
||||
// Find lowest price in old and new to compare generally?
|
||||
// Or simply alert if ANY price changed significantly?
|
||||
// Let's find the lowest price change which is most relevant.
|
||||
|
||||
const getLowest = (list: PriceRecord[]) => Math.min(...list.map(p => p.price_decimal));
|
||||
|
||||
const oldLow = getLowest(oldPrices);
|
||||
const newLow = getLowest(newPrices);
|
||||
|
||||
if (oldLow && newLow && oldLow !== newLow) {
|
||||
const diff = newLow - oldLow;
|
||||
const trend = diff > 0 ? 'up' : 'down';
|
||||
const type = diff > 0 ? 'warn' : 'success'; // Green for price drop!
|
||||
|
||||
notify({
|
||||
title: 'Oil Price Update',
|
||||
text: `Prices have gone ${trend} by $${Math.abs(diff).toFixed(3)}. Lowest: $${newLow}`,
|
||||
type: type,
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking price changes', e);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
prices,
|
||||
loading,
|
||||
error,
|
||||
fetchPrices,
|
||||
startPolling,
|
||||
stopPolling
|
||||
};
|
||||
});
|
||||
@@ -129,6 +129,9 @@ export interface Delivery extends BaseEntity {
|
||||
prime: number;
|
||||
same_day: number;
|
||||
emergency: number;
|
||||
pricing_tier_same_day?: number;
|
||||
pricing_tier_prime?: number;
|
||||
pricing_tier_emergency?: number;
|
||||
payment_type: number;
|
||||
payment_card_id?: number;
|
||||
cash_recieved?: number;
|
||||
@@ -278,6 +281,10 @@ export interface AutoDelivery extends BaseEntity {
|
||||
days_since_last_fill: number;
|
||||
hot_water_summer: number;
|
||||
open_ticket_id?: number;
|
||||
confidence_score: number;
|
||||
k_factor_source: string;
|
||||
days_remaining: number;
|
||||
gallons_per_day: number;
|
||||
}
|
||||
|
||||
// Promo interfaces
|
||||
@@ -614,6 +621,9 @@ export interface DeliveryFormData {
|
||||
promo_id: number | null;
|
||||
emergency: number;
|
||||
same_day: number;
|
||||
pricing_tier_same_day?: number;
|
||||
pricing_tier_prime?: number;
|
||||
pricing_tier_emergency?: number;
|
||||
payment_type: number;
|
||||
payment_card_id: number;
|
||||
driver_employee_id: number;
|
||||
@@ -715,6 +725,10 @@ export interface AutoDeliveryData {
|
||||
house_factor: number;
|
||||
auto_status: number;
|
||||
open_ticket_id: number | null;
|
||||
confidence_score: number;
|
||||
k_factor_source: string;
|
||||
days_remaining: number;
|
||||
gallons_per_day: number;
|
||||
}
|
||||
|
||||
export interface CustomerDescriptionData {
|
||||
|
||||
61
src/utils/addressUtils.ts
Normal file
61
src/utils/addressUtils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { STATE_ID_TO_ABBR } from '../constants/states';
|
||||
|
||||
/**
|
||||
* Formats an address string with street, town, state, and zip.
|
||||
* Handles missing or invalid state IDs gracefully.
|
||||
*
|
||||
* @param address Street address (e.g. "123 Main St")
|
||||
* @param town Town name (e.g. "Springfield")
|
||||
* @param stateId State ID (integer)
|
||||
* @param zip Zip code
|
||||
* @param includeUsa Whether to append ", USA" (default false)
|
||||
* @returns Formatted address string
|
||||
*/
|
||||
export const formatAddress = (
|
||||
address: string,
|
||||
town: string,
|
||||
stateId: number | string,
|
||||
zip: string | number
|
||||
): string => {
|
||||
const cleanAddress = (address || '').trim();
|
||||
const cleanTown = (town || '').trim();
|
||||
const cleanZip = String(zip || '').trim();
|
||||
|
||||
const parsedStateId = typeof stateId === 'string' ? parseInt(stateId, 10) : stateId;
|
||||
const stateAbbr = STATE_ID_TO_ABBR[parsedStateId] || 'MA'; // Default to MA if not found
|
||||
|
||||
let formatted = cleanAddress;
|
||||
|
||||
if (cleanTown) {
|
||||
formatted += formatted ? `, ${cleanTown}` : cleanTown;
|
||||
}
|
||||
|
||||
if (stateAbbr) {
|
||||
formatted += formatted ? `, ${stateAbbr}` : stateAbbr;
|
||||
}
|
||||
|
||||
if (cleanZip) {
|
||||
formatted += formatted ? ` ${cleanZip}` : cleanZip;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a Google Maps link for the given address components.
|
||||
*
|
||||
* @param address Street address
|
||||
* @param town Town name
|
||||
* @param stateId State ID
|
||||
* @param zip Zip code
|
||||
* @returns Google Maps URL
|
||||
*/
|
||||
export const getGoogleMapsLink = (
|
||||
address: string,
|
||||
town: string,
|
||||
stateId: number | string,
|
||||
zip: string | number
|
||||
): string => {
|
||||
const fullAddress = formatAddress(address, town, stateId, zip);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user