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
+198 -131
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 || [];
// Add federal holidays as background 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'],
}));
const holidayEvents = holidays.value.map(holiday => ({
id: `holiday-${holiday.date}`,
title: holiday.name,
@@ -191,11 +208,8 @@ const fetchCalendarEvents = async (
display: 'background',
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>