feat: Implement Schedule Maintenance Management and Tab Dashboard
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
|
||||
// Re-export all maintenance API functions from their respective modules
|
||||
export { clearCache as clearMaintenanceCache } from './maintenanceCache';
|
||||
export { fetchAllMaintenanceRecords } from './maintenanceFetch';
|
||||
export {
|
||||
updateMaintenanceStatus,
|
||||
updateMaintenance,
|
||||
deleteMaintenance,
|
||||
createMaintenance
|
||||
} from './maintenanceOperations';
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
import { MaintenanceItem } from '../../types/maintenance.types';
|
||||
|
||||
// Cache management for maintenance records
|
||||
|
||||
// Cache for maintenance records
|
||||
let cachedMaintenanceRecords: MaintenanceItem[] | null = null;
|
||||
let lastFetchTimestamp = 0;
|
||||
const CACHE_EXPIRY_MS = 60000; // 1 minute cache expiry
|
||||
|
||||
/**
|
||||
* Check if cache is valid and can be used
|
||||
*/
|
||||
export const isCacheValid = (): boolean => {
|
||||
const now = Date.now();
|
||||
return !!(cachedMaintenanceRecords && now - lastFetchTimestamp < CACHE_EXPIRY_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cached maintenance records if available
|
||||
*/
|
||||
export const getCachedRecords = () => {
|
||||
if (cachedMaintenanceRecords) {
|
||||
console.log("Using cached maintenance records", cachedMaintenanceRecords.length);
|
||||
return [...cachedMaintenanceRecords]; // Return a copy to prevent mutation
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update cache with new data
|
||||
*/
|
||||
export const updateCache = (data: MaintenanceItem[], timestamp?: number): void => {
|
||||
cachedMaintenanceRecords = data;
|
||||
lastFetchTimestamp = timestamp || Date.now();
|
||||
console.log("Maintenance cache updated with", data.length, "records");
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cache to force refresh
|
||||
*/
|
||||
export const clearCache = (): void => {
|
||||
cachedMaintenanceRecords = null;
|
||||
lastFetchTimestamp = 0;
|
||||
console.log("Maintenance cache cleared");
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { MaintenanceItem } from '../../types/maintenance.types';
|
||||
import { normalizeMaintenanceItem } from '../maintenanceUtils';
|
||||
import { isCacheValid, getCachedRecords, updateCache } from './maintenanceCache';
|
||||
|
||||
/**
|
||||
* Get all maintenance records from the API with optimized caching
|
||||
*/
|
||||
export const fetchAllMaintenanceRecords = async (forceRefresh = false): Promise<MaintenanceItem[]> => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if available and not forced refresh
|
||||
if (!forceRefresh && isCacheValid()) {
|
||||
return getCachedRecords() as MaintenanceItem[];
|
||||
}
|
||||
|
||||
// Use a unique requestKey to prevent caching issues
|
||||
const timestamp = now;
|
||||
|
||||
// Increase items per page and use fields parameter to fetch only needed data
|
||||
// Also expand the assigned_users relation to get user details
|
||||
const result = await pb.collection('maintenance').getList(1, 200, {
|
||||
sort: '-created',
|
||||
requestKey: `maintenance-${timestamp}`,
|
||||
$cancelKey: `maintenance-fetch-${timestamp}`,
|
||||
expand: 'assigned_users', // Expand the relation to get user details
|
||||
});
|
||||
|
||||
if (!result || !result.items || result.items.length === 0) {
|
||||
const emptyData: MaintenanceItem[] = [];
|
||||
updateCache(emptyData, now);
|
||||
return emptyData;
|
||||
}
|
||||
|
||||
// Process and normalize data
|
||||
const normalizedData = result.items.map(item => {
|
||||
console.log("Processing maintenance item:", item.id);
|
||||
console.log("Item assigned_users:", item.assigned_users);
|
||||
console.log("Notification channel:", item.notification_channel_id);
|
||||
return normalizeMaintenanceItem(item);
|
||||
});
|
||||
|
||||
// Update cache
|
||||
updateCache(normalizedData, now);
|
||||
|
||||
return normalizedData;
|
||||
} catch (error) {
|
||||
// Handle abort errors gracefully without console errors
|
||||
if ((error as any)?.isAbort) {
|
||||
console.log("Request aborted:", error);
|
||||
return getCachedRecords() || [];
|
||||
}
|
||||
|
||||
console.error('Error fetching maintenance records:', error);
|
||||
return getCachedRecords() || [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { MaintenanceItem, CreateMaintenanceInput } from '../../types/maintenance.types';
|
||||
import { clearCache } from './maintenanceCache';
|
||||
|
||||
/**
|
||||
* Update the status of a maintenance record
|
||||
*/
|
||||
export const updateMaintenanceStatus = async (id: string, status: string): Promise<void> => {
|
||||
try {
|
||||
await pb.collection('maintenance').update(id, { status });
|
||||
clearCache();
|
||||
} catch (error) {
|
||||
console.error('Error updating maintenance status:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a maintenance record with partial data
|
||||
*/
|
||||
export const updateMaintenance = async (id: string, data: Partial<MaintenanceItem>): Promise<void> => {
|
||||
try {
|
||||
// Format assigned_users for storage
|
||||
const formattedData = { ...data };
|
||||
|
||||
// Ensure assigned_users is stored in a consistent format (JSON string)
|
||||
if (formattedData.assigned_users) {
|
||||
if (Array.isArray(formattedData.assigned_users)) {
|
||||
formattedData.assigned_users = JSON.stringify(formattedData.assigned_users);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Updating maintenance record with formatted data:", formattedData);
|
||||
|
||||
await pb.collection('maintenance').update(id, formattedData);
|
||||
clearCache();
|
||||
} catch (error) {
|
||||
console.error('Error updating maintenance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a maintenance record
|
||||
*/
|
||||
export const deleteMaintenance = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await pb.collection('maintenance').delete(id);
|
||||
clearCache();
|
||||
} catch (error) {
|
||||
console.error('Error deleting maintenance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new maintenance record
|
||||
*/
|
||||
export const createMaintenance = async (data: CreateMaintenanceInput): Promise<void> => {
|
||||
try {
|
||||
// Format data for submission
|
||||
const formattedData = { ...data };
|
||||
|
||||
// Ensure assigned_users is stored in a consistent format (JSON string)
|
||||
if (formattedData.assigned_users) {
|
||||
if (Array.isArray(formattedData.assigned_users)) {
|
||||
formattedData.assigned_users = JSON.stringify(formattedData.assigned_users);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure notification_id is set from notification_channel_id if not already present
|
||||
if (formattedData.notification_channel_id && !formattedData.notification_id) {
|
||||
formattedData.notification_id = formattedData.notification_channel_id;
|
||||
}
|
||||
|
||||
console.log("Creating maintenance record with formatted data:", formattedData);
|
||||
|
||||
await pb.collection('maintenance').create(formattedData);
|
||||
clearCache();
|
||||
} catch (error) {
|
||||
console.error('Error creating maintenance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
/**
|
||||
* Validation utilities for maintenance data
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate status value to ensure it's one of the acceptable values
|
||||
*/
|
||||
export const validateStatus = (status: string): string => {
|
||||
// Convert to lowercase and replace spaces with underscore for consistency
|
||||
const formattedStatus = status.toLowerCase().replace(' ', '_');
|
||||
|
||||
// Check if the status is one of the allowed values
|
||||
const allowedStatuses = ['scheduled', 'in_progress', 'completed', 'cancelled'];
|
||||
|
||||
if (!allowedStatuses.includes(formattedStatus)) {
|
||||
console.warn(`Invalid status value: ${formattedStatus}. Using 'scheduled' as fallback.`);
|
||||
return 'scheduled';
|
||||
}
|
||||
|
||||
return formattedStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure assigned_users is properly formatted
|
||||
*/
|
||||
export const formatAssignedUsers = (assignedUsers: unknown): string => {
|
||||
// If it's already a JSON string, return it
|
||||
if (typeof assignedUsers === 'string' && (assignedUsers.startsWith('[') || assignedUsers === '')) {
|
||||
return assignedUsers;
|
||||
}
|
||||
|
||||
// If it's an array, stringify it
|
||||
if (Array.isArray(assignedUsers)) {
|
||||
return JSON.stringify(assignedUsers);
|
||||
}
|
||||
|
||||
// If it's a string but not JSON, convert it to array then stringify
|
||||
if (typeof assignedUsers === 'string') {
|
||||
const userArray = assignedUsers.trim() !== ''
|
||||
? assignedUsers.split(',').map(id => id.trim())
|
||||
: [];
|
||||
return JSON.stringify(userArray);
|
||||
}
|
||||
|
||||
// Default to empty array
|
||||
return '[]';
|
||||
};
|
||||
|
||||
/**
|
||||
* Process notification settings consistently
|
||||
*/
|
||||
export const processNotificationSettings = (
|
||||
notifySubscribers: string,
|
||||
channelId?: string
|
||||
): { notification_channel_id: string, notification_id: string } => {
|
||||
if (notifySubscribers !== 'yes' || !channelId) {
|
||||
return {
|
||||
notification_channel_id: '',
|
||||
notification_id: ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
notification_channel_id: channelId,
|
||||
notification_id: channelId // Set notification_id to match notification_channel_id
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
export * from './maintenanceService';
|
||||
// Export maintenance API functions from the new structure
|
||||
export {
|
||||
fetchAllMaintenanceRecords,
|
||||
updateMaintenanceStatus,
|
||||
updateMaintenance,
|
||||
deleteMaintenance,
|
||||
createMaintenance,
|
||||
clearMaintenanceCache
|
||||
} from './api';
|
||||
// Export everything from maintenanceUtils
|
||||
export * from './maintenanceUtils';
|
||||
export * from './pdf';
|
||||
export * from '../types/maintenance.types';
|
||||
@@ -0,0 +1,199 @@
|
||||
|
||||
import { MaintenanceItem } from '../types/maintenance.types';
|
||||
import { alertConfigService, AlertConfiguration } from '../alertConfigService';
|
||||
import { sendTelegramNotification } from '../notification/telegramService';
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface NotificationParams {
|
||||
maintenance: MaintenanceItem;
|
||||
notificationType: 'start' | 'end' | 'update';
|
||||
}
|
||||
|
||||
export const maintenanceNotificationService = {
|
||||
/**
|
||||
* Send notification for maintenance events
|
||||
*/
|
||||
async sendMaintenanceNotification({ maintenance, notificationType }: NotificationParams): Promise<boolean> {
|
||||
try {
|
||||
console.log(`Preparing to send ${notificationType} notification for maintenance: ${maintenance.title}`);
|
||||
|
||||
// Get notification channel ID - try both fields
|
||||
let notificationChannelId = maintenance.notification_channel_id;
|
||||
|
||||
// If notification_channel_id is empty, try to use notification_id
|
||||
if (!notificationChannelId && maintenance.notification_id) {
|
||||
console.log(`No notification_channel_id found, using notification_id: ${maintenance.notification_id}`);
|
||||
notificationChannelId = maintenance.notification_id;
|
||||
}
|
||||
|
||||
// Check if maintenance has notification channel configured
|
||||
if (!notificationChannelId) {
|
||||
console.log("No notification channel configured for this maintenance");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Using notification channel ID: ${notificationChannelId}`);
|
||||
|
||||
// Get notification channel configuration
|
||||
let notificationConfig: AlertConfiguration;
|
||||
try {
|
||||
const config = await pb.collection('alert_configurations').getOne(notificationChannelId);
|
||||
notificationConfig = config as unknown as AlertConfiguration;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notification configuration:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!notificationConfig.enabled) {
|
||||
console.log("Notification channel is disabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create notification message based on type
|
||||
const message = this.generateMaintenanceMessage(maintenance, notificationType);
|
||||
|
||||
console.log(`Sending ${notificationConfig.notification_type} notification with message: ${message}`);
|
||||
|
||||
// Send notification based on channel type
|
||||
if (notificationConfig.notification_type === 'telegram') {
|
||||
return await sendTelegramNotification(notificationConfig, message);
|
||||
}
|
||||
|
||||
// Add more notification types here as needed
|
||||
|
||||
console.log(`Unsupported notification type: ${notificationConfig.notification_type}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error sending maintenance notification:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate message for maintenance notification
|
||||
*/
|
||||
generateMaintenanceMessage(maintenance: MaintenanceItem, type: 'start' | 'end' | 'update'): string {
|
||||
const startDate = new Date(maintenance.start_time);
|
||||
const endDate = new Date(maintenance.end_time);
|
||||
const duration = formatDistanceToNow(endDate, { addSuffix: false });
|
||||
const affectedServices = maintenance.affected.split(',').map(s => s.trim()).join(', ');
|
||||
|
||||
let emoji = '🔧';
|
||||
let statusText = '';
|
||||
let timeText = '';
|
||||
|
||||
switch (type) {
|
||||
case 'start':
|
||||
emoji = '⚠️';
|
||||
statusText = 'has started';
|
||||
timeText = `Estimated duration: ${duration}`;
|
||||
break;
|
||||
case 'end':
|
||||
emoji = '✅';
|
||||
statusText = 'has completed';
|
||||
timeText = 'All systems are back to normal operation';
|
||||
break;
|
||||
case 'update':
|
||||
emoji = '🔄';
|
||||
statusText = 'has been updated';
|
||||
timeText = `Scheduled for ${startDate.toLocaleString()} to ${endDate.toLocaleString()}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return `${emoji} <b>Maintenance ${statusText}</b>
|
||||
|
||||
<b>Title:</b> ${maintenance.title}
|
||||
<b>Description:</b> ${maintenance.description}
|
||||
<b>Affected Services:</b> ${affectedServices}
|
||||
<b>${timeText}</b>
|
||||
|
||||
<b>Priority:</b> ${maintenance.priority.toUpperCase()}
|
||||
<b>Impact:</b> ${maintenance.field.toUpperCase()}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Set up scheduled maintenance notifications
|
||||
export const setupMaintenanceNotificationsScheduler = () => {
|
||||
console.log("Setting up maintenance notifications scheduler");
|
||||
|
||||
// Check every minute for maintenance that needs notifications
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
console.log("Checking for maintenance notifications to send...");
|
||||
const now = new Date();
|
||||
|
||||
// Fetch upcoming and ongoing maintenance
|
||||
const records = await pb.collection('maintenance').getList(1, 100, {
|
||||
filter: `status = 'scheduled' || status = 'in_progress'`,
|
||||
});
|
||||
|
||||
if (!records || !records.items || records.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each maintenance record
|
||||
for (const record of records.items) {
|
||||
const maintenance = record as unknown as MaintenanceItem;
|
||||
const startTime = new Date(maintenance.start_time);
|
||||
const endTime = new Date(maintenance.end_time);
|
||||
|
||||
// If maintenance is scheduled to start within the next minute or has just started
|
||||
if (maintenance.status === 'scheduled' &&
|
||||
startTime <= new Date(now.getTime() + 60000) &&
|
||||
startTime > new Date(now.getTime() - 60000)) {
|
||||
|
||||
console.log(`Maintenance ${maintenance.title} is starting now, sending notification`);
|
||||
await maintenanceNotificationService.sendMaintenanceNotification({
|
||||
maintenance,
|
||||
notificationType: 'start'
|
||||
});
|
||||
|
||||
// Update status to in_progress
|
||||
await pb.collection('maintenance').update(maintenance.id, {
|
||||
status: 'in_progress'
|
||||
});
|
||||
}
|
||||
|
||||
// If maintenance is in progress and scheduled to end within the next minute or has just ended
|
||||
if (maintenance.status === 'in_progress' &&
|
||||
endTime <= new Date(now.getTime() + 60000) &&
|
||||
endTime > new Date(now.getTime() - 60000)) {
|
||||
|
||||
console.log(`Maintenance ${maintenance.title} is ending now, sending notification`);
|
||||
await maintenanceNotificationService.sendMaintenanceNotification({
|
||||
maintenance,
|
||||
notificationType: 'end'
|
||||
});
|
||||
|
||||
// Update status to completed
|
||||
await pb.collection('maintenance').update(maintenance.id, {
|
||||
status: 'completed'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking maintenance notifications:", error);
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
|
||||
return checkInterval;
|
||||
};
|
||||
|
||||
// Initialize the notification scheduler
|
||||
let notificationScheduler: NodeJS.Timeout | null = null;
|
||||
|
||||
export const initMaintenanceNotifications = () => {
|
||||
if (notificationScheduler === null) {
|
||||
notificationScheduler = setupMaintenanceNotificationsScheduler();
|
||||
console.log("Maintenance notifications scheduler initialized");
|
||||
}
|
||||
};
|
||||
|
||||
export const stopMaintenanceNotifications = () => {
|
||||
if (notificationScheduler !== null) {
|
||||
clearInterval(notificationScheduler);
|
||||
notificationScheduler = null;
|
||||
console.log("Maintenance notifications scheduler stopped");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
|
||||
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||
import { MaintenanceItem, CreateMaintenanceInput } from '../types/maintenance.types';
|
||||
import { categorizeMaintenance } from './maintenanceUtils';
|
||||
import {
|
||||
fetchAllMaintenanceRecords,
|
||||
updateMaintenanceStatus,
|
||||
updateMaintenance,
|
||||
deleteMaintenance,
|
||||
createMaintenance as createMaintenanceRecord,
|
||||
clearMaintenanceCache
|
||||
} from './api';
|
||||
|
||||
// Helper function to get the API URL
|
||||
const getApiUrl = (): string => {
|
||||
return getCurrentEndpoint();
|
||||
};
|
||||
|
||||
// Get all maintenance records with performance optimizations
|
||||
const getMaintenanceRecords = async (): Promise<MaintenanceItem[]> => {
|
||||
return await fetchAllMaintenanceRecords();
|
||||
};
|
||||
|
||||
// Get upcoming maintenance with caching
|
||||
const getUpcomingMaintenance = async (): Promise<MaintenanceItem[]> => {
|
||||
try {
|
||||
const allRecords = await getMaintenanceRecords();
|
||||
const { upcoming } = categorizeMaintenance(allRecords);
|
||||
return upcoming;
|
||||
} catch (error) {
|
||||
console.error('Error fetching upcoming maintenance:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Get ongoing maintenance with caching
|
||||
const getOngoingMaintenance = async (): Promise<MaintenanceItem[]> => {
|
||||
try {
|
||||
const allRecords = await getMaintenanceRecords();
|
||||
const { ongoing } = categorizeMaintenance(allRecords);
|
||||
return ongoing;
|
||||
} catch (error) {
|
||||
console.error('Error fetching ongoing maintenance:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Get completed maintenance with caching
|
||||
const getCompletedMaintenance = async (): Promise<MaintenanceItem[]> => {
|
||||
try {
|
||||
const allRecords = await getMaintenanceRecords();
|
||||
const { completed } = categorizeMaintenance(allRecords);
|
||||
return completed;
|
||||
} catch (error) {
|
||||
console.error('Error fetching completed maintenance:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new maintenance record
|
||||
const createMaintenance = async (data: CreateMaintenanceInput): Promise<void> => {
|
||||
await createMaintenanceRecord(data);
|
||||
clearMaintenanceCache(); // Force cache refresh after creation
|
||||
};
|
||||
|
||||
// Update an existing maintenance record
|
||||
const updateMaintenanceRecord = async (id: string, data: Partial<MaintenanceItem>): Promise<void> => {
|
||||
await updateMaintenance(id, data);
|
||||
clearMaintenanceCache(); // Force cache refresh after update
|
||||
};
|
||||
|
||||
// Refresh cache manually
|
||||
const refreshMaintenanceData = (): void => {
|
||||
clearMaintenanceCache();
|
||||
};
|
||||
|
||||
// Export all functions as the maintenanceService object
|
||||
export const maintenanceService = {
|
||||
getApiUrl,
|
||||
getMaintenanceRecords,
|
||||
getUpcomingMaintenance,
|
||||
getOngoingMaintenance,
|
||||
getCompletedMaintenance,
|
||||
updateMaintenanceStatus,
|
||||
updateMaintenance: updateMaintenanceRecord,
|
||||
deleteMaintenance,
|
||||
createMaintenance,
|
||||
refreshMaintenanceData
|
||||
};
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { MaintenanceItem, CreateMaintenanceInput };
|
||||
@@ -0,0 +1,125 @@
|
||||
|
||||
import { MaintenanceItem } from '../types/maintenance.types';
|
||||
|
||||
// Normalize maintenance item data from PocketBase to our expected format
|
||||
export const normalizeMaintenanceItem = (item: any): MaintenanceItem => {
|
||||
// Log the item to help debug
|
||||
console.log(`Normalizing maintenance item ${item.id}, assigned_users:`, item.assigned_users);
|
||||
|
||||
// Handle assigned_users, ensuring it's always an array
|
||||
let assignedUsers: string[] = [];
|
||||
|
||||
if (item.assigned_users) {
|
||||
if (Array.isArray(item.assigned_users)) {
|
||||
// If it's already an array, use it
|
||||
assignedUsers = item.assigned_users;
|
||||
console.log(`Maintenance ${item.id} assigned_users is an array:`, assignedUsers);
|
||||
} else if (typeof item.assigned_users === 'string' && item.assigned_users.trim() !== '') {
|
||||
// If it's a string, split it by comma
|
||||
assignedUsers = item.assigned_users.split(',').filter((id: string) => id.trim() !== '').map((id: string) => id.trim());
|
||||
console.log(`Maintenance ${item.id} converted assigned_users string to array:`, assignedUsers);
|
||||
}
|
||||
}
|
||||
|
||||
// Include expand data if available
|
||||
const expandData = item.expand ? {
|
||||
expand: {
|
||||
...item.expand
|
||||
}
|
||||
} : {};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || '',
|
||||
description: item.description || '',
|
||||
start_time: item.start_time || '',
|
||||
end_time: item.end_time || '',
|
||||
affected: item.affected || '',
|
||||
priority: item.priority || 'medium',
|
||||
status: item.status || 'scheduled',
|
||||
field: item.field || 'minor',
|
||||
created_by: item.created_by || '',
|
||||
assigned_users: assignedUsers,
|
||||
notify_subscribers: item.notify_subscribers || 'no',
|
||||
notification_channel_id: item.notification_channel_id || '',
|
||||
notification_id: item.notification_id || '',
|
||||
operational_status_id: item.operational_status_id || '',
|
||||
created: item.created || '',
|
||||
updated: item.updated || '',
|
||||
...expandData
|
||||
};
|
||||
};
|
||||
|
||||
// Utility function to convert maintenance status to display format
|
||||
export const formatMaintenanceStatus = (status: string): string => {
|
||||
if (!status) return 'Unknown';
|
||||
|
||||
// Convert to lowercase and replace underscores with spaces
|
||||
const formattedStatus = status.toLowerCase().replace('_', ' ');
|
||||
|
||||
// Capitalize first letter
|
||||
return formattedStatus.charAt(0).toUpperCase() + formattedStatus.slice(1);
|
||||
};
|
||||
|
||||
// Get appropriate color class based on status
|
||||
export const getStatusColorClass = (status: string): string => {
|
||||
const statusLower = status?.toLowerCase() || '';
|
||||
|
||||
switch (statusLower) {
|
||||
case 'scheduled':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||
case 'in_progress':
|
||||
case 'in progress':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
// Get appropriate badge variant based on priority
|
||||
export const getPriorityVariant = (priority: string): 'default' | 'destructive' | 'outline' | 'secondary' => {
|
||||
const priorityLower = priority?.toLowerCase() || '';
|
||||
|
||||
switch (priorityLower) {
|
||||
case 'high':
|
||||
return 'destructive';
|
||||
case 'medium':
|
||||
return 'default';
|
||||
case 'low':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// Categorize maintenance records into upcoming, ongoing, and completed
|
||||
export const categorizeMaintenance = (maintenanceRecords: MaintenanceItem[]) => {
|
||||
const currentDate = new Date();
|
||||
const upcoming: MaintenanceItem[] = [];
|
||||
const ongoing: MaintenanceItem[] = [];
|
||||
const completed: MaintenanceItem[] = [];
|
||||
|
||||
maintenanceRecords.forEach(item => {
|
||||
const status = item.status?.toLowerCase() || '';
|
||||
const startTime = new Date(item.start_time);
|
||||
const endTime = new Date(item.end_time);
|
||||
|
||||
if (status === 'completed' || status === 'cancelled') {
|
||||
completed.push(item);
|
||||
} else if (status === 'in_progress' ||
|
||||
(status === 'scheduled' && startTime <= currentDate && endTime >= currentDate)) {
|
||||
ongoing.push(item);
|
||||
} else if (status === 'scheduled' && startTime > currentDate) {
|
||||
upcoming.push(item);
|
||||
} else {
|
||||
// Default case: treat as upcoming if we can't determine
|
||||
upcoming.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return { upcoming, ongoing, completed };
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
import jsPDF from 'jspdf';
|
||||
import 'jspdf-autotable';
|
||||
import { format } from 'date-fns';
|
||||
import { MaintenanceItem } from '../../types/maintenance.types';
|
||||
import { addHeader, addFooter } from './headerFooter';
|
||||
import {
|
||||
addDescriptionSection,
|
||||
addScheduleSection,
|
||||
addAffectedServicesSection,
|
||||
addPersonnelSection
|
||||
} from './sections';
|
||||
|
||||
// Function to generate a professional PDF from maintenance record
|
||||
export const generatePdf = async (maintenance: MaintenanceItem): Promise<string> => {
|
||||
try {
|
||||
// Validate maintenance data
|
||||
if (!maintenance?.id) {
|
||||
console.error('Invalid maintenance data for PDF generation');
|
||||
throw new Error('Invalid maintenance data');
|
||||
}
|
||||
|
||||
// Create a new PDF document
|
||||
const doc = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4'
|
||||
});
|
||||
|
||||
// Add header with title and metadata
|
||||
addHeader(doc, maintenance);
|
||||
|
||||
// Start rendering content from this position
|
||||
let yPos = 55;
|
||||
|
||||
// Add description section
|
||||
yPos = addDescriptionSection(doc, maintenance, yPos);
|
||||
|
||||
// Check if we need to add a new page
|
||||
if (yPos > 250) {
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
}
|
||||
|
||||
// Add schedule information
|
||||
yPos = addScheduleSection(doc, maintenance, yPos);
|
||||
|
||||
// Check if we need to add a new page
|
||||
if (yPos > 250) {
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
}
|
||||
|
||||
// Add affected services section
|
||||
yPos = addAffectedServicesSection(doc, maintenance, yPos);
|
||||
|
||||
// Check if we need to add a new page
|
||||
if (yPos > 250) {
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
}
|
||||
|
||||
// Add personnel information section
|
||||
addPersonnelSection(doc, maintenance, yPos);
|
||||
|
||||
// Add footer to all pages
|
||||
addFooter(doc);
|
||||
|
||||
// Generate filename
|
||||
const fileName = `maintenance-${maintenance.id}-${format(new Date(), 'yyyy-MM-dd')}.pdf`;
|
||||
|
||||
// Create blob and URL for download
|
||||
const pdfOutput = doc.output('blob');
|
||||
const blobUrl = URL.createObjectURL(new Blob([pdfOutput], { type: 'application/pdf' }));
|
||||
|
||||
// Trigger download using a technique that works in most browsers
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = blobUrl;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
|
||||
console.log('PDF generated successfully:', fileName);
|
||||
return fileName;
|
||||
} catch (error) {
|
||||
console.error('Error generating maintenance PDF:', error);
|
||||
throw new Error(`Failed to generate PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
|
||||
import jsPDF from 'jspdf';
|
||||
import { MaintenanceItem } from '../../types/maintenance.types';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Add header with title and branding to the maintenance PDF
|
||||
*/
|
||||
export const addHeader = (doc: jsPDF, maintenance: MaintenanceItem): void => {
|
||||
// Add colored header background
|
||||
doc.setFillColor(30, 64, 175); // Blue-800 color
|
||||
doc.rect(0, 0, 210, 35, 'F');
|
||||
|
||||
// Add title
|
||||
doc.setFontSize(22);
|
||||
doc.setTextColor(255, 255, 255); // White text
|
||||
doc.text('Scheduled Maintenance Report', 15, 15);
|
||||
|
||||
// Add maintenance title
|
||||
doc.setFontSize(12);
|
||||
doc.text(maintenance.title || 'Maintenance Report', 15, 22);
|
||||
|
||||
// Add reference ID
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(219, 234, 254); // Blue-100 text
|
||||
doc.text(`Reference ID: ${maintenance.id}`, 15, 28);
|
||||
|
||||
// Add current date
|
||||
doc.text(`Generated on: ${format(new Date(), 'PPP')}`, 140, 28, {
|
||||
align: 'right'
|
||||
});
|
||||
|
||||
// Add status badge area below the header
|
||||
doc.setFillColor(249, 250, 251); // Gray-50
|
||||
doc.rect(0, 35, 210, 12, 'F');
|
||||
|
||||
// Add status info
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
const statusText = `Status: ${maintenance.status || 'N/A'} | Priority: ${maintenance.priority || 'N/A'} | Impact: ${maintenance.field || 'N/A'}`;
|
||||
doc.text(statusText, 105, 43, { align: 'center' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Add footer to all pages of the PDF
|
||||
*/
|
||||
export const addFooter = (doc: jsPDF): void => {
|
||||
// Add footer with blue background
|
||||
doc.setFillColor(30, 64, 175); // Blue-800 for footer background
|
||||
doc.rect(0, 277, 210, 20, 'F');
|
||||
|
||||
// Get total page count
|
||||
const totalPages = doc.getNumberOfPages();
|
||||
|
||||
// Add footer to each page
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
|
||||
// Add page number
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(255, 255, 255); // White text for page numbers
|
||||
doc.text(
|
||||
`Page ${i} of ${totalPages}`,
|
||||
195,
|
||||
10,
|
||||
{ align: 'right' }
|
||||
);
|
||||
|
||||
// Add footer text with white color
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(255, 255, 255); // White text for footer
|
||||
doc.text(
|
||||
`Maintenance ID: ${i === 1 ? doc.getTextWidth('Maintenance ID: ') : ''}`,
|
||||
15,
|
||||
283
|
||||
);
|
||||
doc.text(
|
||||
`Generated on ${format(new Date(), 'PPP')}`,
|
||||
195,
|
||||
283,
|
||||
{ align: 'right' }
|
||||
);
|
||||
doc.text(
|
||||
'This document is confidential and intended for internal use only.',
|
||||
105,
|
||||
290,
|
||||
{ align: 'center' }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import { generatePdf } from './generator';
|
||||
import { MaintenanceItem } from '../../types/maintenance.types';
|
||||
|
||||
/**
|
||||
* Generate and download PDF for maintenance report
|
||||
*/
|
||||
export const generateMaintenancePDF = async (maintenance: MaintenanceItem): Promise<string> => {
|
||||
try {
|
||||
const filename = await generatePdf(maintenance);
|
||||
return filename;
|
||||
} catch (error) {
|
||||
console.error("Error in generateMaintenancePDF:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { MaintenanceItem } from '../../types/maintenance.types';
|
||||
import { formatDate, calculateDuration } from './utils';
|
||||
|
||||
/**
|
||||
* Add description section to the PDF
|
||||
*/
|
||||
export const addDescriptionSection = (doc: jsPDF, maintenance: MaintenanceItem, yPos: number): number => {
|
||||
// Add section title
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(30, 64, 175); // Blue-800 color for headings
|
||||
doc.text('Description', 15, yPos);
|
||||
|
||||
// Draw a line under the heading
|
||||
doc.setDrawColor(30, 64, 175); // Blue-800
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, yPos + 2, 195, yPos + 2);
|
||||
|
||||
// Handle multi-line description with word wrapping
|
||||
const description = maintenance.description || 'No description provided';
|
||||
const splitDescription = doc.splitTextToSize(description, 180);
|
||||
|
||||
// Add description text
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(0, 0, 0); // Reset text color
|
||||
doc.text(splitDescription, 15, yPos + 10);
|
||||
|
||||
// Return new Y position after the description text
|
||||
return yPos + 15 + (splitDescription.length * 5);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add schedule information section to the PDF
|
||||
*/
|
||||
export const addScheduleSection = (doc: jsPDF, maintenance: MaintenanceItem, yPos: number): number => {
|
||||
// Add section title
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(30, 64, 175); // Blue-800
|
||||
doc.text('Schedule Information', 15, yPos);
|
||||
|
||||
// Draw a line under the heading
|
||||
doc.setDrawColor(30, 64, 175); // Blue-800
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, yPos + 2, 195, yPos + 2);
|
||||
|
||||
// Create schedule data table
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Start Time', 'End Time', 'Duration']],
|
||||
body: [
|
||||
[
|
||||
formatDate(maintenance.start_time),
|
||||
formatDate(maintenance.end_time),
|
||||
calculateDuration(maintenance.start_time, maintenance.end_time)
|
||||
]
|
||||
],
|
||||
theme: 'striped',
|
||||
headStyles: {
|
||||
fillColor: [30, 64, 175], // Blue-800
|
||||
textColor: 255,
|
||||
fontStyle: 'bold'
|
||||
},
|
||||
margin: { left: 15, right: 15 }
|
||||
});
|
||||
|
||||
// Return next Y position
|
||||
return (doc as any).lastAutoTable.finalY + 15;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add affected services section to the PDF
|
||||
*/
|
||||
export const addAffectedServicesSection = (doc: jsPDF, maintenance: MaintenanceItem, yPos: number): number => {
|
||||
// Add section title
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(30, 64, 175); // Blue-800
|
||||
doc.text('Affected Services', 15, yPos);
|
||||
|
||||
// Draw a line under the heading
|
||||
doc.setDrawColor(30, 64, 175); // Blue-800
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, yPos + 2, 195, yPos + 2);
|
||||
|
||||
// Safely handle affected services string
|
||||
const affectedServices = typeof maintenance.affected === 'string' && maintenance.affected.trim() !== ''
|
||||
? maintenance.affected.split(',').map(item => [item.trim()])
|
||||
: [];
|
||||
|
||||
if (affectedServices.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Service Name']],
|
||||
body: affectedServices,
|
||||
theme: 'striped',
|
||||
headStyles: {
|
||||
fillColor: [30, 64, 175], // Blue-800
|
||||
textColor: 255,
|
||||
fontStyle: 'bold'
|
||||
},
|
||||
margin: { left: 15, right: 15 }
|
||||
});
|
||||
return (doc as any).lastAutoTable.finalY + 15;
|
||||
} else {
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text('No affected services specified', 15, yPos + 10);
|
||||
return yPos + 25;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add personnel information section to the PDF
|
||||
*/
|
||||
export const addPersonnelSection = (doc: jsPDF, maintenance: MaintenanceItem, yPos: number): number => {
|
||||
// Add section title
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(30, 64, 175); // Blue-800
|
||||
doc.text('Personnel Information', 15, yPos);
|
||||
|
||||
// Draw a line under the heading
|
||||
doc.setDrawColor(30, 64, 175); // Blue-800
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, yPos + 2, 195, yPos + 2);
|
||||
|
||||
// Process assigned users for the table safely
|
||||
let assignedUsersText = 'None';
|
||||
if (maintenance.assigned_users) {
|
||||
// Handle both string and array formats for assigned_users
|
||||
const assignedUsersList = Array.isArray(maintenance.assigned_users)
|
||||
? maintenance.assigned_users
|
||||
: (typeof maintenance.assigned_users === 'string'
|
||||
? maintenance.assigned_users.split(',').map(item => item.trim())
|
||||
: []);
|
||||
|
||||
assignedUsersText = assignedUsersList.length > 0
|
||||
? assignedUsersList.join(', ')
|
||||
: 'None';
|
||||
}
|
||||
|
||||
// Create personnel data table
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Created By', 'Assigned Personnel', 'Notifications']],
|
||||
body: [
|
||||
[
|
||||
maintenance.created_by || 'Not specified',
|
||||
assignedUsersText,
|
||||
maintenance.notify_subscribers === 'yes' ? 'Enabled' : 'Disabled'
|
||||
]
|
||||
],
|
||||
theme: 'striped',
|
||||
headStyles: {
|
||||
fillColor: [30, 64, 175], // Blue-800
|
||||
textColor: 255,
|
||||
fontStyle: 'bold'
|
||||
},
|
||||
margin: { left: 15, right: 15 }
|
||||
});
|
||||
|
||||
// Return next Y position
|
||||
return (doc as any).lastAutoTable.finalY + 15;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// Font configuration for the PDF
|
||||
export const fonts = {
|
||||
normal: 'Helvetica',
|
||||
bold: 'Helvetica-Bold',
|
||||
italic: 'Helvetica-Oblique',
|
||||
};
|
||||
|
||||
// Helper function to format dates
|
||||
export const formatDate = (dateString: string | undefined): string => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return format(new Date(dateString), 'PPp');
|
||||
} catch (e) {
|
||||
return dateString || 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to calculate duration between two dates
|
||||
export const calculateDuration = (start: string, end: string): string => {
|
||||
try {
|
||||
if (!start || !end) return 'N/A';
|
||||
|
||||
const startTime = new Date(start);
|
||||
const endTime = new Date(end);
|
||||
|
||||
// Validate dates
|
||||
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// Check for negative duration
|
||||
if (durationMs < 0) {
|
||||
return 'Invalid date range';
|
||||
}
|
||||
|
||||
// Convert to hours and minutes
|
||||
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
} catch (error) {
|
||||
console.error('Error calculating duration:', error);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
export interface MaintenanceItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
affected: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
field: string;
|
||||
created_by: string;
|
||||
assigned_users: string[] | string; // Can be array of user IDs or string (JSON or comma-separated)
|
||||
notify_subscribers: string;
|
||||
notification_channel_id?: string;
|
||||
notification_id?: string;
|
||||
operational_status_id?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
notification_channel_name?: string; // Add this optional property for UI display purposes
|
||||
expand?: {
|
||||
assigned_users?: any[]; // For expanded user data
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateMaintenanceInput {
|
||||
title: string;
|
||||
description: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
affected: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
field: string;
|
||||
created_by: string;
|
||||
assigned_users: string[] | string; // Can be array of user IDs or string representation
|
||||
notify_subscribers: string;
|
||||
notification_channel_id?: string;
|
||||
notification_id?: string; // Added to ensure it's included in create operations
|
||||
}
|
||||
|
||||
export interface MaintenanceFilter {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
field?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user