Refactor frontend to Composition API and improve UI/UX

Major Changes:
- Migrate components from Options API to Composition API with <script setup>
- Add centralized service layer (serviceService, deliveryService, adminService)
- Implement new reusable components (EnhancedButton, EnhancedModal, StatCard, etc.)
- Add theme store for consistent theming across application
- Improve ServiceCalendar with federal holidays and better styling
- Refactor customer profile and tank estimation components
- Update all delivery and payment pages to use centralized services
- Add utility functions for formatting and validation
- Update Dockerfiles for better environment configuration
- Enhance Tailwind config with custom design tokens

UI Improvements:
- Modern, premium design with glassmorphism effects
- Improved form layouts with FloatingInput components
- Better loading states and empty states
- Enhanced modals and tables with consistent styling
- Responsive design improvements across all pages

Technical Improvements:
- Strict TypeScript types throughout
- Better error handling and validation
- Removed deprecated api.js in favor of TypeScript services
- Improved code organization and maintainability
This commit is contained in:
2026-02-01 19:04:07 -05:00
parent 72d8e35e06
commit 61f93ec4e8
86 changed files with 3931 additions and 2086 deletions
+361 -86
View File
@@ -1,8 +1,8 @@
<!-- src/pages/service/ServiceCalendar.vue -->
<template>
<div class="flex">
<div class="w-full px-10">
<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>
@@ -11,21 +11,104 @@
</ul>
</div>
<div class="flex text-2xl mb-5 font-bold">
Master Service Calendar
<!-- 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>
<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>
<div class="flex h-screen font-sans">
<div class="flex-1 p-4 overflow-auto">
<!-- The 'ref' is important for accessing the calendar's API -->
<!-- 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-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>
<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>
<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>
<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>
</div>
</div>
</div>
<!-- FullCalendar -->
<div class="calendar-body p-6">
<FullCalendar ref="fullCalendar" :options="calendarOptions" />
</div>
</div>
</div>
</div>
<Footer />
<!-- Edit Modal -->
<ServiceEditModal
v-if="selectedServiceForEdit"
:service="selectedServiceForEdit"
@@ -36,49 +119,56 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Header from '../../layouts/headers/headerauth.vue';
import SideBar from '../../layouts/sidebar/sidebar.vue';
import Footer from '../../layouts/footers/footer.vue';
import { ref, onMounted, computed } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { CalendarOptions, EventClickArg } from '@fullcalendar/core';
import { CalendarOptions, EventClickArg, DayCellContentArg } from '@fullcalendar/core';
import ServiceEditModal from './ServiceEditModal.vue';
import axios from 'axios';
import authHeader from '../../services/auth.header';
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;
}
import { serviceService } from '../../services/serviceService';
import { authService } from '../../services/authService';
import { AxiosResponse, AxiosError, ServiceCall } from '../../types/models';
import { getFederalHolidays, type Holiday } from '../../utils/holidays';
// Reactive data
const user = ref(null)
const selectedServiceForEdit = ref(null as Partial<ServiceCall> | null)
const fullCalendar = ref()
const currentView = ref('dayGridMonth')
const holidays = ref<Holiday[]>([])
const currentDate = ref(new Date())
// Functions
// We can remove the fetchEvents method as FullCalendar now handles it.
// async fetchEvents(): Promise<void> { ... }
// 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 => {
// This logic remains the same, as it correctly pulls data from extendedProps
selectedServiceForEdit.value = {
id: parseInt(clickInfo.event.id),
scheduled_date: clickInfo.event.startStr,
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,
id: parseInt(clickInfo.event.id),
scheduled_date: clickInfo.event.startStr,
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,
};
}
@@ -89,73 +179,116 @@ const fetchCalendarEvents = async (
failureCallback: (error: Error) => void
) => {
try {
const path = `${import.meta.env.VITE_BASE_URL}/service/all`;
const response = await axios.get(path, {
headers: authHeader(),
withCredentials: true,
});
// Backend returns { ok: true, events: [...] }
const events = response.data?.events || [];
successCallback(events);
} catch (error) {
const response = await serviceService.getAll();
const serviceEvents = response.data?.events || [];
// Add federal holidays as background events
const holidayEvents = holidays.value.map(holiday => ({
id: `holiday-${holiday.date}`,
title: holiday.name,
start: holiday.date,
allDay: true,
display: 'background',
classNames: ['holiday-event']
}));
// Combine service events and holiday events
const allEvents = [...serviceEvents, ...holidayEvents];
successCallback(allEvents);
} catch (err: unknown) {
const error = err as AxiosError;
console.error("Failed to fetch calendar events:", error);
failureCallback(error as Error);
}
};
// 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'] : []
}
// Calendar options
const calendarOptions = ref({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: false, // We're using custom header
weekends: true,
// Use function source to fetch events with auth headers and transform response
height: 'auto',
events: fetchCalendarEvents,
eventClick: handleEventClick,
eventClassNames: 'custom-event',
dayCellClassNames: getDayCellClassNames,
eventDisplay: 'block',
displayEventTime: false,
eventBackgroundColor: 'transparent',
eventBorderColor: 'transparent',
} as CalendarOptions)
// Lifecycle
onMounted(() => {
userStatus();
// We no longer need to call fetchEvents() here because FullCalendar does it automatically.
})
// Modal handlers
const closeEditModal = () => {
selectedServiceForEdit.value = null;
selectedServiceForEdit.value = null;
}
// =================== THIS IS THE CORRECTED SECTION ===================
const handleSaveChanges = async (updatedService: ServiceCall) => {
try {
const path = `${import.meta.env.VITE_BASE_URL}/service/update/${updatedService.id}`;
await axios.put(path, updatedService, { headers: authHeader(), withCredentials: true });
// Get the FullCalendar component instance from the ref
await serviceService.update(updatedService.id, updatedService);
const calendarApi = (fullCalendar.value as any).getApi();
if (calendarApi) {
// Tell FullCalendar to re-fetch its events from the source.
// This is the most reliable way to refresh the view immediately.
calendarApi.refetchEvents();
}
closeEditModal();
} catch (error) {
console.error("Failed to save changes:", error);
alert("An error occurred while saving. Please check the console.");
}
}
// =================== END OF CORRECTED SECTION ===================
const handleDeleteService = async (serviceId: number) => {
try {
const path = `${import.meta.env.VITE_BASE_URL}/service/delete/${serviceId}`;
await axios.delete(path, { withCredentials: true, headers: authHeader() });
// Also refresh the calendar after a delete
await serviceService.delete(serviceId);
const calendarApi = (fullCalendar.value as any).getApi();
if (calendarApi) {
calendarApi.refetchEvents();
}
closeEditModal();
} catch (error) {
console.error("Error deleting event:", error);
@@ -163,20 +296,162 @@ const handleDeleteService = async (serviceId: number) => {
}
const userStatus = () => {
let path = import.meta.env.VITE_BASE_URL + '/auth/whoami';
axios({
method: 'get',
url: path,
withCredentials: true,
headers: authHeader(),
authService.whoami().then((response: AxiosResponse<any>) => {
if (response.data.ok) {
user.value = response.data.user;
}
})
.catch(() => {
user.value = null
})
.then((response: any) => {
if (response.data.ok) {
user.value = response.data.user;
}
})
.catch(() => {
user.value = null
})
}
</script>
// Lifecycle
onMounted(() => {
userStatus();
loadHolidays();
})
</script>
<style scoped>
.calendar-page {
@apply min-h-screen animate-fade-in;
}
.calendar-container {
@apply transition-all duration-300;
}
/* FullCalendar Custom Styling */
: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;
}
:deep(.fc-scrollgrid) {
border-width: 2px !important;
border-color: hsl(var(--bc) / 0.2) !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;
}
:deep(.fc-daygrid-day) {
@apply transition-colors hover:bg-base-200/50;
border-width: 2px !important;
border-color: hsl(var(--bc) / 0.2) !important;
}
:deep(.fc-daygrid-day-number) {
@apply text-base-content/80 font-medium 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];
}
:deep(.fc-daygrid-day-top) {
@apply flex justify-center;
}
/* Remove default FullCalendar button styling since we have custom header */
:deep(.fc-toolbar) {
@apply hidden;
}
/* 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(.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 */
:deep(.fc-day-sat),
:deep(.fc-day-sun) {
background-color: hsl(var(--b3)) !important;
border-color: hsl(var(--bc) / 0.3) !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;
}
/* 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;
}
</style>