feat: service calendar UX, alert button, stats, auto delivery fixes, and more

- Service calendar: remove manual label input (auto-generates as "Type - Name"),
  add quick time buttons (7am/9am/11am/1pm/3pm), click-to-highlight date cell,
  orange Add Event button
- Alert button on customer profile always red (not conditional)
- Customer alert popup system (severity levels, confirm-to-dismiss for critical)
- Auto delivery finalize: async submit guards, race condition and null-check fixes
- Tank estimation: gallons-to-fill stat, K-factor slider hidden from non-admins,
  last fill / days-since-last-fill stat boxes
- Auto page: removed Confidence column (never meaningfully updates)
- Stats: 4-month chart on home, full-year weekly stats page, customer signups graph
- Delivery map: quick-date buttons, All Eligible mode, zip padding fix
- Ticket: past deliveries limit + a-/wc- prefix, 2-page print fix
- Address checker: OSM false-street fix, zip capture, street manager admin page
- Customer create: quick-town buttons for 15 service area towns
- Customer profile: map pin with popup
- Sidebar: Stats section, admin streets link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:40:43 -04:00
parent 203fbc2175
commit afdb9eb4e0
43 changed files with 1838 additions and 562 deletions
+1
View File
@@ -7,6 +7,7 @@ ENV VITE_AUTHORIZE_URL="http://192.168.1.204:9616"
ENV VITE_VOIPMS_URL="http://192.168.1.204:9617"
ENV VITE_SERVICE_URL="http://192.168.1.204:9615"
ENV VITE_ADDRESS_CHECKER_URL="http://192.168.1.204:9618"
ENV VITE_SCRAPER_URL="http://192.168.1.204:9619"
ENV VITE_COMPANY_ID='1'
+6 -6
View File
@@ -163,11 +163,11 @@ const formatDate = (dateStr: string) => {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
};
watch(days, () => {
fetchData();
});
// watch(days, () => {
// fetchData();
// });
onMounted(() => {
fetchData();
});
// onMounted(() => {
// fetchData();
// });
</script>
+20 -18
View File
@@ -16,14 +16,14 @@
Home
</router-link>
</li>
<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>
</li> -->
<!-- Customer Section -->
<li>
@@ -157,6 +157,23 @@
</details>
</li>
<!-- Stats Section -->
<li v-if="settingsStore.settings.show_stats">
<details>
<summary class="font-bold text-lg 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="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>
Stats
</summary>
<ul>
<li><router-link :to="{ name: 'statsDailyDeliveries' }" exact-active-class="active">Daily Deliveries</router-link></li>
<li><router-link :to="{ name: 'statsTotals' }" exact-active-class="active">Totals Comparison</router-link></li>
<li><router-link :to="{ name: 'statsCustomers' }" exact-active-class="active">Customer Sign-ups</router-link></li>
</ul>
</details>
</li>
<!-- Admin Section -->
<li>
<details>
@@ -171,26 +188,11 @@
<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: 'streetManager' }" exact-active-class="active">Street Manager</router-link></li>
<li><router-link :to="{ name: 'settings' }" exact-active-class="active">Settings</router-link></li>
</ul>
</details>
</li>
<!-- Stats Section -->
<li v-if="settingsStore.settings.show_stats">
<details>
<summary class="font-bold text-lg 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="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>
Stats
</summary>
<ul>
<li><router-link :to="{ name: 'statsDailyDeliveries' }" exact-active-class="active">Daily Deliveries</router-link></li>
<li><router-link :to="{ name: 'statsTotals' }" exact-active-class="active">Totals Comparison</router-link></li>
</ul>
</details>
</li>
</ul>
</template>
+66 -23
View File
@@ -97,7 +97,7 @@
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold">Weekly Delivery Trend</h3>
<p class="text-sm text-base-content/50">Gallons delivered over the past 4 weeks</p>
<p class="text-sm text-base-content/50">Gallons delivered over the past 4 months (weekly)</p>
</div>
<router-link :to="{ name: 'stats' }" class="btn btn-ghost btn-sm gap-1">
View Stats
@@ -555,32 +555,75 @@ const fetchMapDeliveries = async () => {
const fetchWeeklyChartData = async () => {
try {
// Get last 28 days of data
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - 28)
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 4)
const currentYear = new Date().getFullYear()
const queryParams = new URLSearchParams({
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
years: currentYear.toString()
})
const toLocalDateStr = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const path = import.meta.env.VITE_BASE_URL + `/stats/gallons/daily?${queryParams.toString()}`
const response = await axios({
method: "get",
url: path,
withCredentials: true,
headers: authHeader(),
})
// Collect all daily points into a map keyed by date string.
// The backend replaces the year in start/end with the requested year, so we must
// never pass a cross-year range — split at Dec 31 / Jan 1 if needed.
const dailyMap = new Map<string, number>()
if (response.data.ok && response.data.years?.length > 0) {
weeklyData.value = response.data.years[0].data.map((d: { date: string; gallons: number }) => ({
date: d.date,
gallons: d.gallons
}))
const fetchRange = async (rangeStart: Date, rangeEnd: Date) => {
const yr = rangeStart.getFullYear()
const params = new URLSearchParams({
start_date: toLocalDateStr(rangeStart),
end_date: toLocalDateStr(rangeEnd),
years: yr.toString()
})
const resp = await axios({
method: 'get',
url: import.meta.env.VITE_BASE_URL + `/stats/gallons/daily?${params}`,
withCredentials: true,
headers: authHeader(),
})
if (resp.data.ok && resp.data.years?.length > 0) {
for (const pt of resp.data.years[0].data as { date: string; gallons: number }[]) {
dailyMap.set(pt.date, (dailyMap.get(pt.date) || 0) + pt.gallons)
}
}
}
if (start.getFullYear() === end.getFullYear()) {
await fetchRange(start, end)
} else {
// Split at year boundary to keep each call within a single year
await Promise.all([
fetchRange(start, new Date(start.getFullYear(), 11, 31)),
fetchRange(new Date(end.getFullYear(), 0, 1), end),
])
}
// Aggregate daily data into weekly buckets, each keyed by its Saturday
const weekMap = new Map<string, number>()
for (const [dateStr, gallons] of dailyMap.entries()) {
const d = new Date(dateStr + 'T12:00:00') // noon avoids DST/timezone edge cases
const daysToSat = (6 - d.getDay() + 7) % 7
const sat = new Date(d)
sat.setDate(d.getDate() + daysToSat)
const satKey = toLocalDateStr(sat)
weekMap.set(satKey, (weekMap.get(satKey) || 0) + gallons)
}
// Generate every Saturday in the 4-month window
const saturdays: string[] = []
const cur = new Date(start)
cur.setHours(12, 0, 0, 0)
cur.setDate(cur.getDate() + (6 - cur.getDay() + 7) % 7)
const endNoon = new Date(end)
endNoon.setHours(12, 0, 0, 0)
while (cur <= endNoon) {
saturdays.push(toLocalDateStr(cur))
cur.setDate(cur.getDate() + 7)
}
weeklyData.value = saturdays.map(sat => ({
date: sat,
gallons: weekMap.get(sat) || 0
}))
} catch (err) {
console.error('Error fetching chart data:', err)
}
+6
View File
@@ -8,6 +8,7 @@ const PromoEdit = () => import('../admin/promo/edit.vue');
const StatsHome = () => import('../admin/stats/StatsHome.vue');
const SettingsPage = () => import('../admin/settings/SettingsPage.vue');
const StreetManager = () => import('../admin/streets.vue');
const adminRoutes = [
@@ -43,6 +44,11 @@ const adminRoutes = [
name: 'settings',
component: SettingsPage,
},
{
path: '/streets',
name: 'streetManager',
component: StreetManager,
},
]
+319
View File
@@ -0,0 +1,319 @@
<!-- src/pages/admin/streets.vue -->
<template>
<div class="flex">
<div class="w-full px-4 md:px-10 py-4">
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs">
<ul>
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li>Street Manager</li>
</ul>
</div>
<!-- Page Header -->
<div class="mt-4 mb-6">
<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-info to-info/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-info-content">
<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>
</div>
Street Manager
</h1>
<p class="text-base-content/60 mt-1 ml-13">Manage street reference data used for address autocomplete. Update re-fetches streets from OpenStreetMap.</p>
</div>
<!-- Service unavailable banner -->
<div v-if="serviceDown" class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span>Address checker service is unavailable. Streets cannot be updated.</span>
</div>
<!-- Add Town card -->
<div class="modern-table-card p-5 mb-4">
<h2 class="text-sm font-bold uppercase tracking-wide text-base-content/50 mb-3">Add Town</h2>
<form class="flex flex-wrap gap-3 items-end" @submit.prevent="addTown">
<div class="form-control">
<label class="label py-0 mb-1"><span class="label-text text-xs">Town Name</span></label>
<input
v-model="newTown"
type="text"
placeholder="e.g. Auburn"
class="input input-bordered input-sm w-48"
:disabled="isAdding"
/>
</div>
<div class="form-control">
<label class="label py-0 mb-1"><span class="label-text text-xs">State</span></label>
<select v-model="newState" class="select select-bordered select-sm" :disabled="isAdding">
<option v-for="s in US_STATES" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<button
type="submit"
class="btn btn-sm btn-primary"
:disabled="!newTown.trim() || isAdding"
>
<span v-if="isAdding" class="loading loading-spinner loading-xs"></span>
<span v-else>Add &amp; Fetch Streets</span>
</button>
</form>
</div>
<!-- Towns table -->
<div class="modern-table-card">
<!-- Loading -->
<div v-if="isLoading" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg text-info"></span>
</div>
<!-- Empty -->
<div v-else-if="towns.length === 0" class="flex flex-col items-center justify-center py-16 text-base-content/50">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mb-3">
<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>
<p class="font-medium">No towns yet</p>
<p class="text-sm mt-1">Add a town above to get started.</p>
</div>
<!-- Table -->
<div v-else class="overflow-x-auto">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs uppercase tracking-wide text-base-content/50">
<th>Town</th>
<th>State</th>
<th class="text-right">Streets</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="town in towns" :key="rowKey(town)" class="hover">
<!-- Town name static or inline edit -->
<td class="font-medium">
<template v-if="editingKey === rowKey(town)">
<input
v-model="editValue"
type="text"
class="input input-bordered input-xs w-40"
@keydown.enter="saveRename(town)"
@keydown.escape="cancelEdit"
ref="editInput"
/>
</template>
<template v-else>
{{ town.town }}
</template>
</td>
<td><span class="badge badge-ghost badge-sm">{{ town.state }}</span></td>
<td class="text-right font-mono text-sm">{{ town.street_count }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<!-- Editing mode: Save / Cancel -->
<template v-if="editingKey === rowKey(town)">
<button
class="btn btn-xs btn-success"
:disabled="renamingKey === rowKey(town)"
@click="saveRename(town)"
>
<span v-if="renamingKey === rowKey(town)" class="loading loading-spinner loading-xs"></span>
<span v-else>Save</span>
</button>
<button class="btn btn-xs btn-ghost" @click="cancelEdit">Cancel</button>
</template>
<!-- Normal mode: Edit / Update -->
<template v-else>
<button
class="btn btn-xs btn-ghost"
:disabled="updatingKey === rowKey(town)"
@click="startEdit(town)"
title="Rename town"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3 h-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>
</button>
<button
class="btn btn-xs btn-info"
:disabled="updatingKey === rowKey(town)"
@click="updateStreets(town)"
>
<span v-if="updatingKey === rowKey(town)" class="loading loading-spinner loading-xs"></span>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3 h-3 inline mr-1">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Update
</template>
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { notify } from '@kyvg/vue3-notification'
import { addressService, TownStreetInfo } from '../../services/addressService'
// ── State ──────────────────────────────────────────────────────────────────
const isLoading = ref(true)
const serviceDown = ref(false)
const towns = ref<TownStreetInfo[]>([])
// Add-town form
const newTown = ref('')
const newState = ref('MA')
const isAdding = ref(false)
// Which row is currently being updated / renamed (null = none)
const updatingKey = ref<string | null>(null)
const editingKey = ref<string | null>(null)
const renamingKey = ref<string | null>(null)
const editValue = ref('')
const editInput = ref<HTMLInputElement | null>(null)
// ── Helpers ────────────────────────────────────────────────────────────────
const rowKey = (t: TownStreetInfo) => `${t.town}|${t.state}`
const US_STATES = [
'AL','AK','AZ','AR','CA','CO','CT','DE','DC','FL','GA','HI','ID','IL','IN',
'IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH',
'NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT',
'VT','VA','WA','WV','WI','WY',
]
// ── Data loading ───────────────────────────────────────────────────────────
const loadTowns = async () => {
isLoading.value = true
try {
const res = await addressService.listTowns()
towns.value = res.data.towns
} catch {
serviceDown.value = true
} finally {
isLoading.value = false
}
}
// ── Core action: fetch streets for a town (always clears existing first) ───
const fetchStreets = async (town: string, state: string): Promise<number> => {
const res = await addressService.populateStreets(town, state, true)
return res.data.streets_added
}
// ── Update existing town ───────────────────────────────────────────────────
const updateStreets = async (town: TownStreetInfo) => {
updatingKey.value = rowKey(town)
try {
const count = await fetchStreets(town.town, town.state)
const idx = towns.value.findIndex(t => rowKey(t) === rowKey(town))
if (idx !== -1) towns.value[idx].street_count = count
notify({ title: 'Streets Updated', text: `${town.town}: ${count} streets.`, type: 'success' })
} catch (err: any) {
notify({
title: 'Update Failed',
text: err?.response?.data?.detail || `Failed to update ${town.town}.`,
type: 'error',
})
} finally {
updatingKey.value = null
}
}
// ── Rename town ────────────────────────────────────────────────────────────
const startEdit = (town: TownStreetInfo) => {
editingKey.value = rowKey(town)
editValue.value = town.town
nextTick(() => editInput.value?.focus())
}
const cancelEdit = () => {
editingKey.value = null
editValue.value = ''
}
const saveRename = async (town: TownStreetInfo) => {
const newName = editValue.value.trim()
if (!newName || newName === town.town) { cancelEdit(); return }
renamingKey.value = rowKey(town)
try {
await addressService.renameTown(town.town, town.state, newName)
const idx = towns.value.findIndex(t => rowKey(t) === rowKey(town))
if (idx !== -1) {
towns.value[idx] = { ...towns.value[idx], town: newName }
towns.value.sort((a, b) => a.town.localeCompare(b.town))
}
notify({ title: 'Town Renamed', text: `'${town.town}' → '${newName}'`, type: 'success' })
cancelEdit()
} catch (err: any) {
notify({
title: 'Rename Failed',
text: err?.response?.data?.detail || `Could not rename ${town.town}.`,
type: 'error',
})
} finally {
renamingKey.value = null
}
}
// ── Add new town ───────────────────────────────────────────────────────────
const addTown = async () => {
const town = newTown.value.trim()
const state = newState.value
if (!town) return
// Prevent duplicates
if (towns.value.some(t => t.town.toLowerCase() === town.toLowerCase() && t.state === state)) {
notify({ title: 'Already exists', text: `${town}, ${state} is already in the list.`, type: 'warning' })
return
}
isAdding.value = true
try {
const count = await fetchStreets(town, state)
towns.value.push({ town, state, street_count: count })
towns.value.sort((a, b) => a.town.localeCompare(b.town))
notify({ title: 'Town Added', text: `${town}, ${state}: ${count} streets fetched.`, type: 'success' })
newTown.value = ''
} catch (err: any) {
notify({
title: 'Failed to Add Town',
text: err?.response?.data?.detail || `Could not fetch streets for ${town}, ${state}.`,
type: 'error',
})
} finally {
isAdding.value = false
}
}
onMounted(loadTowns)
</script>
-17
View File
@@ -89,12 +89,6 @@
<span v-if="sortKey === 'house_factor'" class="ml-1">{{ sortAsc ? '&#9650;' : '&#9660;' }}</span>
<span v-else class="ml-1 opacity-30 text-xs">&#8693;</span>
</th>
<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 ? '&#9650;' : '&#9660;' }}</span>
<span v-else class="ml-1 opacity-30 text-xs">&#8693;</span>
</th>
<th @click="sortBy('days_remaining')" class="cursor-pointer select-none whitespace-nowrap w-[8%]"
:class="{ 'text-primary': sortKey === 'days_remaining' }">
Days Left
@@ -143,11 +137,6 @@
<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>
@@ -225,12 +214,6 @@
<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>
+292
View File
@@ -0,0 +1,292 @@
<template>
<div class="w-full min-h-screen px-4 md:px-10 pb-10">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4 pt-4">
<ul>
<li><router-link :to="{ name: 'customer' }">Customers</router-link></li>
<li v-if="customer">
<router-link :to="{ name: 'customerProfile', params: { id: customerId } }">
{{ customer.customer_first_name }} {{ customer.customer_last_name }}
</router-link>
</li>
<li>Alert</li>
</ul>
</div>
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<router-link :to="{ name: 'customerProfile', params: { id: customerId } }" class="btn btn-ghost btn-sm">
<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="M15 19l-7-7 7-7"/>
</svg>
Back
</router-link>
<div>
<h1 class="text-2xl font-bold">Customer Alert</h1>
<p v-if="customer" class="text-sm opacity-60">
{{ customer.customer_first_name }} {{ customer.customer_last_name }} #{{ customer.account_number }}
</p>
</div>
</div>
<div v-if="isLoading" class="flex justify-center mt-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- Form card -->
<div class="card-glass p-6">
<h2 class="text-lg font-semibold mb-5">
{{ existingAlert ? 'Edit Alert' : 'Add Alert' }}
</h2>
<!-- Severity selector -->
<div class="form-control mb-6">
<label class="label pb-1">
<span class="label-text font-medium">Severity Level</span>
</label>
<div class="flex flex-col gap-3">
<label class="flex items-start gap-3 cursor-pointer p-3 rounded-lg border-2 transition-colors"
:class="form.severity === 0 ? 'border-info bg-info/10' : 'border-base-300 hover:border-info/40'">
<input type="radio" v-model="form.severity" :value="0" class="radio radio-info mt-0.5" />
<div>
<p class="font-semibold text-info">Info</p>
<p class="text-xs opacity-70">Simple popup with an X button. Dismisses quickly.</p>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer p-3 rounded-lg border-2 transition-colors"
:class="form.severity === 1 ? 'border-warning bg-warning/10' : 'border-base-300 hover:border-warning/40'">
<input type="radio" v-model="form.severity" :value="1" class="radio radio-warning mt-0.5" />
<div>
<p class="font-semibold text-warning">Notice</p>
<p class="text-xs opacity-70">Orange background with siren icons. Must click X to close.</p>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer p-3 rounded-lg border-2 transition-colors"
:class="form.severity === 2 ? 'border-error bg-error/10' : 'border-base-300 hover:border-error/40'">
<input type="radio" v-model="form.severity" :value="2" class="radio radio-error mt-0.5" />
<div>
<p class="font-semibold text-error">Critical</p>
<p class="text-xs opacity-70">Cannot be dismissed without typing "confirm". For serious issues only.</p>
</div>
</label>
</div>
</div>
<!-- Message -->
<div class="form-control mb-6">
<label class="label pb-1">
<span class="label-text font-medium">Alert Message</span>
</label>
<textarea v-model="form.message"
class="textarea textarea-bordered w-full"
:class="{ 'textarea-error': messageError }"
rows="4"
placeholder="Describe the issue or reason for this alert..." />
<p v-if="messageError" class="text-error text-xs mt-1">{{ messageError }}</p>
</div>
<!-- Action buttons -->
<div class="flex flex-wrap gap-2">
<button @click="saveAlert" class="btn btn-primary" :disabled="isSaving">
<span v-if="isSaving" class="loading loading-spinner loading-xs mr-1"></span>
{{ existingAlert ? 'Update Alert' : 'Create Alert' }}
</button>
<button v-if="existingAlert" @click="confirmDelete = true" class="btn btn-error btn-outline">
Delete Alert
</button>
<router-link :to="{ name: 'customerProfile', params: { id: customerId } }" class="btn btn-ghost">
Cancel
</router-link>
</div>
</div>
<!-- Preview card -->
<div class="card-glass p-6">
<h2 class="text-lg font-semibold mb-4">Preview</h2>
<p class="text-sm opacity-60 mb-4">This is how the popup will appear on the customer profile.</p>
<!-- Info preview -->
<div v-if="form.severity === 0" class="border-2 border-base-300 rounded-xl p-4 bg-base-100">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="font-bold">Customer Alert</span>
</div>
<button class="btn btn-xs btn-circle btn-ghost"></button>
</div>
<p class="text-sm whitespace-pre-wrap">{{ form.message || 'Your alert message will appear here...' }}</p>
</div>
<!-- Notice preview -->
<div v-else-if="form.severity === 1"
class="rounded-xl p-4 bg-orange-500 text-white border-4 border-orange-700">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">🚨</span>
<span class="font-bold text-xl tracking-widest">NOTICE</span>
<span class="text-xl">🚨</span>
</div>
<button class="btn btn-xs btn-circle bg-transparent border-white text-white"></button>
</div>
<div class="bg-orange-600 rounded-lg p-3">
<p class="text-sm font-medium text-center whitespace-pre-wrap">
{{ form.message || 'Your alert message will appear here...' }}
</p>
</div>
</div>
<!-- Critical preview -->
<div v-else class="rounded-xl p-4 border-4 border-red-500" style="background-color: #1a0000;">
<div class="text-center mb-3">
<div class="text-3xl mb-2"></div>
<span class="font-bold text-xl text-red-400 tracking-wider">CRITICAL ALERT</span>
</div>
<div class="rounded-lg p-3 mb-4 border border-red-700" style="background-color: #3b0000;">
<p class="text-white text-sm text-center whitespace-pre-wrap">
{{ form.message || 'Your alert message will appear here...' }}
</p>
</div>
<div class="space-y-2">
<p class="text-red-300 text-xs text-center">
Type <span class="font-mono font-bold text-white bg-red-800 px-1.5 py-0.5 rounded">confirm</span> to acknowledge
</p>
<div class="flex gap-2">
<input disabled placeholder="confirm" class="input input-sm flex-1 bg-red-900 text-white border-red-600 opacity-50" />
<button disabled class="btn btn-sm btn-error opacity-30">Close</button>
</div>
</div>
</div>
<!-- Existing alert info -->
<div v-if="existingAlert" class="mt-4 p-3 bg-base-200 rounded-lg text-xs opacity-70">
<p>Alert created: {{ formatDate(existingAlert.created_at) }}</p>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div class="modal" :class="{ 'modal-open': confirmDelete }">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Alert?</h3>
<p class="py-3">This will remove the alert for this customer. It will no longer appear when their profile is opened.</p>
<div class="modal-action">
<button @click="deleteAlert" class="btn btn-error" :disabled="isSaving">
<span v-if="isSaving" class="loading loading-spinner loading-xs mr-1"></span>
Delete
</button>
<button @click="confirmDelete = false" class="btn btn-ghost">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { customerService } from '../../services/customerService';
import { notify } from '@kyvg/vue3-notification';
import dayjs from 'dayjs';
interface CustomerAlert {
id: number;
customer_id: number;
severity: number;
message: string;
created_at: string | null;
}
const route = useRoute();
const router = useRouter();
const customerId = computed(() => Number(route.params.id));
const customer = ref<any>(null);
const existingAlert = ref<CustomerAlert | null>(null);
const isLoading = ref(true);
const isSaving = ref(false);
const confirmDelete = ref(false);
const messageError = ref('');
const form = ref({ severity: 0, message: '' });
onMounted(async () => {
await Promise.all([loadCustomer(), loadAlert()]);
isLoading.value = false;
});
const loadCustomer = async () => {
try {
const res = await customerService.getById(customerId.value);
customer.value = res.data?.customer || res.data;
} catch {
// ignore
}
};
const loadAlert = async () => {
try {
const res = await customerService.getAlert(customerId.value);
const alert = res.data?.alert || null;
existingAlert.value = alert;
if (alert) {
form.value.severity = alert.severity;
form.value.message = alert.message;
}
} catch {
existingAlert.value = null;
}
};
const saveAlert = async () => {
messageError.value = '';
if (!form.value.message.trim()) {
messageError.value = 'Please enter a message.';
return;
}
isSaving.value = true;
try {
const payload = { severity: form.value.severity, message: form.value.message.trim() };
if (existingAlert.value) {
const res = await customerService.updateAlert(customerId.value, payload);
existingAlert.value = res.data.alert;
} else {
const res = await customerService.createAlert(customerId.value, payload);
existingAlert.value = res.data.alert;
}
notify({ title: 'Saved', text: 'Alert saved successfully.', type: 'success' });
router.push({ name: 'customerProfile', params: { id: customerId.value } });
} catch {
notify({ title: 'Error', text: 'Failed to save alert.', type: 'error' });
} finally {
isSaving.value = false;
}
};
const deleteAlert = async () => {
isSaving.value = true;
try {
await customerService.deleteAlert(customerId.value);
existingAlert.value = null;
form.value = { severity: 0, message: '' };
confirmDelete.value = false;
notify({ title: 'Deleted', text: 'Alert removed.', type: 'success' });
router.push({ name: 'customerProfile', params: { id: customerId.value } });
} catch {
notify({ title: 'Error', text: 'Failed to delete alert.', type: 'error' });
} finally {
isSaving.value = false;
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Unknown';
return dayjs(dateStr).format('MMM D, YYYY h:mm A');
};
</script>
+24
View File
@@ -115,6 +115,16 @@
>
No matching towns found. You can enter a new town name.
</div>
<!-- Quick town buttons -->
<div class="flex flex-wrap gap-1 mt-2">
<button
v-for="town in quickTowns"
:key="town"
type="button"
@click="selectQuickTown(town)"
:class="['btn', 'btn-xs', CreateCustomerForm.customer_town === town ? 'bg-blue-600 text-white border-blue-600' : 'btn-outline']"
>{{ town }}</button>
</div>
<span v-if="v$.CreateCustomerForm.customer_town.$error" class="text-red-500 text-xs mt-1">
{{ v$.CreateCustomerForm.customer_town.$errors[0].$message }}
</span>
@@ -252,6 +262,10 @@ const custList = ref<HomeTypeOption[]>([])
const selectedTown = ref<TownSuggestion | null>(null)
const zipAutoFilled = ref(false)
const quickTowns = ['Auburn', 'Millbury', 'Sutton', 'Oxford', 'North Oxford', 'Webster',
'Grafton', 'Dudley', 'Charlton', 'Leicester', 'Cherry Valley', 'Rochdale',
'Paxton', 'Spencer', 'Worcester']
// State ID to abbreviation mapping (matches backend STATE_MAPPING)
const STATE_ABBR_MAP: Record<number, string> = {
0: 'MA', // Default for unmapped
@@ -344,6 +358,16 @@ const selectTown = (suggestion: TownSuggestion) => {
zipAutoFilled.value = false
}
const selectQuickTown = (townName: string) => {
CreateCustomerForm.value.customer_town = townName
CreateCustomerForm.value.customer_state = 0 // MA
selectedTown.value = { town: townName, state: 'MA', state_id: 0, customer_count: 0 }
showTownDropdown.value = false
CreateCustomerForm.value.customer_address = ''
CreateCustomerForm.value.customer_zip = ''
zipAutoFilled.value = false
}
// Helper to get current town and state for street search
const getCurrentTownState = () => {
// If a town was selected from dropdown, use that
+2 -2
View File
@@ -227,7 +227,7 @@
<!-- Fill Location -->
<div class="form-control">
<label class="label"><span class="label-text">Fill Location</span></label>
<input v-model="CreateCustomerForm.basicInfo.customer_fill_location" type="text" placeholder="e.g., Left side of house" class="input input-bordered input-sm w-full" />
<input v-model="CreateCustomerForm.basicInfo.customer_fill_location" type="text" placeholder="0-12" class="input input-bordered input-sm w-full" />
</div>
<!-- Description -->
<div class="form-control md:col-span-2">
@@ -497,7 +497,7 @@ const userStatus = () => {
const getCustomerDescription = (userid: number) => {
customerService.getDescription(userid).then((response: any) => {
customerDescription.value = response.data?.description || response.data
customerDescription.value = response.data
CreateCustomerForm.value.basicInfo.customer_description = customerDescription.value.description;
CreateCustomerForm.value.basicInfo.customer_fill_location = customerDescription.value.fill_location
})
+51 -29
View File
@@ -48,37 +48,42 @@
</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>
<!-- Days Until Empty + Last Fill -->
<div class="grid grid-cols-2 gap-4">
<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>
<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>
<label class="label-text font-medium">Last Fill</label>
<div class="mt-1">
<span v-if="estimation.last_fill" class="text-lg font-mono font-bold">{{ formatDate(estimation.last_fill) }}</span>
<span v-else class="text-lg font-mono text-base-content/40">N/A</span>
</div>
</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>
<!-- Gallons to Fill + Days Since Last Fill -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label-text font-medium">Gallons to Fill</label>
<div class="mt-1">
<span class="text-lg font-mono font-bold">{{ Math.max(0, getMaxFill(estimation.tank_size) - Math.round(estimation.estimated_gallons)) }} gal</span>
</div>
</div>
<div>
<label class="label-text font-medium">Days Since Last Fill</label>
<div class="mt-1">
<span v-if="estimation.last_fill" class="text-lg font-mono font-bold">{{ daysSinceLastFill(estimation.last_fill) }} days</span>
<span v-else class="text-lg font-mono text-base-content/40">N/A</span>
</div>
</div>
</div>
@@ -97,8 +102,8 @@
</div>
</div>
<!-- K-Factor Slider -->
<div class="pt-2 border-t">
<!-- K-Factor Slider (admin only) -->
<div v-if="currentUserId === 0" 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>
@@ -169,6 +174,7 @@ interface FuelEstimation {
// Props
const props = defineProps<{
customerId: number
currentUserId: number
}>()
// Reactive data
@@ -287,6 +293,18 @@ const fetchEstimation = async () => {
}
}
const TANK_MAX_FILLS: Record<number, number> = {
275: 240,
320: 275,
330: 280,
500: 475,
550: 500,
}
const getMaxFill = (tankSize: number): number => {
return TANK_MAX_FILLS[Math.round(tankSize)] ?? Math.round(tankSize * 0.9)
}
const getTankLevelPercentage = (): number => {
if (!estimation.value || !estimation.value.tank_size || estimation.value.tank_size === 0) {
return 0
@@ -347,4 +365,8 @@ const formatDate = (dateString: string): string => {
if (!dateString) return 'N/A'
return dayjs(dateString).format('MMM D, YYYY')
}
const daysSinceLastFill = (dateString: string): number => {
return dayjs().diff(dayjs(dateString), 'day')
}
</script>
+20 -4
View File
@@ -46,7 +46,9 @@
<div class="xl:col-span-5 space-y-6">
<ProfileSummary :customer="customer" :automatic_status="automatic_status"
:customer_description="customer_description.description" @toggle-automatic="userAutomatic" />
:customer_description="customer_description.description"
:has-alert="!!customerAlert"
@toggle-automatic="userAutomatic" />
<CustomerStats :stats="customer_stats" :last_delivery="customer_last_delivery" />
</div>
</div>
@@ -101,7 +103,7 @@
</div>
</div>
<TankEstimation :customer-id="customer.id" />
<TankEstimation :customer-id="customer.id" :current-user-id="user?.user_id ?? -1" />
<CustomerComments :comments="comments" @add-comment="onSubmitSocial" @delete-comment="deleteCustomerSocial" />
@@ -145,6 +147,9 @@
</div>
<!-- Customer alert popup - shown automatically if an active alert exists -->
<CustomerAlertPopup v-if="customerAlert" :alert="customerAlert" />
<!-- Modals remain at the root of the template for proper display -->
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
@@ -271,6 +276,7 @@ import CreditCards from './profile/CreditCards.vue';
import CustomerComments from './profile/CustomerComments.vue';
import HistoryTabs from './profile/HistoryTabs.vue';
import TankEstimation from './TankEstimation.vue';
import CustomerAlertPopup from './profile/CustomerAlertPopup.vue';
import { AuthorizeTransaction, PricingData, CustomerDescriptionData, CustomersResponse, CustomerResponse, AxiosResponse, AxiosError } from '../../../types/models';
L.Icon.Default.mergeOptions({
@@ -376,6 +382,7 @@ const isCreateAccountModalVisible = ref(false)
const isCreatingAccount = ref(false)
const createdProfileId = ref('')
const isDuplicateErrorModalVisible = ref(false) // Add for duplicate detection popup
const customerAlert = ref<{ id: number; severity: number; message: string } | null>(null)
const pricing = ref<PricingData>({
price_from_supplier: 0,
price_for_customer: 0,
@@ -453,6 +460,7 @@ const getCustomer = (userid: number) => {
loadServicePlan(customer.value.id);
getCustomerTransactions(customer.value.id);
checkAuthorizeAccount();
getCustomerAlert(customer.value.id);
}).catch((err: unknown) => {
const error = err as AxiosError;
@@ -521,6 +529,14 @@ const getNozzleColor = (nozzleString: string): string => {
}
}
const getCustomerAlert = (customerId: number) => {
customerService.getAlert(customerId).then((response: AxiosResponse<any>) => {
customerAlert.value = response.data?.alert || null
}).catch(() => {
customerAlert.value = null
})
}
const getCustomerLastDelivery = (userid: number) => {
adminService.stats.userLastDelivery(userid).then((response: AxiosResponse<any>) => {
customer_last_delivery.value = response.data.date
@@ -695,8 +711,8 @@ const handleDeleteService = async (serviceId: number) => {
const fetchCustomerParts = async (customerId: number) => {
try {
const response = await serviceService.getPartsForCustomer(customerId);
if (response.data && 'parts' in response.data && Array.isArray(response.data.parts) && response.data.parts.length > 0) {
currentParts.value = response.data.parts[0];
if (response.data && response.data.parts && typeof response.data.parts === 'object' && !Array.isArray(response.data.parts)) {
currentParts.value = response.data.parts as ServiceParts;
} else {
currentParts.value = null;
}
@@ -0,0 +1,86 @@
<template>
<!-- Info: simple popup, X button, backdrop click also dismisses -->
<div v-if="alert.severity === 0 && visible"
class="modal modal-open z-[9999]"
@click.self="dismiss">
<div class="modal-box relative max-w-md">
<button @click="dismiss"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div class="flex items-center gap-3 mb-3">
<svg class="w-8 h-8 text-info flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="font-bold text-lg">Customer Alert</h3>
</div>
<p class="text-base-content whitespace-pre-wrap">{{ alert.message }}</p>
</div>
</div>
<!-- Notice: orange background, siren icons, X button only (no backdrop dismiss) -->
<div v-else-if="alert.severity === 1 && visible"
class="modal modal-open z-[9999]">
<div class="modal-box relative max-w-md bg-orange-500 text-white border-4 border-orange-700">
<button @click="dismiss"
class="btn btn-sm btn-circle absolute right-2 top-2 bg-transparent border-white text-white hover:bg-orange-700 hover:border-orange-700"></button>
<div class="flex items-center justify-center gap-3 mb-4">
<span class="text-3xl animate-bounce">🚨</span>
<h3 class="font-bold text-2xl tracking-widest">NOTICE</h3>
<span class="text-3xl animate-bounce" style="animation-delay: 0.15s">🚨</span>
</div>
<div class="bg-orange-600 rounded-lg p-4">
<p class="text-lg font-medium text-center whitespace-pre-wrap">{{ alert.message }}</p>
</div>
</div>
</div>
<!-- Critical: dark red, must type "confirm" to close, no X, no backdrop dismiss -->
<div v-else-if="alert.severity === 2 && visible"
class="modal modal-open z-[9999]">
<div class="modal-box relative max-w-md border-4 border-red-500"
style="background-color: #1a0000;">
<div class="text-center mb-5">
<div class="text-5xl mb-3"></div>
<h3 class="font-bold text-2xl text-red-400 tracking-wider">CRITICAL ALERT</h3>
</div>
<div class="rounded-lg p-4 mb-6 border border-red-700" style="background-color: #3b0000;">
<p class="text-white text-lg font-medium text-center whitespace-pre-wrap">{{ alert.message }}</p>
</div>
<div class="space-y-3">
<p class="text-red-300 text-sm text-center">
Type <span class="font-mono font-bold text-white bg-red-800 px-2 py-0.5 rounded">confirm</span> to acknowledge
</p>
<div class="flex gap-2">
<input v-model="confirmText"
type="text"
placeholder="Type confirm here..."
class="input flex-1 bg-red-900 text-white border-red-600 placeholder-red-500" />
<button @click="dismiss"
:disabled="confirmText.toLowerCase() !== 'confirm'"
class="btn btn-error disabled:opacity-30">
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface CustomerAlert {
id: number;
severity: number;
message: string;
}
const props = defineProps<{ alert: CustomerAlert }>();
const visible = ref(true);
const confirmText = ref('');
const dismiss = () => {
if (props.alert.severity === 2 && confirmText.value.toLowerCase() !== 'confirm') return;
visible.value = false;
};
</script>
@@ -4,8 +4,21 @@
<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 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 ref="map" v-model:zoom="zoom" :center="center">
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker :lat-lng="center">
<l-popup>
<div class="text-sm leading-snug">
<p class="font-semibold">{{ customer.customer_first_name }} {{ customer.customer_last_name }}</p>
<p>{{ customer.customer_address }}<span v-if="customer.customer_apt">, {{ customer.customer_apt }}</span></p>
<p>{{ customer.customer_town }}, {{ customer.customer_zip }}</p>
</div>
</l-popup>
</l-marker>
</l-map>
</div>
</div>
@@ -13,21 +26,28 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import "leaflet/dist/leaflet.css";
import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { LMap, LTileLayer, LMarker, LPopup } from "@vue-leaflet/vue-leaflet";
interface Customer {
account_number: string;
customer_latitude: number;
customer_longitude: number;
customer_latitude: string | number;
customer_longitude: string | number;
customer_first_name?: string;
customer_last_name?: string;
customer_address?: string;
customer_apt?: string;
customer_town?: string;
customer_zip?: string;
}
interface Props {
customer: Customer;
}
defineProps<Props>();
const props = defineProps<{ customer: Customer }>();
const zoom = ref(14);
const center = computed<[number, number]>(() => [
parseFloat(props.customer.customer_latitude as string),
parseFloat(props.customer.customer_longitude as string),
]);
</script>
@@ -47,6 +47,15 @@
</svg>
<span class="text-[10px] uppercase font-bold">{{ automatic_status === 1 ? 'Set to Will Call' : 'Set to Auto' }}</span>
</button>
<!-- Alert button (full-width row 3) -->
<router-link :to="{ name: 'customerAlert', params: { id: customer.id } }"
class="btn btn-error btn-sm h-auto py-2 flex flex-col items-center justify-center gap-1 col-span-3 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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<span class="text-[10px] uppercase font-bold">{{ hasAlert ? 'Alert Active' : 'Alert' }}</span>
</router-link>
</div>
<div class="divider my-3"></div>
@@ -119,6 +128,7 @@ interface Props {
customer: Customer;
automatic_status: number;
customer_description?: string;
hasAlert?: boolean;
}
const props = defineProps<Props>();
+6
View File
@@ -5,6 +5,7 @@ const CustomerCreate = () => import('../customer/create.vue');
const CustomerEdit = () => import("../customer/edit.vue");
const CustomerProfile = () => import("./profile/profile.vue")
const TankEdit = () => import("./tank/edit.vue")
const CustomerAlert = () => import("./alert.vue")
const customerRoutes = [
@@ -44,6 +45,11 @@ const customerRoutes = [
name: 'customerList',
component: () => import('./list.vue'),
},
{
path: '/customer/:id/alert',
name: 'customerAlert',
component: CustomerAlert,
},
]
export default customerRoutes
+49 -12
View File
@@ -13,16 +13,26 @@
<!-- Main Content Card -->
<div class="bg-neutral rounded-lg p-4 sm:p-6 mt-6">
<!-- Header: Date Picker -->
<!-- Header: Quick-select date buttons -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
<h2 class="text-lg font-bold">Delivery Map</h2>
<div class="form-control">
<input
type="date"
v-model="selectedDate"
@change="fetchDeliveries"
class="input input-bordered w-full max-w-xs"
/>
<div class="flex flex-wrap gap-2">
<button
v-for="btn in dateButtons"
:key="btn.offset"
@click="selectDay(btn.offset)"
class="btn btn-sm"
:class="!showAll && selectedDate === dateForOffset(btn.offset) ? 'btn-primary' : 'btn-outline'"
>
{{ btn.label }}
</button>
<button
@click="selectAll"
class="btn btn-sm"
:class="showAll ? 'btn-warning' : 'btn-outline btn-warning'"
>
All Eligible
</button>
</div>
</div>
@@ -70,7 +80,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<h3 class="mt-2 text-sm font-medium">No deliveries found</h3>
<p class="mt-1 text-sm text-gray-500">No deliveries scheduled for {{ formatDate(selectedDate) }}</p>
<p class="mt-1 text-sm text-gray-500">{{ showAll ? 'No eligible deliveries found' : `No deliveries scheduled for ${formatDate(selectedDate)}` }}</p>
</div>
<!-- Map and List -->
@@ -97,7 +107,7 @@
<div class="text-sm">
<p class="font-bold">{{ delivery.customerName }}</p>
<p>{{ delivery.street }}</p>
<p>{{ delivery.town }}, {{ delivery.state }} {{ delivery.zipcode }}</p>
<p>{{ delivery.town }}, {{ STATE_ID_TO_ABBR[Number(delivery.state)] || delivery.state }} {{ delivery.zipcode }}</p>
<p class="mt-1">
<span v-if="delivery.isFill" class="font-semibold text-blue-600">FILL</span>
<span v-else>{{ delivery.gallonsOrdered }} gallons</span>
@@ -141,11 +151,38 @@ import { deliveryService } from '../../services/deliveryService';
import { DeliveryMapItem } from '../../types/models';
import { STATE_ID_TO_ABBR } from '../../constants/states';
// Date helpers
const dateButtons = [
{ label: 'Yesterday', offset: -1 },
{ label: 'Today', offset: 0 },
{ label: 'Tomorrow', offset: 1 },
{ label: '+2 Days', offset: 2 },
{ label: '+3 Days', offset: 3 },
];
const dateForOffset = (offset: number): string => {
const d = new Date();
d.setDate(d.getDate() + offset);
return d.toISOString().split('T')[0];
};
const selectDay = (offset: number) => {
showAll.value = false;
selectedDate.value = dateForOffset(offset);
fetchDeliveries();
};
const selectAll = () => {
showAll.value = true;
fetchDeliveries();
};
// State
const loading = ref(false);
const error = ref<string | null>(null);
const deliveries = ref<DeliveryMapItem[]>([]);
const selectedDate = ref(new Date().toISOString().split('T')[0]);
const selectedDate = ref(dateForOffset(0));
const showAll = ref(false);
const zoom = ref(11);
// Computed
@@ -221,7 +258,7 @@ const fetchDeliveries = async () => {
loading.value = true;
error.value = null;
try {
const response = await deliveryService.getForMap(selectedDate.value);
const response = await deliveryService.getForMap(selectedDate.value, showAll.value);
if (response.data.ok) {
deliveries.value = response.data.deliveries || [];
} else {
@@ -103,7 +103,10 @@
</div>
<div class="pt-2">
<button type="submit" class="btn btn-success btn-sm">Finalize Delivery</button>
<button type="submit" class="btn btn-success btn-sm" :disabled="submitting">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
{{ submitting ? 'Finalizing...' : 'Finalize Delivery' }}
</button>
</div>
</form>
</div>
@@ -141,6 +144,7 @@ const router = useRouter()
// Reactive data
const v$ = useValidate()
const loaded = ref(false)
const submitting = ref(false)
const user = ref({
id: 0
})
@@ -507,20 +511,24 @@ const CreateTransaction = (auto_ticket_id: string) => {
})
}
const onSubmit = () => {
let payload = {
gallons_delivered: FinalizeOilOrderForm.value.gallons_delivered,
};
UpdateDeliveredAuto(payload);
if (autoTicket.value.payment_status == '1') {
// Pre-authorized: redirect to capture page
router.push({ name: "payAutoCapture", params: { id: autoTicket.value.id } });
} else {
// Fully charged: close ticket
if (autoDelivery.value.open_ticket_id) {
closeTicket(autoDelivery.value.open_ticket_id);
const onSubmit = async () => {
if (submitting.value) return;
submitting.value = true;
try {
const payload = {
gallons_delivered: FinalizeOilOrderForm.value.gallons_delivered,
};
await UpdateDeliveredAuto(payload);
if (autoTicket.value.payment_status == '1') {
router.push({ name: "payAutoCapture", params: { id: autoTicket.value.id } });
} else {
if (autoDelivery.value.open_ticket_id) {
await closeTicket(autoDelivery.value.open_ticket_id);
}
router.push({ name: "auto" });
}
router.push({ name: "auto" });
} finally {
submitting.value = false;
}
}
</script>
@@ -105,7 +105,10 @@
</div>
<div class="pt-2">
<button type="submit" class="btn btn-success btn-sm">Finalize Delivery</button>
<button type="submit" class="btn btn-success btn-sm" :disabled="submitting">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
{{ submitting ? 'Finalizing...' : 'Finalize Delivery' }}
</button>
</div>
</form>
</div>
@@ -143,6 +146,7 @@ const router = useRouter()
// Reactive data
const v$ = useValidate()
const loaded = ref(false)
const submitting = ref(false)
const user = ref({
id: 0
})
@@ -475,13 +479,18 @@ const ConfirmAuto = async (payload: {
}
}
const onSubmit = () => {
let payload = {
gallons_delivered: FinalizeOilOrderForm.value.gallons_delivered,
};
ConfirmAuto(payload)
router.push({ name: "auto" });
const onSubmit = async () => {
if (submitting.value) return;
submitting.value = true;
try {
const payload = {
gallons_delivered: FinalizeOilOrderForm.value.gallons_delivered,
};
await ConfirmAuto(payload);
router.push({ name: "auto" });
} finally {
submitting.value = false;
}
}
</script>
+4 -2
View File
@@ -89,8 +89,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -285,6 +285,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+4 -2
View File
@@ -89,8 +89,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -286,6 +286,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+4 -9
View File
@@ -24,13 +24,6 @@
<p class="text-base-content/60 mt-1 ml-13">Completed and finalized deliveries</p>
</div>
<!-- Quick Stats -->
<div class="flex flex-wrap gap-3">
<div class="stat-pill">
<span class="stat-pill-value">{{ recordsLength }}</span>
<span class="stat-pill-label">Deliveries</span>
</div>
</div>
</div>
@@ -89,8 +82,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -285,6 +278,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+4 -2
View File
@@ -89,8 +89,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -286,6 +286,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+13 -7
View File
@@ -26,8 +26,8 @@
<!-- Quick Stats -->
<div class="flex flex-wrap gap-3">
<div class="stat-pill">
<span class="stat-pill-value">{{ recordsLength }}</span>
<span class="stat-pill-label">Deliveries</span>
<span class="stat-pill-value">{{ totalGallons.toLocaleString() }} gal</span>
<span class="stat-pill-label">Pending</span>
</div>
</div>
</div>
@@ -91,15 +91,15 @@
<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 == 10">Finalized</span>
<span v-else>N/A</span>
<span v-else>Pending Finalize</span>
</span>
</td>
<td>
<div class="flex flex-col">
<span class="font-medium text-sm">{{ oil.customer_address }}</span>
<div class="flex items-center gap-1">
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ oil.customer_zip }}</span>
<a :href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${oil.customer_address}, ${oil.customer_town}, ${getStateAbbr(oil.customer_state)} ${oil.customer_zip}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -172,7 +172,7 @@
<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 == 10">Finalized</span>
<span v-else>N/A</span>
<span v-else>Pending Finalize</span>
</div>
</div>
@@ -232,7 +232,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { ref, computed, onMounted, markRaw } from 'vue'
import { deliveryService } from '../../../services/deliveryService'
import authService from '../../../services/authService'
import { Delivery } from '../../../types/models'
@@ -248,6 +248,10 @@ const deliveries = ref<Delivery[]>([])
const page = ref(1)
const perPage = ref(50)
const recordsLength = ref(0)
const totalGallons = computed(() =>
deliveries.value.reduce((sum, d) => sum + (Number(d.gallons_ordered) || 0), 0)
)
const options = ref({
edgeNavigation: false,
format: false,
@@ -323,6 +327,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
@@ -201,8 +201,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -520,6 +520,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+4 -2
View File
@@ -155,8 +155,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -475,6 +475,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+4 -2
View File
@@ -141,8 +141,8 @@
<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}`)}`"
<span class="text-xs opacity-70">{{ oil.customer_town }}, {{ getStateAbbr(oil.customer_state) }} {{ formatZip(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)} ${formatZip(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">
@@ -392,6 +392,8 @@ const getStateAbbr = (stateId: number | string): string => {
return STATE_ABBR_MAP[id] || 'MA';
}
const formatZip = (zip: string | number): string => String(zip || '').trim().padStart(5, '0');
// Lifecycle
onMounted(() => {
userStatus()
+196 -129
View File
@@ -24,12 +24,6 @@
</svg>
Today
</button>
<router-link :to="{ name: 'ServiceHome' }" class="btn btn-primary btn-sm 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-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Service Call
</router-link>
</div>
</div>
@@ -79,22 +73,30 @@
</div>
<!-- Legend -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-info"></div>
<span class="text-sm">Scheduled</span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-sm bg-info"></div>
<span class="text-xs font-medium">Tune-up</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-success"></div>
<span class="text-sm">Completed</span>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-sm bg-error"></div>
<span class="text-xs font-medium">No Heat</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-warning"></div>
<span class="text-sm">In Progress</span>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-sm bg-success"></div>
<span class="text-xs font-medium">Fix</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-accent"></div>
<span class="text-sm">Federal Holiday</span>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-sm bg-warning"></div>
<span class="text-xs font-medium">Tank Install</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-sm bg-neutral"></div>
<span class="text-xs font-medium">Other</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-sm bg-accent/60"></div>
<span class="text-xs font-medium">Holiday</span>
</div>
</div>
</div>
@@ -172,6 +174,14 @@ const handleEventClick = (clickInfo: EventClickArg): void => {
};
}
const TYPE_CLASS: Record<number, string> = {
0: 'event-tune-up',
1: 'event-no-heat',
2: 'event-fix',
3: 'event-tank',
4: 'event-other',
}
// Fetch events function for FullCalendar
const fetchCalendarEvents = async (
fetchInfo: { startStr: string; endStr: string },
@@ -180,9 +190,16 @@ const fetchCalendarEvents = async (
) => {
try {
const response = await serviceService.getAll();
const serviceEvents = response.data?.events || [];
const rawEvents: any[] = response.data?.events || [];
const serviceEvents = rawEvents.map(ev => ({
...ev,
backgroundColor: undefined,
borderColor: undefined,
textColor: undefined,
classNames: [TYPE_CLASS[ev.extendedProps?.type_service_call] ?? 'event-other'],
}));
// Add federal holidays as background events
const holidayEvents = holidays.value.map(holiday => ({
id: `holiday-${holiday.date}`,
title: holiday.name,
@@ -192,10 +209,7 @@ const fetchCalendarEvents = async (
classNames: ['holiday-event']
}));
// Combine service events and holiday events
const allEvents = [...serviceEvents, ...holidayEvents];
successCallback(allEvents);
successCallback([...serviceEvents, ...holidayEvents]);
} catch (err: unknown) {
const error = err as AxiosError;
console.error("Failed to fetch calendar events:", error);
@@ -246,21 +260,33 @@ const getDayCellClassNames = (arg: any) => {
return holiday ? ['holiday-cell'] : []
}
const eventContent = (arg: any) => {
const time = arg.timeText || ''
const title = arg.event.title || ''
const desc = arg.event.extendedProps?.description || ''
return {
html: `<div style="padding:2px 4px">
${time ? `<div class="fc-event-time">${time}</div>` : ''}
<div class="fc-event-title">${title}</div>
${desc ? `<div class="fc-event-desc">${desc}</div>` : ''}
</div>`
}
}
// Calendar options
const calendarOptions = ref({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: false, // We're using custom header
headerToolbar: false,
weekends: true,
height: 'auto',
events: fetchCalendarEvents,
eventClick: handleEventClick,
eventClassNames: 'custom-event',
dayCellClassNames: getDayCellClassNames,
eventDisplay: 'block',
displayEventTime: false,
eventBackgroundColor: 'transparent',
eventBorderColor: 'transparent',
displayEventTime: true,
eventTimeFormat: { hour: 'numeric', minute: '2-digit', meridiem: 'short' },
eventContent,
} as CalendarOptions)
// Modal handlers
@@ -315,143 +341,184 @@ onMounted(() => {
<style scoped>
.calendar-page {
@apply min-h-screen animate-fade-in;
@apply min-h-screen;
}
.calendar-container {
@apply transition-all duration-300;
}
/* FullCalendar Custom Styling */
:deep(.fc) {
@apply font-sans;
}
/* ── Grid chrome ── */
:deep(.fc) { @apply font-sans; }
:deep(.fc-theme-standard td),
:deep(.fc-theme-standard th) {
border-color: hsl(var(--bc) / 0.2) !important;
border-width: 2px !important;
border-color: hsl(var(--bc) / 0.15) !important;
}
:deep(.fc-scrollgrid) {
border-width: 2px !important;
border-color: hsl(var(--bc) / 0.2) !important;
border-color: hsl(var(--bc) / 0.15) !important;
}
:deep(.fc-col-header-cell) {
@apply bg-base-200 font-semibold text-sm uppercase tracking-wider py-3;
border-width: 2px !important;
border-color: hsl(var(--bc) / 0.2) !important;
background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 100%) !important;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.85) !important;
padding: 12px 0;
border: none !important;
}
:deep(.fc-col-header-cell a) {
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
}
/* Today column header — brighter white */
:deep(.fc-col-header-cell.fc-day-today) {
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%) !important;
color: #fff !important;
box-shadow: inset 0 -3px 0 rgba(255,255,255,0.4);
}
:deep(.fc-col-header-cell.fc-day-today a) {
color: #fff !important;
}
:deep(.fc-daygrid-day) {
@apply transition-colors hover:bg-base-200/50;
border-width: 2px !important;
border-color: hsl(var(--bc) / 0.2) !important;
@apply transition-colors duration-150;
border-color: hsl(var(--bc) / 0.15) !important;
}
:deep(.fc-daygrid-day:hover) {
background-color: hsl(var(--b2)) !important;
}
/* ── Day number ── */
:deep(.fc-daygrid-day-number) {
@apply text-base-content/80 font-medium p-2;
@apply text-base-content/70 font-medium text-sm p-2;
}
:deep(.fc-day-today) {
@apply bg-primary/5 !important;
}
:deep(.fc-day-today .fc-daygrid-day-number) {
@apply text-primary font-bold;
}
/* Custom Event Styling */
:deep(.fc-event) {
@apply rounded-lg px-2 py-1 mb-1 cursor-pointer;
@apply bg-info/20 border-l-4 border-info;
@apply hover:bg-info/30 transition-colors;
@apply shadow-sm hover:shadow-md;
}
:deep(.fc-event-title) {
@apply text-sm font-medium text-base-content truncate;
}
/* Day cell styling */
:deep(.fc-daygrid-day-frame) {
@apply min-h-[100px];
@apply min-h-[130px];
}
:deep(.fc-daygrid-day-top) {
@apply flex justify-center;
@apply flex justify-start;
}
/* Remove default FullCalendar button styling since we have custom header */
:deep(.fc-toolbar) {
@apply hidden;
/* ── TODAY — full box highlight ── */
:deep(.fc-day-today) {
background-color: rgba(59, 130, 246, 0.18) !important;
border: 2px solid rgba(59, 130, 246, 0.55) !important;
}
/* Holiday styling - Using accent color from theme */
:deep(.holiday-cell) {
background-color: hsl(var(--a) / 0.15) !important;
border-color: hsl(var(--a) / 0.4) !important;
:deep(.fc-day-today .fc-daygrid-day-number) {
background-color: #3b82f6 !important;
color: #fff !important;
@apply font-bold rounded-full text-sm;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin: 4px;
padding: 0;
}
:deep(.holiday-cell .fc-daygrid-day-number) {
color: hsl(var(--a)) !important;
@apply font-bold;
}
/* Holiday background events */
:deep(.fc-bg-event.holiday-event) {
background-color: hsl(var(--a) / 0.5) !important;
opacity: 1 !important;
z-index: 1 !important;
inset: 0 !important;
margin: 0 !important;
border-radius: 0 !important;
}
:deep(.fc-daygrid-day-bg .fc-bg-event.holiday-event) {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100% !important;
height: 100% !important;
}
/* Holiday event title positioning */
:deep(.fc-bg-event.holiday-event .fc-event-title) {
position: absolute !important;
bottom: 4px !important;
left: 4px !important;
right: 4px !important;
top: auto !important;
font-size: 0.7rem !important;
font-weight: 600 !important;
color: hsl(var(--a)) !important;
text-align: center !important;
line-height: 1.2 !important;
padding: 2px !important;
}
/* Weekend styling - Using theme colors */
/* ── Weekends ── */
:deep(.fc-day-sat),
:deep(.fc-day-sun) {
background-color: hsl(var(--b3)) !important;
border-color: hsl(var(--bc) / 0.3) !important;
background-color: hsl(var(--b2)) !important;
}
:deep(.fc-day-sat .fc-daygrid-day-number),
:deep(.fc-day-sun .fc-daygrid-day-number) {
color: hsl(var(--bc) / 0.8) !important;
@apply font-semibold;
@apply opacity-60;
}
/* Weekend header cells */
:deep(.fc-col-header-cell.fc-day-sat),
:deep(.fc-col-header-cell.fc-day-sun) {
background-color: hsl(var(--b3)) !important;
color: hsl(var(--bc)) !important;
@apply font-bold;
color: rgba(255, 255, 255, 0.5) !important;
}
/* ── Event base ── */
:deep(.fc-event) {
@apply rounded mb-0.5 cursor-pointer transition-opacity duration-150 shadow-sm;
border-left-width: 3px !important;
border-top: none !important;
border-right: none !important;
border-bottom: none !important;
}
:deep(.fc-event:hover) { @apply opacity-90; }
:deep(.fc-event-main) {
@apply px-1.5 py-0.5;
}
:deep(.fc-event-time) {
@apply font-bold text-xs opacity-90;
display: block;
}
:deep(.fc-event-title) {
@apply text-xs font-semibold;
white-space: normal;
word-break: break-word;
line-height: 1.3;
}
:deep(.fc-event-desc) {
@apply text-xs opacity-80 mt-0.5;
white-space: normal;
word-break: break-word;
line-height: 1.3;
}
/* ── Per-type colors ── */
:deep(.event-tune-up) {
background-color: hsl(var(--in) / 0.18) !important;
border-left-color: hsl(var(--in)) !important;
color: hsl(var(--in)) !important;
}
:deep(.event-no-heat) {
background-color: hsl(var(--er) / 0.18) !important;
border-left-color: hsl(var(--er)) !important;
color: hsl(var(--er)) !important;
}
:deep(.event-fix) {
background-color: hsl(var(--su) / 0.18) !important;
border-left-color: hsl(var(--su)) !important;
color: hsl(var(--su)) !important;
}
:deep(.event-tank) {
background-color: hsl(var(--wa) / 0.18) !important;
border-left-color: hsl(var(--wa)) !important;
color: hsl(var(--wac)) !important;
}
:deep(.event-other) {
background-color: hsl(var(--n) / 0.25) !important;
border-left-color: hsl(var(--n)) !important;
color: hsl(var(--nc)) !important;
}
/* ── Holidays ── */
:deep(.holiday-cell) {
background-color: hsl(var(--a) / 0.08) !important;
}
:deep(.holiday-cell .fc-daygrid-day-number) {
color: hsl(var(--a)) !important;
}
:deep(.fc-bg-event.holiday-event) {
background-color: hsl(var(--a) / 0.12) !important;
opacity: 1 !important;
}
/* ── Toolbar (hidden — custom header used) ── */
:deep(.fc-toolbar) { @apply hidden; }
</style>
@@ -20,7 +20,7 @@
<div class="flex flex-col lg:flex-row gap-6 mt-6">
<!-- Sidebar Area (Uses our new responsive EventSidebar) -->
<EventSidebar v-if="!isLoading && customer" :customer="customer" @event-scheduled="handleEventScheduled" />
<EventSidebar v-if="!isLoading && customer" :customer="customer" :selected-date="selectedDate" @event-scheduled="handleEventScheduled" />
<!-- Loading/Error States (Styled to match the sidebar) -->
<div v-else class="w-full lg:w-96 lg:flex-none p-4">
@@ -67,6 +67,9 @@ import EventSidebar from './EventSidebar.vue';
import ServiceEditModal from '../../service/ServiceEditModal.vue';
import serviceService from '../../../services/serviceService';
import customerService from '../../../services/customerService';
import { notify } from '@kyvg/vue3-notification';
const fullCalendar = ref<InstanceType<typeof FullCalendar> | null>(null);
interface ServiceCall { id: number; scheduled_date: string; customer_id: number; customer_name: string; customer_address: string; customer_town: string; type_service_call: number; description: string; service_cost: string;}
interface Customer { id: number; customer_last_name: string; customer_first_name: string; customer_town: string; customer_state: number; customer_zip: string; customer_phone_number: string; customer_address: string; customer_home_type: number; customer_apt: string; }
@@ -98,6 +101,8 @@ const handleEventClick = (clickInfo: EventClickArg): void => {
// Reactive data
const isLoading = ref(false);
const selectedDate = ref('');
const selectedDayEl = ref<HTMLElement | null>(null);
const selectedServiceForEdit = ref<Partial<ServiceCall> | null>(null);
const calendarOptions = ref<CalendarOptions>({
plugins: [dayGridPlugin, interactionPlugin],
@@ -105,6 +110,12 @@ const calendarOptions = ref<CalendarOptions>({
weekends: true,
events: [] as any[],
eventClick: handleEventClick,
dateClick: (info: any) => {
if (selectedDayEl.value) selectedDayEl.value.classList.remove('fc-date-selected');
selectedDate.value = info.dateStr;
selectedDayEl.value = info.dayEl;
info.dayEl.classList.add('fc-date-selected');
},
});
const customer = ref<Customer | null>(null);
@@ -132,7 +143,13 @@ const getCustomer = async (customerId: string): Promise<void> => {
const fetchEvents = async (): Promise<void> => {
try {
const response = await serviceService.getAll();
calendarOptions.value.events = response.data?.events || [];
const events = response.data?.events || [];
calendarOptions.value.events = events;
const api = fullCalendar.value?.getApi();
if (api) {
api.removeAllEvents();
events.forEach((e: any) => api.addEvent(e));
}
} catch (error) {
console.error("Error fetching all calendar events:", error);
}
@@ -176,14 +193,15 @@ const handleEventScheduled = async (eventData: any): Promise<void> => {
};
const response = await serviceService.create(payload);
// Service response has { ok: boolean, service: ServiceCall }
if (response.data.service) {
if (response.data.ok) {
await fetchEvents();
notify({ title: 'Service Call Added', text: 'The service call has been scheduled.', type: 'success' });
} else {
console.error("Failed to create event");
notify({ title: 'Error', text: 'Failed to create service call.', type: 'error' });
}
} catch (error) {
console.error("Error creating event:", error);
notify({ title: 'Error', text: 'An error occurred while scheduling the service call.', type: 'error' });
}
};
@@ -202,3 +220,9 @@ onMounted(() => {
fetchEvents();
});
</script>
<style scoped>
:deep(.fc-date-selected) {
background-color: rgba(59, 130, 246, 0.2) !important;
}
</style>
+29 -11
View File
@@ -10,12 +10,6 @@
<h2 class="text-xl font-bold mb-4">Schedule Service</h2>
<form @submit.prevent="submitEvent" class="space-y-4">
<!-- Calendar Label -->
<div class="form-control">
<label class="label"><span class="label-text">Calendar Label</span></label>
<input type="text" v-model="event.title" required class="input input-bordered input-sm w-full" placeholder="e.g., Boiler Tune-up">
</div>
<!-- Service Type -->
<div class="form-control">
<label class="label"><span class="label-text">Type of Service</span></label>
@@ -49,7 +43,17 @@
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm w-full mt-4">
<!-- Quick Time Buttons -->
<div class="grid grid-cols-5 gap-2">
<button v-for="qt in quickTimes" :key="qt.hour" type="button"
class="btn btn-sm"
:class="event.time === qt.hour ? 'btn-primary' : 'btn-outline'"
@click="event.time = qt.hour">
{{ qt.label }}
</button>
</div>
<button type="submit" class="btn btn-warning btn-sm w-full mt-4">
Add Event
</button>
</form>
@@ -78,7 +82,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import dayjs from 'dayjs';
interface Customer {
@@ -97,8 +101,13 @@ interface Customer {
// Props
const props = defineProps<{
customer: Customer | null;
selectedDate?: string;
}>();
watch(() => props.selectedDate, (date) => {
if (date) event.value.date = date;
});
// Emits
const emit = defineEmits<{
'event-scheduled': [eventData: any];
@@ -114,13 +123,20 @@ const serviceOptions = ref([
{ text: 'Other', value: 4 },
]);
const event = ref({
title: '',
description: '',
date: dayjs().format('YYYY-MM-DD'),
endDate: '',
time: 12,
});
const quickTimes = [
{ label: '7am', hour: 7 },
{ label: '9am', hour: 9 },
{ label: '11am', hour: 11 },
{ label: '1pm', hour: 13 },
{ label: '3pm', hour: 15 },
];
// Computed properties
const customerStateName = computed((): string => {
if (!props.customer) return '';
@@ -147,11 +163,14 @@ const submitEvent = () => {
return;
}
const serviceLabel = serviceOptions.value.find(o => o.value === selectedService.value)?.text || 'Service';
const autoTitle = `${serviceLabel} - ${props.customer.customer_first_name} ${props.customer.customer_last_name}`;
const startDateTime = dayjs(`${event.value.date} ${event.value.time}:00`).format('YYYY-MM-DDTHH:mm:ss');
const endDateTime = event.value.endDate ? dayjs(event.value.endDate).add(1, 'day').format('YYYY-MM-DD') : undefined;
const eventPayload = {
title: event.value.title,
title: autoTitle,
start: startDateTime,
type_service_call: selectedService.value,
end: endDateTime,
@@ -162,7 +181,6 @@ const submitEvent = () => {
emit('event-scheduled', eventPayload);
event.value.title = '';
selectedService.value = '';
event.value.description = '';
event.value.endDate = '';
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="bg-neutral rounded-lg p-4 sm:p-6">
<!-- Controls -->
<div class="flex flex-wrap items-end gap-4 mb-6">
<div>
<label class="label pb-1">
<span class="label-text font-semibold">Compare Years</span>
</label>
<YearSelector v-model="selectedYears" />
</div>
<div class="ml-auto self-end">
<ExportButtons :data="exportData" filename="customer-signups-weekly" />
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center items-center h-[400px]">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Error -->
<div v-else-if="error" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
<button class="btn btn-sm" @click="fetchData">Retry</button>
</div>
<!-- Chart -->
<div v-else class="w-full h-[420px] relative">
<Bar v-if="chartData" :data="chartData as any" :options="chartOptions as any" />
<div v-else class="flex items-center justify-center h-full text-base-content/50">
No signup data available for the selected years.
</div>
</div>
<!-- Summary stats -->
<div v-if="!loading && !error" class="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
<div
v-for="(stat, index) in summaryStats"
:key="stat.year"
class="stat bg-base-100 rounded-lg p-3"
>
<div class="stat-title text-sm">{{ stat.year }} Total</div>
<div class="stat-value text-lg" :style="{ color: yearColors[index % yearColors.length].border }">
{{ stat.totalSignups.toLocaleString() }}
</div>
<div class="stat-desc">new customers</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js';
import { Bar } from 'vue-chartjs';
import YearSelector from './components/YearSelector.vue';
import ExportButtons from './components/ExportButtons.vue';
import { statsService } from '../../services/statsService';
import type { WeeklyCustomerYearData } from '../../types/stats';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
const currentYear = new Date().getFullYear();
const selectedYears = ref<number[]>([currentYear, currentYear - 1]);
const loading = ref(false);
const error = ref<string | null>(null);
const weeklyData = ref<WeeklyCustomerYearData[]>([]);
const yearColors = [
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.7)' },
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.7)' },
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.7)' },
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.7)' },
];
// Month label at the approximate ISO week that starts each month
const MONTH_AT_WEEK: Record<number, string> = {
1: 'Jan', 5: 'Feb', 9: 'Mar', 14: 'Apr', 18: 'May', 22: 'Jun',
27: 'Jul', 31: 'Aug', 36: 'Sep', 40: 'Oct', 44: 'Nov', 48: 'Dec'
};
const xLabels = Array.from({ length: 52 }, (_, i) => MONTH_AT_WEEK[i + 1] ?? '');
const chartData = computed(() => {
if (weeklyData.value.length === 0) return null;
const datasets = weeklyData.value.map((yearData, index) => {
const slots = new Array<number>(52).fill(0);
for (const pt of yearData.data) {
const idx = (pt.week_number ?? 0) - 1;
if (idx >= 0 && idx < 52) slots[idx] = pt.signups;
}
const color = yearColors[index % yearColors.length];
return {
label: String(yearData.year),
data: slots,
backgroundColor: color.background,
borderColor: color.border,
borderWidth: 1,
borderRadius: 3,
};
});
return { labels: xLabels, datasets };
});
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
title: {
display: true,
text: 'New Customer Sign-ups — Full Year (Weekly)',
font: { size: 16 },
},
legend: { position: 'top' as const },
tooltip: {
callbacks: {
title: (items: any[]) => `Week ${items[0]?.dataIndex + 1}`,
label: (ctx: any) => {
const v = ctx.parsed?.y ?? 0;
return `${ctx.dataset.label}: ${v} new customer${v !== 1 ? 's' : ''}`;
},
},
},
},
scales: {
x: {
grid: { display: false },
ticks: {
autoSkip: false,
maxRotation: 0,
callback: (_val: any, index: number) => xLabels[index] || null,
},
},
y: {
beginAtZero: true,
title: { display: true, text: 'New Customers' },
ticks: {
stepSize: 1,
callback: (v: string | number) =>
typeof v === 'number' && Number.isInteger(v) ? v : '',
},
},
},
}));
const summaryStats = computed(() =>
weeklyData.value.map(yd => ({
year: yd.year,
totalSignups: yd.data.reduce((s, d) => s + d.signups, 0),
}))
);
const exportData = computed(() => {
const rows: Record<string, unknown>[] = [];
weeklyData.value.forEach(yd =>
yd.data.forEach(d =>
rows.push({
year: yd.year,
week_number: d.week_number,
week_start: d.week_start,
signups: d.signups,
})
)
);
return rows;
});
async function fetchData() {
loading.value = true;
error.value = null;
try {
const response = await statsService.getWeeklyCustomers(selectedYears.value);
if (response.data.ok !== false) {
weeklyData.value = response.data.years || [];
} else {
error.value = 'Failed to load data';
}
} catch {
error.value = 'An error occurred while fetching data';
} finally {
loading.value = false;
}
}
watch(selectedYears, fetchData, { deep: true });
onMounted(fetchData);
</script>
+150 -215
View File
@@ -1,54 +1,24 @@
<template>
<div class="bg-neutral rounded-lg p-4 sm:p-6">
<!-- Controls -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-6">
<div class="flex flex-col gap-4">
<div>
<label class="label">
<span class="label-text font-semibold">Time Range</span>
</label>
<TimeRangeSelector v-model="timeRange" />
</div>
<div>
<label class="label">
<span class="label-text font-semibold">Compare Years</span>
</label>
<YearSelector v-model="selectedYears" />
</div>
<div class="flex flex-wrap items-end gap-4 mb-6">
<div>
<label class="label pb-1">
<span class="label-text font-semibold">Compare Years</span>
</label>
<YearSelector v-model="selectedYears" />
</div>
<div class="flex flex-col gap-4 items-end">
<div class="flex gap-2">
<div class="form-control">
<label class="label">
<span class="label-text">Start Date</span>
</label>
<input
type="date"
v-model="startDate"
class="input input-bordered input-sm"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">End Date</span>
</label>
<input
type="date"
v-model="endDate"
class="input input-bordered input-sm"
/>
</div>
</div>
<ExportButtons :data="exportData" :filename="'gallons-' + timeRange" />
<div class="ml-auto self-end">
<ExportButtons :data="exportData" filename="gallons-weekly" />
</div>
</div>
<!-- Loading State -->
<!-- Loading -->
<div v-if="loading" class="flex justify-center items-center h-[400px]">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Error State -->
<!-- Error -->
<div v-else-if="error" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -58,22 +28,25 @@
</div>
<!-- Chart -->
<DeliveryChart
v-else
:datasets="chartDatasets"
:title="chartTitle"
:use-time-scale="timeRange !== 'month'"
:x-axis-label="xAxisLabel"
/>
<div v-else class="w-full h-[420px] relative">
<Line v-if="chartData" :data="chartData as any" :options="chartOptions as any" />
<div v-else class="flex items-center justify-center h-full text-base-content/50">
No data available for the selected years.
</div>
</div>
<!-- Summary Stats -->
<!-- Summary stats -->
<div v-if="!loading && !error" class="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
<div v-for="(yearData, index) in summaryStats" :key="yearData.year" class="stat bg-base-100 rounded-lg p-3">
<div class="stat-title text-sm">{{ yearData.year }} Total</div>
<div class="stat-value text-lg" :style="{ color: getYearColor(index) }">
{{ yearData.totalGallons.toLocaleString() }}
<div
v-for="(stat, index) in summaryStats"
:key="stat.year"
class="stat bg-base-100 rounded-lg p-3"
>
<div class="stat-title text-sm">{{ stat.year }} Total</div>
<div class="stat-value text-lg" :style="{ color: yearColors[index % yearColors.length].border }">
{{ stat.totalGallons.toLocaleString() }}
</div>
<div class="stat-desc">{{ yearData.totalDeliveries }} deliveries</div>
<div class="stat-desc">{{ stat.totalDeliveries }} deliveries</div>
</div>
</div>
</div>
@@ -81,202 +54,164 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import dayjs from 'dayjs';
import TimeRangeSelector from './components/TimeRangeSelector.vue';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { Line } from 'vue-chartjs';
import YearSelector from './components/YearSelector.vue';
import DeliveryChart from './components/DeliveryChart.vue';
import ExportButtons from './components/ExportButtons.vue';
import { statsService } from '../../services/statsService';
import type { TimeRange, DailyGallonsYearData, WeeklyGallonsYearData, MonthlyGallonsYearData } from '../../types/stats';
import type { WeeklyGallonsYearData } from '../../types/stats';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
const currentYear = new Date().getFullYear();
// State
const timeRange = ref<TimeRange>('day');
const selectedYears = ref<number[]>([currentYear, currentYear - 1]);
const startDate = ref(dayjs().subtract(30, 'day').format('YYYY-MM-DD'));
const endDate = ref(dayjs().format('YYYY-MM-DD'));
const loading = ref(false);
const error = ref<string | null>(null);
// Data storage
const dailyData = ref<DailyGallonsYearData[]>([]);
const weeklyData = ref<WeeklyGallonsYearData[]>([]);
const monthlyData = ref<MonthlyGallonsYearData[]>([]);
// Colors for year lines
const yearColors = [
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.1)' }, // Blue
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.1)' }, // Green
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.1)' }, // Orange
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.1)' }, // Purple
{ border: 'rgb(59, 130, 246)', background: 'rgba(59, 130, 246, 0.12)' },
{ border: 'rgb(16, 185, 129)', background: 'rgba(16, 185, 129, 0.12)' },
{ border: 'rgb(249, 115, 22)', background: 'rgba(249, 115, 22, 0.12)' },
{ border: 'rgb(139, 92, 246)', background: 'rgba(139, 92, 246, 0.12)' },
];
function getYearColor(index: number): string {
return yearColors[index % yearColors.length].border;
}
// Month name at the ISO week that starts each month (approximate, works for most years)
const MONTH_AT_WEEK: Record<number, string> = {
1: 'Jan', 5: 'Feb', 9: 'Mar', 14: 'Apr', 18: 'May', 22: 'Jun',
27: 'Jul', 31: 'Aug', 36: 'Sep', 40: 'Oct', 44: 'Nov', 48: 'Dec'
};
// Computed
const chartTitle = computed(() => {
const rangeLabels = { day: 'Daily', week: 'Weekly', month: 'Monthly' };
return `${rangeLabels[timeRange.value]} Gallons Delivered`;
// 52-element label array: month name at the right week, empty string elsewhere
const xLabels = Array.from({ length: 52 }, (_, i) => MONTH_AT_WEEK[i + 1] ?? '');
const chartData = computed(() => {
if (weeklyData.value.length === 0) return null;
const datasets = weeklyData.value.map((yearData, index) => {
// Slot gallons into a 52-element array indexed by week_number (1-52)
const slots = new Array<number | null>(52).fill(null);
for (const pt of yearData.data) {
const idx = (pt.week_number ?? 0) - 1;
if (idx >= 0 && idx < 52) slots[idx] = pt.gallons;
}
const color = yearColors[index % yearColors.length];
return {
label: String(yearData.year),
data: slots,
borderColor: color.border,
backgroundColor: color.background,
tension: 0.35,
fill: false,
pointRadius: 3,
pointHoverRadius: 6,
spanGaps: true,
};
});
return { labels: xLabels, datasets };
});
const xAxisLabel = computed(() => {
const labels = { day: 'Date', week: 'Week', month: 'Month' };
return labels[timeRange.value];
});
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
title: {
display: true,
text: 'Weekly Gallons Delivered — Full Year',
font: { size: 16 },
},
legend: { position: 'top' as const },
tooltip: {
callbacks: {
title: (items: any[]) => `Week ${items[0]?.dataIndex + 1}`,
label: (ctx: any) => {
const v = ctx.parsed?.y;
if (v == null) return `${ctx.dataset.label}: —`;
return `${ctx.dataset.label}: ${Number(v).toLocaleString()} gal`;
},
},
},
},
scales: {
x: {
grid: { display: false },
ticks: {
autoSkip: false,
maxRotation: 0,
callback: (_val: any, index: number) => xLabels[index] || null,
},
},
y: {
beginAtZero: true,
title: { display: true, text: 'Gallons' },
ticks: {
callback: (v: string | number) =>
typeof v === 'number' ? v.toLocaleString() : v,
},
},
},
}));
const chartDatasets = computed(() => {
if (timeRange.value === 'day') {
return dailyData.value.map((yearData, index) => ({
label: `${yearData.year}`,
data: yearData.data.map(d => ({ x: d.date, y: d.gallons })),
borderColor: yearColors[index % yearColors.length].border,
backgroundColor: yearColors[index % yearColors.length].background
}));
} else if (timeRange.value === 'week') {
return weeklyData.value.map((yearData, index) => ({
label: `${yearData.year}`,
data: yearData.data.map(d => ({ x: d.week_start, y: d.gallons })),
borderColor: yearColors[index % yearColors.length].border,
backgroundColor: yearColors[index % yearColors.length].background
}));
} else {
// Monthly - use month names as categories
return monthlyData.value.map((yearData, index) => ({
label: `${yearData.year}`,
data: yearData.data.map(d => ({ x: d.month_name, y: d.gallons })),
borderColor: yearColors[index % yearColors.length].border,
backgroundColor: yearColors[index % yearColors.length].background
}));
}
});
const summaryStats = computed(() => {
let data: { year: number; totalGallons: number; totalDeliveries: number }[] = [];
if (timeRange.value === 'day') {
data = dailyData.value.map(yearData => ({
year: yearData.year,
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
}));
} else if (timeRange.value === 'week') {
data = weeklyData.value.map(yearData => ({
year: yearData.year,
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
}));
} else {
data = monthlyData.value.map(yearData => ({
year: yearData.year,
totalGallons: yearData.data.reduce((sum, d) => sum + d.gallons, 0),
totalDeliveries: yearData.data.reduce((sum, d) => sum + d.deliveries, 0)
}));
}
return data;
});
const summaryStats = computed(() =>
weeklyData.value.map(yd => ({
year: yd.year,
totalGallons: yd.data.reduce((s, d) => s + d.gallons, 0),
totalDeliveries: yd.data.reduce((s, d) => s + d.deliveries, 0),
}))
);
const exportData = computed(() => {
const rows: Record<string, unknown>[] = [];
if (timeRange.value === 'day') {
dailyData.value.forEach(yearData => {
yearData.data.forEach(d => {
rows.push({
year: yearData.year,
date: d.date,
gallons: d.gallons,
deliveries: d.deliveries
});
});
});
} else if (timeRange.value === 'week') {
weeklyData.value.forEach(yearData => {
yearData.data.forEach(d => {
rows.push({
year: yearData.year,
week_start: d.week_start,
week_end: d.week_end,
week_number: d.week_number,
gallons: d.gallons,
deliveries: d.deliveries
});
});
});
} else {
monthlyData.value.forEach(yearData => {
yearData.data.forEach(d => {
rows.push({
year: yearData.year,
month: d.month,
month_name: d.month_name,
gallons: d.gallons,
deliveries: d.deliveries
});
});
});
}
weeklyData.value.forEach(yd =>
yd.data.forEach(d =>
rows.push({
year: yd.year,
week_number: d.week_number,
week_start: d.week_start,
week_end: d.week_end,
gallons: d.gallons,
deliveries: d.deliveries,
})
)
);
return rows;
});
// Methods
async function fetchData() {
loading.value = true;
error.value = null;
try {
if (timeRange.value === 'day') {
const response = await statsService.getDailyGallons({
start_date: startDate.value,
end_date: endDate.value,
years: selectedYears.value
});
if (response.data.ok !== false) {
dailyData.value = response.data.years || [];
} else {
error.value = 'Failed to load daily data';
}
} else if (timeRange.value === 'week') {
const response = await statsService.getWeeklyGallons({
start_date: startDate.value,
end_date: endDate.value,
years: selectedYears.value
});
if (response.data.ok !== false) {
weeklyData.value = response.data.years || [];
} else {
error.value = 'Failed to load weekly data';
}
// Always full year. Backend replaces the year in start/end per selected year.
const response = await statsService.getWeeklyGallons({
start_date: `${currentYear}-01-01`,
end_date: `${currentYear}-12-31`,
years: selectedYears.value,
});
if (response.data.ok !== false) {
weeklyData.value = response.data.years || [];
} else {
const response = await statsService.getMonthlyGallons({
year: selectedYears.value[0],
compare_years: selectedYears.value.slice(1)
});
if (response.data.ok !== false) {
monthlyData.value = response.data.years || [];
} else {
error.value = 'Failed to load monthly data';
}
error.value = 'Failed to load data';
}
} catch (err) {
console.error('Error fetching stats data:', err);
} catch {
error.value = 'An error occurred while fetching data';
} finally {
loading.value = false;
}
}
// Watchers
watch([timeRange, selectedYears, startDate, endDate], () => {
fetchData();
}, { deep: true });
// Lifecycle
onMounted(() => {
fetchData();
});
watch(selectedYears, fetchData, { deep: true });
onMounted(fetchData);
</script>
+6
View File
@@ -1,6 +1,7 @@
const StatsLayout = () => import('./StatsLayout.vue');
const DailyDeliveriesGraph = () => import('./DailyDeliveriesGraph.vue');
const TotalsComparison = () => import('./TotalsComparison.vue');
const CustomerSignupsGraph = () => import('./CustomerSignupsGraph.vue');
const statsRoutes = [
{
@@ -20,6 +21,11 @@ const statsRoutes = [
path: 'totals',
name: 'statsTotals',
component: TotalsComparison
},
{
path: 'customers',
name: 'statsCustomers',
component: CustomerSignupsGraph
}
]
}
+16 -15
View File
@@ -32,15 +32,16 @@
</div>
<div class="grid grid-cols-12 pl-6 pb-6 gap-10 max-h-32">
<div class="grid grid-cols-12 pl-6 pb-2 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 }}</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 flex gap-2" v-if="delivery.prime == 1 || delivery.same_day == 1 || delivery.emergency == 1">
<span v-if="delivery.prime == 1">PRIME</span>
<span v-if="delivery.same_day == 1">SAME DAY</span>
<span v-if="delivery.emergency == 1">EMERGENCY</span>
</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>
@@ -74,15 +75,12 @@
<div class="col-span-4">01501 </div>
</div>
</div>
<div class="col-span-12 pl-5">508 426 8800</div>
</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"
:key="past_delivery.id">
<div v-if="past_delivery.gallons_delivered != 0.00">
{{ past_delivery.when_delivered }} - {{ past_delivery.gallons_delivered }}
</div>
:key="past_delivery.when_delivered">
{{ past_delivery.automatic == 1 ? 'a' : 'wc' }}-{{ past_delivery.when_delivered }} - {{ past_delivery.gallons_delivered }}
</div>
</div>
<div v-else>
@@ -127,6 +125,9 @@
</div>
</div>
</div>
<div class="text-sm text-gray-400 pt-1 px-4">
This heating oil is dyed and sold solely for home heating use only not for on-road vehicle use or taxable purposes. Not responsible for driveways damaged from delivery.
</div>
</div>
</template>
@@ -143,9 +144,9 @@ import { adminService } from '../../services/adminService'
import { queryService } from '../../services/queryService'
interface PastDelivery {
id: number
gallons_delivered: number
when_delivered: string
gallons_delivered: number
automatic: number
}
const route = useRoute()
@@ -267,7 +268,7 @@ async function getCustomerTank(userId: number) {
async function getCustomerDescription(userId: number) {
try {
const response = await customerService.getDescription(userId)
customer_description.value = (response.data as any)?.description || response.data
customer_description.value = response.data
} catch (error) {
console.error('Failed to fetch description:', error)
}
@@ -300,8 +301,8 @@ async function getTodayPrice() {
async function getPastDeliveries(userId: number) {
try {
const response = await deliveryService.getByCustomer(userId)
past_deliveries.value = (response.data as any)?.deliveries || response.data || []
const response = await deliveryService.getTicketPast(userId)
past_deliveries.value = (response.data as any)?.deliveries || []
} catch (error) {
console.error('Failed to fetch past deliveries:', error)
}
+11 -4
View File
@@ -36,7 +36,11 @@
<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 "></div>
<div class="col-span-12 flex gap-2" v-if="delivery.prime == 1 || delivery.same_day == 1 || delivery.emergency == 1">
<span v-if="delivery.prime == 1">PRIME</span>
<span v-if="delivery.same_day == 1">SAME DAY</span>
<span v-if="delivery.emergency == 1">EMERGENCY</span>
</div>
<div class="col-span-12 text-lg">Credit Card</div>
<div class="col-span-12">AUTO</div>
</div>
@@ -66,9 +70,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.slice(0, 4)"
:key="past_delivery.fill_date">
{{ past_delivery.fill_date }} - {{ past_delivery.gallons_delivered }}
a-{{ past_delivery.fill_date }} - {{ past_delivery.gallons_delivered }}
</div>
</div>
<div v-else>
@@ -94,6 +98,9 @@
</div>
</div>
</div>
<div class="text-sm text-gray-400 pt-1 px-4">
This heating oil is dyed and sold solely for home heating use only not for on-road vehicle use or taxable purposes. Not responsible for driveways damaged from delivery.
</div>
</div>
</template>
@@ -218,7 +225,7 @@ async function getCustomerTank(userId: number) {
async function getCustomerDescription(userId: number) {
try {
const response = await customerService.getDescription(userId)
customer_description.value = (response.data as any)?.description || response.data
customer_description.value = response.data
} catch (error) {
console.error('Failed to fetch description:', error)
}
+69
View File
@@ -35,6 +35,36 @@ export interface StreetSearchResponse {
query: string;
}
export interface TownStreetInfo {
town: string;
state: string;
street_count: number;
}
export interface TownListResponse {
ok: boolean;
towns: TownStreetInfo[];
}
export interface StreetPopulateResponse {
status: string;
message: string;
town: string;
state: string;
streets_added: number;
streets_updated: number;
total_found: number;
errors: string[];
}
export interface DeleteStreetsResponse {
ok: boolean;
message: string;
deleted: number;
town: string;
state: string;
}
export const addressService = {
/**
* Search for towns based on partial input.
@@ -59,6 +89,45 @@ export const addressService = {
params: { town, state, q: query, limit }
}),
/**
* List all towns that have streets in the StreetReference table.
* Used by the admin Street Manager page.
*/
listTowns: (): Promise<AxiosResponse<TownListResponse>> =>
addressApi.get('/towns/list'),
/**
* Fetch streets from OSM and populate the StreetReference table for a town.
* Pass clearExisting=true to replace existing streets (fixes bad data).
*/
populateStreets: (
town: string,
state: string,
clearExisting: boolean = false
): Promise<AxiosResponse<StreetPopulateResponse>> =>
addressApi.post(`/streets/${encodeURIComponent(town)}/${state}`, null, {
params: { clear_existing: clearExisting }
}),
/**
* Delete all streets for a town from the StreetReference table.
*/
deleteStreets: (
town: string,
state: string
): Promise<AxiosResponse<DeleteStreetsResponse>> =>
addressApi.delete(`/streets/${encodeURIComponent(town)}/${state}`),
/**
* Rename a town across all its StreetReference rows.
*/
renameTown: (
oldTown: string,
state: string,
newTown: string
): Promise<AxiosResponse<{ ok: boolean; message: string; updated: number; old_town: string; new_town: string; state: string }>> =>
addressApi.patch(`/streets/${encodeURIComponent(oldTown)}/${state}/rename`, { new_town: newTown }),
/**
* Check if address checker service is available.
*/
+13
View File
@@ -64,6 +64,19 @@ export const customerService = {
// Search
search: (query: string): Promise<AxiosResponse<SearchResponse>> =>
api.get(`/search/customer?q=${encodeURIComponent(query)}`),
// Customer alerts
getAlert: (id: number): Promise<AxiosResponse<any>> =>
api.get(`/deliverydata/customer/alert/${id}`),
createAlert: (id: number, data: { severity: number; message: string }): Promise<AxiosResponse<any>> =>
api.post(`/deliverydata/customer/alert/${id}`, data),
updateAlert: (id: number, data: { severity: number; message: string }): Promise<AxiosResponse<any>> =>
api.put(`/deliverydata/customer/alert/${id}`, data),
deleteAlert: (id: number): Promise<AxiosResponse<any>> =>
api.delete(`/deliverydata/customer/alert/${id}`),
};
export default customerService;
+5 -2
View File
@@ -25,8 +25,8 @@ export const deliveryService = {
getOrder: (id: number): Promise<AxiosResponse<DeliveryResponse>> =>
api.get(`/delivery/order/${id}`),
getForMap: (date: string): Promise<AxiosResponse<DeliveriesMapResponse>> =>
api.get(`/delivery/map`, { params: { date } }),
getForMap: (date: string, all = false): Promise<AxiosResponse<DeliveriesMapResponse>> =>
api.get(`/delivery/map`, { params: all ? { all: true } : { date } }),
getHistory: (startDate: string, endDate: string): Promise<AxiosResponse<DeliveryHistoryResponse>> =>
api.get(`/delivery/history`, { params: { start_date: startDate, end_date: endDate } }),
@@ -56,6 +56,9 @@ export const deliveryService = {
getPast2: (customerId: number): Promise<AxiosResponse<DeliveriesResponse>> =>
api.get(`/delivery/past2/${customerId}`),
getTicketPast: (customerId: number): Promise<AxiosResponse<{ ok: boolean; deliveries: { when_delivered: string; gallons_delivered: number; automatic: number }[] }>> =>
api.get(`/delivery/ticket/past/${customerId}`),
// Status-based lists
getWaiting: (page: number = 1): Promise<AxiosResponse<DeliveriesResponse>> =>
api.get(`/delivery/waiting/${page}`),
+1 -1
View File
@@ -47,7 +47,7 @@ export const serviceService = {
serviceApi.get(`/service/parts/customer/${customerId}`),
updateParts: (id: number, data: any): Promise<AxiosResponse<{ ok: boolean }>> =>
serviceApi.put(`/service/parts/update/${id}`, data),
serviceApi.post(`/service/parts/update/${id}`, data),
// Service plans
plans: {
+11 -1
View File
@@ -7,7 +7,8 @@ import type {
TotalsComparisonResponse,
DailyGallonsParams,
WeeklyGallonsParams,
MonthlyGallonsParams
MonthlyGallonsParams,
WeeklyCustomerResponse
} from '../types/stats';
/**
@@ -58,6 +59,15 @@ export const statsService = {
*/
getTotalsComparison: (): Promise<AxiosResponse<TotalsComparisonResponse>> => {
return api.get('/stats/totals/comparison');
},
/**
* Get weekly new customer signups by year
* @param years - array of years to compare
*/
getWeeklyCustomers: (years: number[]): Promise<AxiosResponse<WeeklyCustomerResponse>> => {
const queryParams = new URLSearchParams({ years: years.join(',') });
return api.get(`/stats/customers/weekly?${queryParams.toString()}`);
}
};
+1 -1
View File
@@ -110,7 +110,7 @@ export interface Delivery extends BaseEntity {
customer_address: string;
customer_town: string;
customer_state: string;
customer_zip: number;
customer_zip: string;
gallons_ordered: number;
customer_asked_for_fill: number;
gallons_delivered: number;
+20
View File
@@ -47,6 +47,26 @@ export interface WeeklyGallonsResponse {
years: WeeklyGallonsYearData[];
}
// ============================================
// Weekly Customer Signups Endpoint Types
// ============================================
export interface WeeklyCustomerDataPoint {
week_number: number;
week_start: string; // YYYY-MM-DD
signups: number;
}
export interface WeeklyCustomerYearData {
year: number;
data: WeeklyCustomerDataPoint[];
}
export interface WeeklyCustomerResponse {
ok: boolean;
years: WeeklyCustomerYearData[];
}
// ============================================
// Monthly Gallons Endpoint Types
// ============================================
+5 -1
View File
@@ -1,5 +1,9 @@
import { STATE_ID_TO_ABBR } from '../constants/states';
export const formatZip = (zip: string | number): string => {
return String(zip || '').trim().padStart(5, '0');
};
/**
* Formats an address string with street, town, state, and zip.
* Handles missing or invalid state IDs gracefully.
@@ -19,7 +23,7 @@ export const formatAddress = (
): string => {
const cleanAddress = (address || '').trim();
const cleanTown = (town || '').trim();
const cleanZip = String(zip || '').trim();
const cleanZip = formatZip(zip);
const parsedStateId = typeof stateId === 'string' ? parseInt(stateId, 10) : stateId;
const stateAbbr = STATE_ID_TO_ABBR[parsedStateId] || 'MA'; // Default to MA if not found