Files
eamco_office_frontend/src/pages/service/ServiceCalendar.vue
T
anekdotin 3134ef0264 fix: service calendar edit modal saves and displays correct time
- Force FullCalendar remount after save/delete via calendarKey ref
- Fix datetime construction to use proper ISO 8601 format (T separator, zero-padded)
- Add console.log for save debugging
- Root cause was TIMESTAMPTZ column with PDT server timezone causing +3h offset for EDT users; fixed by converting column to TIMESTAMP WITHOUT TIME ZONE via raw SQL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:39:16 -04:00

527 lines
16 KiB
Vue

<!-- src/pages/service/ServiceCalendar.vue -->
<template>
<div class="calendar-page">
<div class="w-full px-4 md:px-10">
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li><router-link :to="{ name: 'ServiceHome' }">Service</router-link></li>
<li>Master Calendar</li>
</ul>
</div>
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold mb-1">Service Calendar</h1>
<p class="text-base-content/60">Manage and schedule service calls</p>
</div>
<div class="flex gap-3">
<button @click="goToToday" class="btn btn-ghost 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="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
Today
</button>
</div>
</div>
<!-- Calendar Container -->
<div class="calendar-container bg-gradient-to-br from-neutral to-neutral/80 rounded-xl shadow-strong overflow-hidden">
<!-- Custom Calendar Header -->
<div class="calendar-header bg-base-200 px-6 py-4 border-b border-base-300">
<div class="flex items-center justify-between">
<!-- Navigation -->
<div class="flex items-center gap-2">
<button @click="previousMonth" class="btn btn-ghost btn-sm btn-circle">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 class="text-2xl font-bold min-w-[200px] text-center">{{ currentMonthYear }}</h2>
<button @click="nextMonth" class="btn btn-ghost btn-sm btn-circle">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
<!-- View Switcher -->
<div class="btn-group">
<button
@click="changeView('dayGridMonth')"
class="btn btn-sm"
:class="{ 'btn-active': currentView === 'dayGridMonth' }"
>
Month
</button>
<button
@click="changeView('dayGridWeek')"
class="btn btn-sm"
:class="{ 'btn-active': currentView === 'dayGridWeek' }"
>
Week
</button>
<button
@click="changeView('dayGridDay')"
class="btn btn-sm"
:class="{ 'btn-active': currentView === 'dayGridDay' }"
>
Day
</button>
</div>
<!-- Legend -->
<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-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-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-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>
</div>
<!-- FullCalendar -->
<div class="calendar-body p-6">
<FullCalendar ref="fullCalendar" :key="calendarKey" :options="calendarOptions" />
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<ServiceEditModal
v-if="selectedServiceForEdit"
:service="selectedServiceForEdit"
@close-modal="closeEditModal"
@save-changes="handleSaveChanges"
@delete-service="handleDeleteService"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import dayjs from 'dayjs';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { CalendarOptions, EventClickArg, DayCellContentArg } from '@fullcalendar/core';
import ServiceEditModal from './ServiceEditModal.vue';
import { serviceService } from '../../services/serviceService';
import { authService } from '../../services/authService';
import { AxiosResponse, AxiosError, ServiceCall } from '../../types/models';
import { getFederalHolidays, type Holiday } from '../../utils/holidays';
import { notify } from '@kyvg/vue3-notification';
// Reactive data
const user = ref(null)
const selectedServiceForEdit = ref(null as Partial<ServiceCall> | null)
const fullCalendar = ref()
const calendarKey = ref(0)
const currentView = ref('dayGridMonth')
const holidays = ref<Holiday[]>([])
const currentDate = ref(new Date())
// Computed
const currentMonthYear = computed(() => {
return currentDate.value.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
})
// Load holidays for current year and next year
const loadHolidays = () => {
const currentYear = new Date().getFullYear()
const allHolidays = [
...getFederalHolidays(currentYear - 1),
...getFederalHolidays(currentYear),
...getFederalHolidays(currentYear + 1),
]
holidays.value = allHolidays
}
// Check if a date is a holiday
const isHolidayDate = (dateStr: string): Holiday | undefined => {
return holidays.value.find(h => h.date === dateStr)
}
// Event handlers
const handleEventClick = (clickInfo: EventClickArg): void => {
const start = clickInfo.event.start;
selectedServiceForEdit.value = {
id: parseInt(clickInfo.event.id),
scheduled_date: start ? dayjs(start).format('YYYY-MM-DDTHH:mm:ss') : '',
customer_name: clickInfo.event.title.split(': ')[1] || 'Unknown Customer',
customer_id: clickInfo.event.extendedProps.customer_id,
type_service_call: clickInfo.event.extendedProps.type_service_call,
description: clickInfo.event.extendedProps.description,
service_cost: clickInfo.event.extendedProps.service_cost,
};
}
const TYPE_CLASS: Record<number, string> = {
0: 'event-tune-up',
1: 'event-no-heat',
2: 'event-fix',
3: 'event-tank',
4: 'event-other',
}
// Fetch and render all events imperatively
const fetchCalendarEvents = async () => {
try {
const response = await serviceService.getAll();
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,
start: holiday.date,
allDay: true,
display: 'background',
classNames: ['holiday-event'],
}));
const allEvents = [...serviceEvents, ...holidayEvents];
calendarOptions.value.events = allEvents as any;
const api = (fullCalendar.value as any)?.getApi();
if (api) {
api.removeAllEvents();
allEvents.forEach((e: any) => api.addEvent(e));
}
} catch (err: unknown) {
console.error("Failed to fetch calendar events:", err);
}
};
// Calendar navigation
const goToToday = () => {
const calendarApi = (fullCalendar.value as any).getApi()
calendarApi.today()
currentDate.value = calendarApi.getDate()
}
const previousMonth = () => {
const calendarApi = (fullCalendar.value as any).getApi()
calendarApi.prev()
currentDate.value = calendarApi.getDate()
}
const nextMonth = () => {
const calendarApi = (fullCalendar.value as any).getApi()
calendarApi.next()
currentDate.value = calendarApi.getDate()
}
const changeView = (viewName: string) => {
currentView.value = viewName
const calendarApi = (fullCalendar.value as any).getApi()
calendarApi.changeView(viewName)
currentDate.value = calendarApi.getDate()
}
// Day cell class names for holidays
const getDayCellClassNames = (arg: any) => {
// Format date as YYYY-MM-DD
const year = arg.date.getFullYear()
const month = String(arg.date.getMonth() + 1).padStart(2, '0')
const day = String(arg.date.getDate()).padStart(2, '0')
const dateStr = `${year}-${month}-${day}`
const holiday = isHolidayDate(dateStr)
if (holiday) {
console.log('Holiday found:', holiday.name, 'on', dateStr)
}
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,
weekends: true,
height: 'auto',
events: [],
eventClick: handleEventClick,
dayCellClassNames: getDayCellClassNames,
eventDisplay: 'block',
displayEventTime: true,
eventTimeFormat: { hour: 'numeric', minute: '2-digit', meridiem: 'short' },
eventContent,
} as CalendarOptions)
// Modal handlers
const closeEditModal = () => {
selectedServiceForEdit.value = null;
}
const handleSaveChanges = async (updatedService: ServiceCall) => {
try {
await serviceService.update(updatedService.id, updatedService);
await fetchCalendarEvents();
calendarKey.value++;
closeEditModal();
notify({ title: 'Saved', text: 'Service call updated.', type: 'success' });
} catch (error) {
console.error("Failed to save changes:", error);
notify({ title: 'Error', text: 'Failed to save service call.', type: 'error' });
}
}
const handleDeleteService = async (serviceId: number) => {
try {
await serviceService.delete(serviceId);
await fetchCalendarEvents();
calendarKey.value++;
closeEditModal();
} catch (error) {
console.error("Error deleting event:", error);
}
}
const userStatus = () => {
authService.whoami().then((response: AxiosResponse<any>) => {
if (response.data.ok) {
user.value = response.data.user;
}
})
.catch(() => {
user.value = null
})
}
// Lifecycle
onMounted(async () => {
userStatus();
loadHolidays();
await fetchCalendarEvents();
})
</script>
<style scoped>
.calendar-page {
@apply min-h-screen;
}
/* ── Grid chrome ── */
:deep(.fc) { @apply font-sans; }
:deep(.fc-theme-standard td),
:deep(.fc-theme-standard th) {
border-color: hsl(var(--bc) / 0.15) !important;
}
:deep(.fc-scrollgrid) {
border-color: hsl(var(--bc) / 0.15) !important;
}
:deep(.fc-col-header-cell) {
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 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/70 font-medium text-sm p-2;
}
:deep(.fc-daygrid-day-frame) {
@apply min-h-[130px];
}
:deep(.fc-daygrid-day-top) {
@apply flex justify-start;
}
/* ── 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;
}
: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;
}
/* ── Weekends ── */
:deep(.fc-day-sat),
:deep(.fc-day-sun) {
background-color: hsl(var(--b2)) !important;
}
:deep(.fc-day-sat .fc-daygrid-day-number),
:deep(.fc-day-sun .fc-daygrid-day-number) {
@apply opacity-60;
}
:deep(.fc-col-header-cell.fc-day-sat),
:deep(.fc-col-header-cell.fc-day-sun) {
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>