3134ef0264
- 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>
527 lines
16 KiB
Vue
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> |