feat: Implement Schedule Maintenance Management and Tab Dashboard
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useMaintenanceForm } from './hooks/useMaintenanceForm';
|
||||
import {
|
||||
MaintenanceBasicFields,
|
||||
MaintenanceTimeFields,
|
||||
MaintenanceAffectedFields,
|
||||
MaintenanceConfigFields,
|
||||
MaintenanceNotificationSettingsField
|
||||
} from './form';
|
||||
|
||||
interface CreateMaintenanceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onMaintenanceCreated: () => void;
|
||||
}
|
||||
|
||||
export const CreateMaintenanceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onMaintenanceCreated
|
||||
}: CreateMaintenanceDialogProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleSuccess = () => {
|
||||
console.log("CreateMaintenanceDialog: maintenance created successfully");
|
||||
onMaintenanceCreated();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
console.log("CreateMaintenanceDialog: closing dialog");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useMaintenanceForm(handleSuccess, handleClose);
|
||||
|
||||
// Log the form state for debugging
|
||||
React.useEffect(() => {
|
||||
const subscription = form.watch((value) => {
|
||||
console.log("Form values changed:", value);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('createMaintenanceWindow')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('createMaintenanceDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<MaintenanceBasicFields />
|
||||
<MaintenanceTimeFields />
|
||||
<MaintenanceAffectedFields />
|
||||
<MaintenanceConfigFields />
|
||||
<MaintenanceNotificationSettingsField />
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? t('creating') : t('createMaintenance')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { CalendarClock } from 'lucide-react';
|
||||
|
||||
export const EmptyMaintenanceState = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<CalendarClock className="w-12 h-12 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">{t('noScheduledMaintenance')}</h3>
|
||||
<p className="text-sm text-center max-w-md">
|
||||
{t('noMaintenanceWindows')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MoreHorizontal, Eye, Edit, Trash, Play, CheckCircle, X } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { maintenanceService } from '@/services/maintenance';
|
||||
import { MaintenanceDetailDialog } from './detail-dialog/MaintenanceDetailDialog';
|
||||
import { EditMaintenanceDialog } from './edit-dialog/EditMaintenanceDialog';
|
||||
|
||||
interface MaintenanceActionsMenuProps {
|
||||
item: MaintenanceItem;
|
||||
onMaintenanceUpdated: () => void;
|
||||
}
|
||||
|
||||
export const MaintenanceActionsMenu = ({ item, onMaintenanceUpdated }: MaintenanceActionsMenuProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
await maintenanceService.updateMaintenanceStatus(item.id, newStatus);
|
||||
toast({
|
||||
title: t('statusUpdated'),
|
||||
description: t('maintenanceStatusUpdated'),
|
||||
});
|
||||
onMaintenanceUpdated();
|
||||
} catch (error) {
|
||||
console.error('Error updating maintenance status:', error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorUpdatingMaintenanceStatus'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await maintenanceService.deleteMaintenance(item.id);
|
||||
toast({
|
||||
title: t('maintenanceDeleted'),
|
||||
description: t('maintenanceDeletedDesc'),
|
||||
});
|
||||
onMaintenanceUpdated();
|
||||
} catch (error) {
|
||||
console.error('Error deleting maintenance:', error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorDeletingMaintenance'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert status to lowercase for consistent comparison
|
||||
const status = item.status.toLowerCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{t('actions')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setDetailDialogOpen(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('view')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{t('edit')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{status === 'scheduled' && (
|
||||
<DropdownMenuItem onClick={() => handleStatusChange('in_progress')}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{t('markAsInProgress')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{(status === 'scheduled' || status === 'in_progress') && (
|
||||
<DropdownMenuItem onClick={() => handleStatusChange('completed')}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
{t('markAsCompleted')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{status !== 'cancelled' && (
|
||||
<DropdownMenuItem onClick={() => handleStatusChange('cancelled')}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
{t('markAsCancelled')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<MaintenanceDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
maintenance={item}
|
||||
/>
|
||||
|
||||
<EditMaintenanceDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
maintenance={item}
|
||||
onMaintenanceUpdated={onMaintenanceUpdated}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteMaintenanceConfirmation')}
|
||||
<span className="font-semibold"> {item.title}</span>?
|
||||
{t('thisActionCannotBeUndone')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
|
||||
{t('delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { CalendarClock, Clock, CheckCircle, X } from 'lucide-react';
|
||||
|
||||
interface MaintenanceStatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const MaintenanceStatusBadge = ({ status }: MaintenanceStatusBadgeProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Ensure we have a string and normalize it
|
||||
const normalizedStatus = typeof status === 'string' ? status.toLowerCase() : '';
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (normalizedStatus) {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (normalizedStatus) {
|
||||
case 'scheduled':
|
||||
return <CalendarClock className="h-3 w-3" />;
|
||||
case 'in progress':
|
||||
case 'in_progress':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-3 w-3" />;
|
||||
case 'cancelled':
|
||||
return <X className="h-3 w-3" />;
|
||||
default:
|
||||
return <CalendarClock className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayStatus = () => {
|
||||
switch (normalizedStatus) {
|
||||
case 'scheduled':
|
||||
return t('scheduled');
|
||||
case 'in progress':
|
||||
case 'in_progress':
|
||||
return t('inProgress');
|
||||
case 'completed':
|
||||
return t('completed');
|
||||
case 'cancelled':
|
||||
return t('cancelled');
|
||||
default:
|
||||
return status || '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={`${getStatusColor()} flex items-center gap-1 font-medium`} variant="outline">
|
||||
{getStatusIcon()}
|
||||
{getDisplayStatus()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { maintenanceService } from '@/services/maintenance';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { maintenanceNotificationService } from '@/services/maintenance/maintenanceNotificationService';
|
||||
|
||||
interface MaintenanceStatusCheckerProps {
|
||||
maintenanceData: MaintenanceItem[];
|
||||
onStatusUpdated: () => void;
|
||||
}
|
||||
|
||||
export const MaintenanceStatusChecker = ({
|
||||
maintenanceData,
|
||||
onStatusUpdated
|
||||
}: MaintenanceStatusCheckerProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const checkedItemsRef = useRef<Set<string>>(new Set());
|
||||
const notificationSentRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!maintenanceData || maintenanceData.length === 0) return;
|
||||
|
||||
// Check for maintenance items that need status update
|
||||
const now = new Date();
|
||||
const checkAndUpdateStatus = async () => {
|
||||
for (const item of maintenanceData) {
|
||||
if (item.status.toLowerCase() !== 'scheduled') continue;
|
||||
|
||||
// Skip if we've already checked this item in this session
|
||||
if (checkedItemsRef.current.has(item.id)) continue;
|
||||
|
||||
try {
|
||||
const startTime = new Date(item.start_time);
|
||||
const endTime = new Date(item.end_time);
|
||||
|
||||
// If current time is past start time but before end time, update to in_progress
|
||||
if (startTime <= now && now <= endTime) {
|
||||
console.log(`Auto-updating maintenance ${item.title} to in_progress status`);
|
||||
|
||||
// Update status to in_progress
|
||||
await maintenanceService.updateMaintenanceStatus(item.id, 'in_progress');
|
||||
|
||||
// Only send notification if not sent before
|
||||
if (!notificationSentRef.current.has(item.id)) {
|
||||
console.log(`Sending start notification for maintenance ${item.title}`);
|
||||
|
||||
// Send notification for maintenance start
|
||||
await maintenanceNotificationService.sendMaintenanceNotification({
|
||||
maintenance: item,
|
||||
notificationType: 'start'
|
||||
});
|
||||
|
||||
// Mark as notified
|
||||
notificationSentRef.current.add(item.id);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('maintenanceInProgress'),
|
||||
description: `${item.title} ${t('isNowInProgress')}`,
|
||||
});
|
||||
|
||||
onStatusUpdated();
|
||||
}
|
||||
|
||||
// Mark as checked regardless of outcome
|
||||
checkedItemsRef.current.add(item.id);
|
||||
} catch (error) {
|
||||
console.error(`Error auto-updating maintenance status for ${item.id}:`, error);
|
||||
|
||||
// Add a small delay before trying again to prevent rapid retry cycles
|
||||
setTimeout(() => {
|
||||
checkedItemsRef.current.delete(item.id); // Allow retry on next check
|
||||
}, 300000); // 5 minutes before retry
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run the check immediately
|
||||
checkAndUpdateStatus();
|
||||
|
||||
// Set up interval to check every minute
|
||||
const intervalId = setInterval(checkAndUpdateStatus, 60000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [maintenanceData, onStatusUpdated, t, toast]);
|
||||
|
||||
// This is a utility component with no UI render
|
||||
return null;
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { CalendarClock, Clock, CheckCircle, X } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { maintenanceService } from '@/services/maintenance';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MaintenanceStatusBadge } from './MaintenanceStatusBadge';
|
||||
|
||||
interface MaintenanceStatusDropdownProps {
|
||||
status: string;
|
||||
id: string;
|
||||
onStatusUpdated: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const MaintenanceStatusDropdown = ({
|
||||
status,
|
||||
id,
|
||||
onStatusUpdated,
|
||||
disabled = false
|
||||
}: MaintenanceStatusDropdownProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'scheduled', label: t('scheduled'), icon: <CalendarClock className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'in_progress', label: t('inProgress'), icon: <Clock className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'completed', label: t('completed'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'cancelled', label: t('cancelled'), icon: <X className="h-4 w-4 mr-2" /> },
|
||||
];
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
// Don't update if the status is the same
|
||||
if (status.toLowerCase() === newStatus) return;
|
||||
|
||||
try {
|
||||
await maintenanceService.updateMaintenanceStatus(id, newStatus);
|
||||
|
||||
toast({
|
||||
title: t('statusUpdated'),
|
||||
description: t('maintenanceStatusUpdated'),
|
||||
});
|
||||
|
||||
onStatusUpdated();
|
||||
} catch (error) {
|
||||
console.error('Error updating maintenance status:', error);
|
||||
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('failedToUpdateStatus'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
|
||||
<MaintenanceStatusBadge status={status} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-popover border border-border shadow-md">
|
||||
{statusOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="flex items-center cursor-pointer"
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { format } from 'date-fns';
|
||||
import { MaintenanceStatusDropdown } from './MaintenanceStatusDropdown';
|
||||
import { MaintenanceActionsMenu } from './MaintenanceActionsMenu';
|
||||
import { MaintenanceDetailDialog } from './detail-dialog/MaintenanceDetailDialog';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmptyMaintenanceState } from './EmptyMaintenanceState';
|
||||
|
||||
interface MaintenanceTableProps {
|
||||
data: MaintenanceItem[];
|
||||
isLoading?: boolean;
|
||||
onMaintenanceUpdated: () => void;
|
||||
}
|
||||
|
||||
export const MaintenanceTable = ({ data, isLoading = false, onMaintenanceUpdated }: MaintenanceTableProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [selectedMaintenance, setSelectedMaintenance] = useState<MaintenanceItem | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
|
||||
// Memoize the formatted date function to avoid recreating it on each render
|
||||
const formatDate = useMemo(() => (dateString: string) => {
|
||||
if (!dateString) return '-';
|
||||
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, yyyy HH:mm');
|
||||
} catch (error) {
|
||||
return dateString || '-';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewMaintenance = (maintenance: MaintenanceItem) => {
|
||||
setSelectedMaintenance(maintenance);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
// Render skeleton loading state with table structure for smoother transitions
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('title')}</TableHead>
|
||||
<TableHead>{t('status')}</TableHead>
|
||||
<TableHead>{t('scheduledStart')}</TableHead>
|
||||
<TableHead>{t('scheduledEnd')}</TableHead>
|
||||
<TableHead>{t('affectedServices')}</TableHead>
|
||||
<TableHead>{t('impact')}</TableHead>
|
||||
<TableHead className="w-[80px]">{t('actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[1, 2, 3].map(i => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell><Skeleton className="h-6 w-28" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-8" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyMaintenanceState />;
|
||||
}
|
||||
|
||||
// Memoize the impact badge variant calculation
|
||||
const getImpactBadgeVariant = (field: string | undefined) => {
|
||||
const fieldLower = field?.toLowerCase() || '';
|
||||
if (fieldLower === 'critical') return 'destructive';
|
||||
if (fieldLower === 'major') return 'default';
|
||||
if (fieldLower === 'minor') return 'secondary';
|
||||
return 'outline';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('title')}</TableHead>
|
||||
<TableHead>{t('status')}</TableHead>
|
||||
<TableHead>{t('scheduledStart')}</TableHead>
|
||||
<TableHead>{t('scheduledEnd')}</TableHead>
|
||||
<TableHead>{t('affectedServices')}</TableHead>
|
||||
<TableHead>{t('impact')}</TableHead>
|
||||
<TableHead className="w-[80px]">{t('actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/40">
|
||||
<TableCell className="font-medium" onClick={() => handleViewMaintenance(item)}>
|
||||
{item.title || '-'}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<MaintenanceStatusDropdown
|
||||
status={item.status}
|
||||
id={item.id}
|
||||
onStatusUpdated={onMaintenanceUpdated}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||
{formatDate(item.start_time)}
|
||||
</TableCell>
|
||||
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||
{formatDate(item.end_time)}
|
||||
</TableCell>
|
||||
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.affected ? item.affected.split(',').slice(0, 2).map((service, index) => (
|
||||
<Badge key={index} variant="outline">{service.trim()}</Badge>
|
||||
)) : '-'}
|
||||
{item.affected && item.affected.split(',').length > 2 && (
|
||||
<Badge variant="outline">+{item.affected.split(',').length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||
<Badge variant={getImpactBadgeVariant(item.field)}>
|
||||
{item.field ? t(item.field.toLowerCase()) : '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<MaintenanceActionsMenu
|
||||
item={item}
|
||||
onMaintenanceUpdated={onMaintenanceUpdated}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<MaintenanceDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
maintenance={selectedMaintenance}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { userService, User } from '@/services/userService';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { MaintenanceDetailHeader } from './MaintenanceDetailHeader';
|
||||
import { MaintenanceDetailSections } from './MaintenanceDetailSections';
|
||||
import { MaintenanceDetailFooter } from './MaintenanceDetailFooter';
|
||||
import { alertConfigService } from '@/services/alertConfigService';
|
||||
|
||||
interface MaintenanceDetailContentProps {
|
||||
maintenance: MaintenanceItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MaintenanceDetailContent = ({
|
||||
maintenance,
|
||||
onClose
|
||||
}: MaintenanceDetailContentProps) => {
|
||||
const [assignedUsers, setAssignedUsers] = useState<User[]>([]);
|
||||
const [maintenanceWithDetails, setMaintenanceWithDetails] = useState<MaintenanceItem>(maintenance);
|
||||
|
||||
// Fetch all users to resolve the assigned users and creator
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
const usersList = await userService.getUsers();
|
||||
console.log("Fetched users for maintenance detail:", usersList);
|
||||
return usersList || [];
|
||||
},
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Fetch notification channels for notification channel display
|
||||
const { data: notificationChannels = [] } = useQuery({
|
||||
queryKey: ['notificationChannels'],
|
||||
queryFn: async () => {
|
||||
const channels = await alertConfigService.getAlertConfigurations();
|
||||
console.log("Fetched notification channels:", channels);
|
||||
return channels || [];
|
||||
},
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Process user information when users data is available
|
||||
useEffect(() => {
|
||||
if (users.length > 0 && maintenance) {
|
||||
console.log("Processing maintenance data with users:", maintenance);
|
||||
console.log("Maintenance assigned_users:", maintenance.assigned_users);
|
||||
|
||||
// Create a copy of the maintenance object for modifications
|
||||
const enhancedMaintenance = { ...maintenance };
|
||||
|
||||
// Process assigned users
|
||||
if (maintenance.assigned_users) {
|
||||
let userIds: string[] = [];
|
||||
|
||||
// Step 1: Extract user IDs from various formats
|
||||
if (Array.isArray(maintenance.assigned_users)) {
|
||||
userIds = maintenance.assigned_users;
|
||||
console.log("Assigned users is an array:", userIds);
|
||||
} else if (typeof maintenance.assigned_users === 'string') {
|
||||
// Try parsing the string to extract user IDs
|
||||
try {
|
||||
// Try parsing as JSON first
|
||||
const parsedData = JSON.parse(maintenance.assigned_users);
|
||||
if (Array.isArray(parsedData)) {
|
||||
// Direct array format
|
||||
userIds = parsedData;
|
||||
console.log("Parsed assigned_users from JSON array:", userIds);
|
||||
} else if (typeof parsedData === 'string') {
|
||||
// Nested JSON string
|
||||
try {
|
||||
const nestedData = JSON.parse(parsedData);
|
||||
if (Array.isArray(nestedData)) {
|
||||
userIds = nestedData;
|
||||
console.log("Parsed assigned_users from nested JSON:", userIds);
|
||||
}
|
||||
} catch (e) {
|
||||
// If nested parsing fails, treat as single ID
|
||||
userIds = [parsedData];
|
||||
console.log("Using parsed string as single ID:", userIds);
|
||||
}
|
||||
} else {
|
||||
// Unknown format, use as is
|
||||
userIds = [String(parsedData)];
|
||||
console.log("Using parsed data as single ID:", userIds);
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, try comma splitting
|
||||
if (maintenance.assigned_users.includes(',')) {
|
||||
userIds = maintenance.assigned_users.split(',').map(id => id.trim()).filter(Boolean);
|
||||
console.log("Split assigned_users by comma:", userIds);
|
||||
} else {
|
||||
// Single ID
|
||||
userIds = [maintenance.assigned_users];
|
||||
console.log("Using string as single ID:", userIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Clean up extracted IDs (remove quotes, brackets, etc.)
|
||||
userIds = userIds.map(id => {
|
||||
// Remove surrounding quotes if present
|
||||
let cleanId = id.replace(/^["']|["']$/g, '');
|
||||
// Remove any remaining JSON artifacts
|
||||
cleanId = cleanId.replace(/[\[\]"'\\]/g, '');
|
||||
return cleanId;
|
||||
}).filter(Boolean);
|
||||
|
||||
console.log("Cleaned user IDs:", userIds);
|
||||
|
||||
// Step 3: Find matching users from the users array
|
||||
if (userIds.length > 0) {
|
||||
const matchedUsers = users.filter(user => userIds.includes(user.id));
|
||||
console.log("Matched assigned users:", matchedUsers);
|
||||
setAssignedUsers(matchedUsers);
|
||||
} else {
|
||||
console.log("No user IDs found in assigned_users");
|
||||
setAssignedUsers([]);
|
||||
}
|
||||
} else {
|
||||
console.log("No assigned users found in maintenance data");
|
||||
setAssignedUsers([]);
|
||||
}
|
||||
|
||||
// Process created_by field - replace ID with full name if available
|
||||
if (maintenance?.created_by) {
|
||||
const creator = users.find(user => user.id === maintenance.created_by);
|
||||
if (creator) {
|
||||
enhancedMaintenance.created_by = creator.full_name || creator.username || maintenance.created_by;
|
||||
}
|
||||
}
|
||||
|
||||
// Process notification channel if needed
|
||||
if (maintenance.notify_subscribers === 'yes') {
|
||||
// Try notification_channel_id first, fall back to notification_id if needed
|
||||
const channelId = maintenance.notification_channel_id || maintenance.notification_id;
|
||||
|
||||
if (channelId && notificationChannels.length > 0) {
|
||||
console.log("Looking for notification channel with ID:", channelId);
|
||||
console.log("Available notification channels:", notificationChannels.map(c => ({ id: c.id, name: c.notify_name })));
|
||||
|
||||
const channel = notificationChannels.find(ch => ch.id === channelId);
|
||||
if (channel) {
|
||||
console.log("Found notification channel:", channel);
|
||||
enhancedMaintenance.notification_channel_name = `${channel.notify_name} (${channel.notification_type})`;
|
||||
} else {
|
||||
console.log("No matching notification channel found for ID:", channelId);
|
||||
enhancedMaintenance.notification_channel_name = `Channel ID: ${channelId}`;
|
||||
}
|
||||
} else {
|
||||
console.log("No channel ID available or no notification channels loaded");
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Enhanced maintenance with details:", enhancedMaintenance);
|
||||
console.log("Assigned users for display:", assignedUsers);
|
||||
setMaintenanceWithDetails(enhancedMaintenance);
|
||||
}
|
||||
}, [maintenance, users, notificationChannels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaintenanceDetailHeader maintenance={maintenanceWithDetails} />
|
||||
<MaintenanceDetailSections
|
||||
maintenance={maintenanceWithDetails}
|
||||
assignedUsers={assignedUsers}
|
||||
/>
|
||||
<MaintenanceDetailFooter
|
||||
maintenance={maintenanceWithDetails}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent
|
||||
} from '@/components/ui/dialog';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { MaintenanceDetailContent } from './MaintenanceDetailContent';
|
||||
|
||||
interface MaintenanceDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
maintenance: MaintenanceItem | null;
|
||||
}
|
||||
|
||||
export const MaintenanceDetailDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
maintenance
|
||||
}: MaintenanceDetailDialogProps) => {
|
||||
if (!maintenance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="dialog-content sm:max-w-[700px] max-h-[90vh] overflow-y-auto
|
||||
print:max-w-none print:max-h-none print:overflow-visible
|
||||
print:shadow-none print:m-0 print:p-0 print:border-none
|
||||
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
|
||||
>
|
||||
<MaintenanceDetailContent maintenance={maintenance} onClose={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
|
||||
import React from 'react';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import {
|
||||
PrintButton,
|
||||
DownloadPdfButton,
|
||||
CloseButton
|
||||
} from './components';
|
||||
|
||||
interface MaintenanceDetailFooterProps {
|
||||
maintenance: MaintenanceItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MaintenanceDetailFooter = ({
|
||||
maintenance,
|
||||
onClose
|
||||
}: MaintenanceDetailFooterProps) => {
|
||||
return (
|
||||
<DialogFooter className="gap-2 sm:gap-2 mt-6 pt-4 border-t print:hidden">
|
||||
<PrintButton maintenance={maintenance} />
|
||||
<DownloadPdfButton maintenance={maintenance} />
|
||||
<CloseButton onClose={onClose} />
|
||||
</DialogFooter>
|
||||
);
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { CalendarClock } from 'lucide-react';
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
|
||||
interface MaintenanceDetailHeaderProps {
|
||||
maintenance: MaintenanceItem;
|
||||
}
|
||||
|
||||
export const MaintenanceDetailHeader = ({ maintenance }: MaintenanceDetailHeaderProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: string) => {
|
||||
switch (impact.toLowerCase()) {
|
||||
case 'critical':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
case 'major':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300';
|
||||
case 'minor':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||
case 'none':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority.toLowerCase()) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogHeader className="print:mb-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CalendarClock className="h-5 w-5 text-blue-500 print:hidden" />
|
||||
<DialogTitle className="text-xl">{maintenance.title}</DialogTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Badge className={getStatusColor(maintenance.status)}>
|
||||
{maintenance.status}
|
||||
</Badge>
|
||||
<Badge className={getImpactColor(maintenance.field)}>
|
||||
{maintenance.field} {t('impact')}
|
||||
</Badge>
|
||||
<Badge className={getPriorityColor(maintenance.priority)}>
|
||||
{maintenance.priority} {t('priority')}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
);
|
||||
};
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { format } from 'date-fns';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { User } from '@/services/userService';
|
||||
|
||||
interface MaintenanceDetailSectionsProps {
|
||||
maintenance: MaintenanceItem;
|
||||
assignedUsers: User[];
|
||||
}
|
||||
|
||||
export const MaintenanceDetailSections = ({
|
||||
maintenance,
|
||||
assignedUsers
|
||||
}: MaintenanceDetailSectionsProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), 'PPP p');
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDuration = (start: string, end: string): string => {
|
||||
try {
|
||||
const startTime = new Date(start);
|
||||
const endTime = new Date(end);
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// Convert to hours and minutes
|
||||
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
} catch (error) {
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
const affectedServices = typeof maintenance.affected === 'string'
|
||||
? maintenance.affected.split(',').map(item => item.trim())
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 print:space-y-1 print-compact-spacing">
|
||||
{/* Print Header - Only visible when printing */}
|
||||
<div className="hidden print:block print-section">
|
||||
<div className="header-print bg-blue-800 text-white p-4 rounded-t-md">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Scheduled Maintenance Report</h1>
|
||||
<p className="text-blue-100 mt-1 print-compact-text">{maintenance.title}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-blue-100">Reference ID: {maintenance.id}</p>
|
||||
<p className="text-xs text-blue-100">Generated on {format(new Date(), 'PPP')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference ID - Only visible in UI */}
|
||||
<div className="flex items-center justify-between text-sm print:hidden">
|
||||
<span className="text-muted-foreground">{t('referenceID')}</span>
|
||||
<span className="font-mono bg-muted px-2 py-1 rounded">{maintenance.id}</span>
|
||||
</div>
|
||||
|
||||
{/* Description Section */}
|
||||
<div className="print:mt-0 print-section">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('description')}</h4>
|
||||
<div className="bg-muted/50 p-3 rounded-md text-sm whitespace-pre-wrap print:bg-transparent print:border print:border-gray-200 print:p-1 print:rounded print:text-xs print:text-black print-description">
|
||||
{maintenance.description || t('noDescriptionProvided')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="print:hidden" />
|
||||
|
||||
{/* Time Information */}
|
||||
<div className="print-section">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('timeInformation')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print:grid-cols-2 print:gap-1 print:bg-gray-50 print:border print:border-gray-200 print:p-1 print:rounded print-compact-text">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('scheduledStart')}</h4>
|
||||
<p className="text-sm print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.start_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('scheduledEnd')}</h4>
|
||||
<p className="text-sm print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.end_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('duration')}</h4>
|
||||
<p className="text-sm print:text-xs print:text-black print-smaller-font">{calculateDuration(maintenance.start_time, maintenance.end_time)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('maintenanceType')}</h4>
|
||||
<p className="text-sm print:text-xs print:text-black print-smaller-font capitalize">{maintenance.field}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="print:hidden" />
|
||||
|
||||
{/* Affected Services */}
|
||||
<div className="print-section">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('affectedServices')}</h4>
|
||||
<div className="print:border print:border-gray-200 print:p-1 print:rounded">
|
||||
{affectedServices.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 print:gap-1">
|
||||
{affectedServices.map((service, index) => (
|
||||
<Badge key={index} variant="outline" className="badge-print print:bg-blue-50 print:text-blue-800 print:border print:border-blue-200">
|
||||
{service}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground print:text-xs print:text-black">{t('noAffectedServices')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Users - Simplified for print */}
|
||||
<div className="print-section">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('assignedPersonnel')}</h4>
|
||||
<div className="border border-border rounded-md p-3 print:border print:border-gray-200 print:p-1 print:rounded print:bg-gray-50">
|
||||
{assignedUsers && assignedUsers.length > 0 ? (
|
||||
<div className="print:grid print:grid-cols-2 print:gap-1">
|
||||
{assignedUsers.map((user) => (
|
||||
<div key={user.id} className="flex items-center gap-2 p-2 bg-muted/30 rounded-md print:bg-transparent print:p-0 print:text-xs">
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium print:bg-blue-100 print:text-blue-800 print:w-4 print:h-4">
|
||||
{user && (user.full_name?.[0] || user.username?.[0] || 'U').toUpperCase()}
|
||||
</div>
|
||||
<span className="print:text-xs print:font-medium print:text-black print-smaller-font">{user.full_name || user.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground print:text-xs print:text-black">{t('noAssignedUsers')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="print:hidden" />
|
||||
|
||||
{/* Metadata - Condensed for print */}
|
||||
<div className="print-section">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('additionalInformation')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm print:grid-cols-2 print:gap-1 print:bg-gray-50 print:border print:border-gray-200 print:p-1 print:rounded print-compact-text">
|
||||
<div>
|
||||
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('createdBy')}</h4>
|
||||
<p className="print:text-xs print:text-black print-smaller-font">{maintenance.created_by || t('notSpecified')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('notificationChannel')}</h4>
|
||||
<p className="print:text-xs print:text-black print-smaller-font">{maintenance.notification_channel_name || t('notSpecified')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('created')}</h4>
|
||||
<p className="print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.created)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('lastUpdated')}</h4>
|
||||
<p className="print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.updated)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Settings - Simplified for print */}
|
||||
<div className="text-sm bg-muted/50 p-3 rounded-md print:bg-blue-50 print:border print:border-blue-200 print:p-1 print:rounded print-section print-compact-text">
|
||||
<h4 className="font-medium mb-1 print:text-xs print:font-bold print:text-blue-800 print:mb-0">{t('notificationSettings')}</h4>
|
||||
<p className="print:text-xs print:text-black print-smaller-font">
|
||||
{maintenance.notify_subscribers === 'yes'
|
||||
? t('subscribersWillBeNotified')
|
||||
: t('noNotifications')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Print Footer - Only visible when printing */}
|
||||
<div className="hidden print:block footer-print border-t border-gray-200 text-xs text-gray-500">
|
||||
<div className="flex justify-between">
|
||||
<span>Maintenance ID: {maintenance.id}</span>
|
||||
<span>Printed on {format(new Date(), 'PPP')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-center">This document is confidential and intended for internal use only.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface CloseButtonProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CloseButton: React.FC<CloseButtonProps> = ({ onClose }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useDownloadMaintenancePdf } from '../hooks';
|
||||
import { MaintenanceItem } from '@/services/maintenance';
|
||||
|
||||
interface DownloadPdfButtonProps {
|
||||
maintenance: MaintenanceItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
|
||||
maintenance,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { handleDownloadPDF } = useDownloadMaintenancePdf();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`flex items-center gap-2 ${className || ''}`}
|
||||
onClick={() => handleDownloadPDF(maintenance)}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('downloadPdf')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePrintMaintenance } from '../hooks/usePrintMaintenance';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
|
||||
interface PrintButtonProps {
|
||||
maintenance: MaintenanceItem;
|
||||
}
|
||||
|
||||
export const PrintButton: React.FC<PrintButtonProps> = ({ maintenance }) => {
|
||||
const { t } = useLanguage();
|
||||
const { handlePrint } = usePrintMaintenance();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => handlePrint(maintenance)}
|
||||
variant="default"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
{t('print')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
|
||||
export * from './DownloadPdfButton';
|
||||
export * from './PrintButton';
|
||||
export * from './CloseButton';
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
export * from './useDownloadMaintenancePdf';
|
||||
export * from './usePrintMaintenance';
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { MaintenanceItem, generateMaintenancePDF } from '@/services/maintenance';
|
||||
|
||||
export const useDownloadMaintenancePdf = () => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleDownloadPDF = async (maintenance: MaintenanceItem) => {
|
||||
try {
|
||||
// Show loading toast
|
||||
toast({
|
||||
title: t('generating'),
|
||||
description: t('preparingPdf'),
|
||||
});
|
||||
|
||||
// Generate and download the PDF
|
||||
const filename = await generateMaintenancePDF(maintenance);
|
||||
|
||||
// Show success toast after successful generation
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('pdfDownloaded'),
|
||||
});
|
||||
|
||||
return filename;
|
||||
} catch (error) {
|
||||
console.error("Error generating PDF:", error);
|
||||
|
||||
// Provide more detailed error message if available
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: t('pdfGenerationFailed');
|
||||
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { handleDownloadPDF };
|
||||
};
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
|
||||
export const usePrintMaintenance = () => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handlePrint = (maintenance: MaintenanceItem) => {
|
||||
try {
|
||||
// Add print-specific stylesheet temporarily
|
||||
const style = document.createElement('style');
|
||||
style.id = 'print-style';
|
||||
style.textContent = `
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 10mm;
|
||||
}
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dialog-content, .dialog-content * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
position: absolute !important;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 15mm !important;
|
||||
margin: 0 !important;
|
||||
background-color: white !important;
|
||||
box-shadow: none;
|
||||
overflow: visible !important;
|
||||
display: block !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Remove any black backgrounds */
|
||||
html, body {
|
||||
background-color: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Optimize spacing for single page */
|
||||
.print-section {
|
||||
margin-bottom: 2mm !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* Reduce vertical spacing */
|
||||
h4 {
|
||||
margin-bottom: 1mm !important;
|
||||
margin-top: 1mm !important;
|
||||
color: #1e40af !important; /* blue-800 */
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.print-compact-text {
|
||||
font-size: 9pt !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
.print-compact-spacing > * {
|
||||
margin-top: 1mm !important;
|
||||
margin-bottom: 1mm !important;
|
||||
}
|
||||
|
||||
.print-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
gap: 1mm !important;
|
||||
}
|
||||
|
||||
.badge-print {
|
||||
background-color: #dbeafe !important; /* blue-100 */
|
||||
color: #1e40af !important; /* blue-800 */
|
||||
border: 1px solid #93c5fd !important; /* blue-300 */
|
||||
padding: 0.5mm 1mm !important;
|
||||
margin: 0.5mm !important;
|
||||
display: inline-block !important;
|
||||
font-size: 8pt !important;
|
||||
}
|
||||
|
||||
/* More compact spacing */
|
||||
.print-compact-margin {
|
||||
margin: 1mm 0 !important;
|
||||
}
|
||||
|
||||
.print-smaller-font {
|
||||
font-size: 8pt !important;
|
||||
}
|
||||
|
||||
.header-print {
|
||||
background-color: #1e40af !important; /* blue-800 */
|
||||
color: #ffffff !important;
|
||||
padding: 2mm 3mm !important;
|
||||
margin-bottom: 2mm !important;
|
||||
}
|
||||
|
||||
/* More compact header */
|
||||
.header-print h1 {
|
||||
font-size: 12pt !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.header-print p {
|
||||
font-size: 9pt !important;
|
||||
margin-top: 1mm !important;
|
||||
}
|
||||
|
||||
/* Footer stays at bottom */
|
||||
.footer-print {
|
||||
font-size: 7pt !important;
|
||||
color: #6b7280 !important; /* gray-500 */
|
||||
border-top: 1px solid #e5e7eb !important; /* gray-200 */
|
||||
position: fixed !important;
|
||||
bottom: 10mm !important;
|
||||
left: 15mm !important;
|
||||
right: 15mm !important;
|
||||
padding-top: 2mm !important;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Hide any unnecessary elements */
|
||||
.print-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Optimize description text */
|
||||
.print-description {
|
||||
max-height: 100px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Wait a bit to ensure styles are applied
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
|
||||
// Remove the style after printing dialog closes
|
||||
setTimeout(() => {
|
||||
const printStyle = document.getElementById('print-style');
|
||||
if (printStyle) {
|
||||
printStyle.remove();
|
||||
}
|
||||
}, 1000);
|
||||
}, 100);
|
||||
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('printJobStarted'),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error printing:", error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('printingFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { handlePrint };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
export * from './MaintenanceDetailDialog';
|
||||
export * from './MaintenanceDetailContent';
|
||||
export * from './MaintenanceDetailHeader';
|
||||
export * from './MaintenanceDetailSections';
|
||||
export * from './MaintenanceDetailFooter';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||
import { useMaintenanceEditForm } from '../hooks/useMaintenanceEditForm';
|
||||
import {
|
||||
MaintenanceBasicFields,
|
||||
MaintenanceTimeFields,
|
||||
MaintenanceAffectedFields,
|
||||
MaintenanceConfigFields,
|
||||
MaintenanceNotificationSettingsField
|
||||
} from '../form';
|
||||
|
||||
interface EditMaintenanceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
maintenance: MaintenanceItem;
|
||||
onMaintenanceUpdated: () => void;
|
||||
}
|
||||
|
||||
export const EditMaintenanceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
maintenance,
|
||||
onMaintenanceUpdated
|
||||
}: EditMaintenanceDialogProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleSuccess = () => {
|
||||
console.log("EditMaintenanceDialog: maintenance updated successfully");
|
||||
onMaintenanceUpdated();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
console.log("EditMaintenanceDialog: closing dialog");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useMaintenanceEditForm(maintenance, handleSuccess, handleClose);
|
||||
|
||||
// Log the form state for debugging
|
||||
React.useEffect(() => {
|
||||
const subscription = form.watch((value) => {
|
||||
console.log("Form values in edit dialog:", value);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('editMaintenanceWindow')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('editMaintenanceDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<MaintenanceBasicFields />
|
||||
<MaintenanceTimeFields />
|
||||
<MaintenanceAffectedFields />
|
||||
<MaintenanceConfigFields />
|
||||
<MaintenanceNotificationSettingsField />
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? t('updating') : t('updateMaintenance')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export * from './EditMaintenanceDialog';
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export const MaintenanceAffectedFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { control } = useFormContext<MaintenanceFormValues>();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="affected"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('affectedServices')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('enterAffectedServices')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-sm text-muted-foreground">{t('separateServicesWithComma')}</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export const MaintenanceBasicFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { control } = useFormContext<MaintenanceFormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('enterTitle')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('enterDescription')}
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
PriorityField,
|
||||
StatusField,
|
||||
ImpactLevelField,
|
||||
AssignedUsersField
|
||||
} from './config';
|
||||
|
||||
export const MaintenanceConfigFields = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm font-medium">{t('configurationSettings')}</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<PriorityField />
|
||||
<StatusField />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<ImpactLevelField />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 mt-4">
|
||||
<AssignedUsersField />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { alertConfigService, AlertConfiguration } from '@/services/alertConfigService';
|
||||
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from '@/components/ui/form';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Bell, BellOff } from 'lucide-react';
|
||||
|
||||
export const MaintenanceNotificationSettingsField = () => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [notificationChannels, setNotificationChannels] = useState<AlertConfiguration[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { control, watch, setValue, getValues } = useFormContext<MaintenanceFormValues>();
|
||||
|
||||
// Watch the notification toggle state
|
||||
const notifySubscribers = watch('notify_subscribers');
|
||||
const notificationChannelId = watch('notification_channel_id');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNotificationChannels = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const channels = await alertConfigService.getAlertConfigurations();
|
||||
console.log("Fetched notification channels for form:", channels);
|
||||
|
||||
// Only show enabled channels
|
||||
const enabledChannels = channels.filter(channel => channel.enabled);
|
||||
setNotificationChannels(enabledChannels);
|
||||
|
||||
// If we have notification channels and notification is enabled but no channel is selected,
|
||||
// select the first one by default
|
||||
const currentChannel = getValues('notification_channel_id');
|
||||
const shouldNotify = getValues('notify_subscribers');
|
||||
|
||||
console.log("Current notification values:", {
|
||||
currentChannel,
|
||||
shouldNotify,
|
||||
availableChannels: enabledChannels.length
|
||||
});
|
||||
|
||||
if (shouldNotify && (!currentChannel || currentChannel === 'none') && enabledChannels.length > 0) {
|
||||
console.log("Setting default notification channel:", enabledChannels[0].id);
|
||||
setValue('notification_channel_id', enabledChannels[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification channels:', error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorFetchingNotificationChannels'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNotificationChannels();
|
||||
}, [t, toast, setValue, getValues]);
|
||||
|
||||
// Log value changes for debugging
|
||||
useEffect(() => {
|
||||
console.log("Current notification settings:", {
|
||||
channel_id: getValues('notification_channel_id'),
|
||||
notify: notifySubscribers
|
||||
});
|
||||
}, [notifySubscribers, notificationChannelId, getValues]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-medium text-lg">{t('notificationSettings')}</h3>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="notify_subscribers"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{field.value ? <Bell className="w-4 h-4" /> : <BellOff className="w-4 h-4" />}
|
||||
<FormLabel className="text-base mb-0">
|
||||
{field.value ? t('notifySubscribers') : t('mutedNotifications')}
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{field.value
|
||||
? t('notifySubscribersDesc')
|
||||
: t('notificationsAreMuted')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
console.log("Notification toggle changed to:", checked);
|
||||
// If notifications are disabled, also clear the notification channel
|
||||
if (!checked) {
|
||||
setValue('notification_channel_id', '');
|
||||
} else if (notificationChannels.length > 0) {
|
||||
// If enabled and channels available, select the first one by default
|
||||
setValue('notification_channel_id', notificationChannels[0].id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="notification_channel_id"
|
||||
render={({ field }) => {
|
||||
// Make sure to handle both empty string and "none" as special cases
|
||||
const displayValue = field.value || "";
|
||||
|
||||
console.log("Rendering notification channel field with value:", {
|
||||
fieldValue: field.value,
|
||||
displayValue
|
||||
});
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t('notificationChannel')}</FormLabel>
|
||||
<FormControl>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={(value) => {
|
||||
console.log("Setting notification channel to:", value);
|
||||
field.onChange(value === "none" ? "" : value);
|
||||
}}
|
||||
disabled={!notifySubscribers}
|
||||
>
|
||||
<SelectTrigger className={!notifySubscribers ? 'opacity-50' : ''}>
|
||||
<SelectValue placeholder={t('selectNotificationChannel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t('none')}
|
||||
</SelectItem>
|
||||
{notificationChannels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.notify_name} ({channel.notification_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{notifySubscribers
|
||||
? t('selectChannelForNotifications')
|
||||
: t('enableNotificationsFirst')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export const MaintenanceTimeFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { control } = useFormContext<MaintenanceFormValues>();
|
||||
|
||||
// Helper function to safely format dates
|
||||
const safeFormat = (date: Date | null | undefined, formatStr: string): string => {
|
||||
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return format(date, formatStr);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="start_time"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t('startTime')}</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value && field.value instanceof Date && !isNaN(field.value.getTime()) ? (
|
||||
safeFormat(field.value, "PPP HH:mm")
|
||||
) : (
|
||||
<span>{t('selectDate')}</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value instanceof Date && !isNaN(field.value.getTime()) ? field.value : undefined}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
// If existing value is a valid date, preserve the time part
|
||||
if (field.value instanceof Date && !isNaN(field.value.getTime())) {
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(field.value.getHours(), field.value.getMinutes());
|
||||
field.onChange(newDate);
|
||||
} else {
|
||||
// Set default time to current time if no valid time exists
|
||||
const now = new Date();
|
||||
date.setHours(now.getHours(), now.getMinutes());
|
||||
field.onChange(date);
|
||||
}
|
||||
} else {
|
||||
field.onChange(undefined);
|
||||
}
|
||||
}}
|
||||
initialFocus
|
||||
className={cn("p-3 pointer-events-auto")}
|
||||
/>
|
||||
<div className="p-3 border-t border-border">
|
||||
<Input
|
||||
type="time"
|
||||
onChange={(e) => {
|
||||
const timeValue = e.target.value;
|
||||
if (!timeValue) return;
|
||||
|
||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||
const date = new Date(field.value || new Date());
|
||||
|
||||
// Ensure we have a valid date before setting hours/minutes
|
||||
if (!isNaN(date.getTime())) {
|
||||
date.setHours(hours, minutes);
|
||||
field.onChange(date);
|
||||
}
|
||||
}}
|
||||
value={field.value instanceof Date && !isNaN(field.value.getTime())
|
||||
? safeFormat(field.value, "HH:mm")
|
||||
: ""}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="end_time"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t('endTime')}</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value && field.value instanceof Date && !isNaN(field.value.getTime()) ? (
|
||||
safeFormat(field.value, "PPP HH:mm")
|
||||
) : (
|
||||
<span>{t('selectDate')}</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value instanceof Date && !isNaN(field.value.getTime()) ? field.value : undefined}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
// If existing value is a valid date, preserve the time part
|
||||
if (field.value instanceof Date && !isNaN(field.value.getTime())) {
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(field.value.getHours(), field.value.getMinutes());
|
||||
field.onChange(newDate);
|
||||
} else {
|
||||
// Set default time to current time if no valid time exists
|
||||
const now = new Date();
|
||||
// Default to current time + 1 hour for end time
|
||||
date.setHours(now.getHours() + 1, now.getMinutes());
|
||||
field.onChange(date);
|
||||
}
|
||||
} else {
|
||||
field.onChange(undefined);
|
||||
}
|
||||
}}
|
||||
initialFocus
|
||||
className={cn("p-3 pointer-events-auto")}
|
||||
/>
|
||||
<div className="p-3 border-t border-border">
|
||||
<Input
|
||||
type="time"
|
||||
onChange={(e) => {
|
||||
const timeValue = e.target.value;
|
||||
if (!timeValue) return;
|
||||
|
||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||
const date = new Date(field.value || new Date());
|
||||
|
||||
// Ensure we have a valid date before setting hours/minutes
|
||||
if (!isNaN(date.getTime())) {
|
||||
date.setHours(hours, minutes);
|
||||
field.onChange(date);
|
||||
}
|
||||
}}
|
||||
value={field.value instanceof Date && !isNaN(field.value.getTime())
|
||||
? safeFormat(field.value, "HH:mm")
|
||||
: ""}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { userService } from '@/services/userService';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export const AssignedUsersField = () => {
|
||||
const { t } = useLanguage();
|
||||
const form = useFormContext();
|
||||
|
||||
// Ensure assigned_users is initialized as an array
|
||||
useEffect(() => {
|
||||
const currentValue = form.getValues('assigned_users');
|
||||
console.log("Initial assigned_users value:", currentValue);
|
||||
|
||||
// Initialize as empty array if no value or invalid value
|
||||
if (!currentValue || !Array.isArray(currentValue)) {
|
||||
console.log("Initializing assigned_users as empty array");
|
||||
form.setValue('assigned_users', [], { shouldValidate: false, shouldDirty: true });
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
console.log("Current form values:", form.getValues());
|
||||
console.log("Current assigned_users:", form.getValues('assigned_users'));
|
||||
|
||||
// Fetch users for the assignment dropdown
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const usersList = await userService.getUsers();
|
||||
console.log("Fetched users for assignment:", usersList);
|
||||
return Array.isArray(usersList) ? usersList : [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 300000 // Cache for 5 minutes to reduce API calls
|
||||
});
|
||||
|
||||
// Get the currently selected assigned users from the form
|
||||
const selectedUserIds = Array.isArray(form.watch('assigned_users'))
|
||||
? form.watch('assigned_users')
|
||||
: [];
|
||||
|
||||
console.log("Selected user IDs:", selectedUserIds);
|
||||
|
||||
// Function to add a user
|
||||
const addUser = (userId: string) => {
|
||||
const currentValues = Array.isArray(form.getValues('assigned_users'))
|
||||
? [...form.getValues('assigned_users')]
|
||||
: [];
|
||||
|
||||
if (!currentValues.includes(userId)) {
|
||||
console.log("Adding user:", userId);
|
||||
form.setValue('assigned_users', [...currentValues, userId], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Function to remove a user
|
||||
const removeUser = (userId: string) => {
|
||||
const currentValues = Array.isArray(form.getValues('assigned_users'))
|
||||
? [...form.getValues('assigned_users')]
|
||||
: [];
|
||||
|
||||
console.log("Removing user:", userId);
|
||||
form.setValue(
|
||||
'assigned_users',
|
||||
currentValues.filter(id => id !== userId),
|
||||
{ shouldValidate: true, shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Get selected users data
|
||||
const selectedUsers = users.filter(user => selectedUserIds.includes(user.id));
|
||||
console.log("Matched selected users:", selectedUsers);
|
||||
|
||||
// Function to get user initials from name
|
||||
const getUserInitials = (user: any): string => {
|
||||
if (user.full_name) {
|
||||
const nameParts = user.full_name.split(' ');
|
||||
if (nameParts.length > 1) {
|
||||
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||
}
|
||||
return user.full_name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return user.username.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assigned_users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" /> {t('assignedPersonnel')}
|
||||
</FormLabel>
|
||||
<div className="space-y-3">
|
||||
<Select onValueChange={addUser}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('selectAssignedUsers')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{isLoading ? (
|
||||
<div className="py-2 px-2 text-sm">{t('loading')}</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="py-2 px-2 text-sm">{t('noUsersFound')}</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
value={user.id}
|
||||
disabled={selectedUserIds.includes(user.id)}
|
||||
>
|
||||
{user.full_name || user.username}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedUsers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
|
||||
{selectedUsers.map((user) => (
|
||||
<Badge key={user.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
|
||||
<Avatar className="h-5 w-5 mr-1">
|
||||
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getUserInitials(user)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{user.full_name || user.username}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
|
||||
onClick={() => removeUser(user.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">{t('remove')}</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic p-2">
|
||||
{t('noAssignedUsers')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
{t('assignedPersonnelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export const ImpactLevelField = () => {
|
||||
const { t } = useLanguage();
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('impactLevel')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectImpactLevel')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('none')}</SelectItem>
|
||||
<SelectItem value="minor">{t('minor')}</SelectItem>
|
||||
<SelectItem value="major">{t('major')}</SelectItem>
|
||||
<SelectItem value="critical">{t('critical')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('impactLevelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export const PriorityField = () => {
|
||||
const { t } = useLanguage();
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('priority')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectPriority')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{t('low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('high')}</SelectItem>
|
||||
<SelectItem value="critical">{t('critical')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('priorityDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export const StatusField = () => {
|
||||
const { t } = useLanguage();
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('status')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectStatus')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">{t('scheduled')}</SelectItem>
|
||||
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
|
||||
<SelectItem value="completed">{t('completed')}</SelectItem>
|
||||
<SelectItem value="cancelled">{t('cancelled')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('statusDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
export * from './PriorityField';
|
||||
export * from './StatusField';
|
||||
export * from './ImpactLevelField';
|
||||
export * from './AssignedUsersField';
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
export * from './MaintenanceBasicFields';
|
||||
export * from './MaintenanceTimeFields';
|
||||
export * from './MaintenanceAffectedFields';
|
||||
export * from './MaintenanceConfigFields';
|
||||
export * from './MaintenanceNotificationSettingsField';
|
||||
export * from './config';
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { maintenanceService, MaintenanceItem } from '@/services/maintenance';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { maintenanceFormSchema, MaintenanceFormValues } from './useMaintenanceForm';
|
||||
|
||||
export const useMaintenanceEditForm = (
|
||||
maintenance: MaintenanceItem,
|
||||
onSuccess: () => void,
|
||||
onClose: () => void
|
||||
) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Parse assigned_users to ensure it's an array
|
||||
const getAssignedUsers = (): string[] => {
|
||||
console.log("Original assigned_users:", maintenance.assigned_users);
|
||||
|
||||
if (!maintenance.assigned_users) {
|
||||
console.log("No assigned users, returning empty array");
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(maintenance.assigned_users)) {
|
||||
console.log("Assigned users is already an array:", maintenance.assigned_users);
|
||||
return maintenance.assigned_users;
|
||||
}
|
||||
|
||||
if (typeof maintenance.assigned_users === 'string') {
|
||||
// Try to parse as JSON first (for array stored as string)
|
||||
try {
|
||||
const parsedData = JSON.parse(maintenance.assigned_users);
|
||||
if (Array.isArray(parsedData)) {
|
||||
console.log("Parsed assigned_users from JSON string:", parsedData);
|
||||
return parsedData;
|
||||
} else if (typeof parsedData === 'string') {
|
||||
// Handle nested JSON strings
|
||||
try {
|
||||
const nestedData = JSON.parse(parsedData);
|
||||
if (Array.isArray(nestedData)) {
|
||||
console.log("Parsed assigned_users from nested JSON string:", nestedData);
|
||||
return nestedData;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a nested JSON string, continue
|
||||
}
|
||||
|
||||
// Single ID in a JSON string
|
||||
console.log("Using parsed string as single ID:", [parsedData]);
|
||||
return [parsedData];
|
||||
} else {
|
||||
// Some other type, convert to string and use as single ID
|
||||
return [String(parsedData)];
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, try comma splitting
|
||||
if (maintenance.assigned_users.includes(',')) {
|
||||
const userArray = maintenance.assigned_users.split(',').map(id => id.trim()).filter(Boolean);
|
||||
console.log("Converted comma-separated string to array:", userArray);
|
||||
return userArray;
|
||||
} else {
|
||||
// Single ID as string
|
||||
console.log("Using string as single ID:", [maintenance.assigned_users]);
|
||||
return [maintenance.assigned_users];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn("Unable to parse assigned_users, returning empty array");
|
||||
return [];
|
||||
};
|
||||
|
||||
// Clean up user IDs by removing quotes, brackets, etc.
|
||||
const cleanUserIds = (userIds: string[]): string[] => {
|
||||
return userIds.map(id => {
|
||||
// Remove surrounding quotes if present
|
||||
let cleanId = id.replace(/^["']|["']$/g, '');
|
||||
// Remove any remaining JSON artifacts
|
||||
cleanId = cleanId.replace(/[\[\]"'\\]/g, '');
|
||||
return cleanId;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
// Get notification channel ID (from either notification_channel_id or notification_id)
|
||||
const getNotificationChannelId = (): string => {
|
||||
console.log("Getting notification channel ID from:", {
|
||||
notification_channel_id: maintenance.notification_channel_id,
|
||||
notification_id: maintenance.notification_id,
|
||||
notify_subscribers: maintenance.notify_subscribers
|
||||
});
|
||||
|
||||
// Try notification_channel_id first
|
||||
if (maintenance.notification_channel_id) {
|
||||
console.log("Using notification_channel_id:", maintenance.notification_channel_id);
|
||||
return maintenance.notification_channel_id;
|
||||
}
|
||||
// Fall back to notification_id if notifications are enabled
|
||||
else if (maintenance.notification_id && maintenance.notify_subscribers === 'yes') {
|
||||
console.log("Using notification_id:", maintenance.notification_id);
|
||||
return maintenance.notification_id;
|
||||
}
|
||||
|
||||
console.log("No notification channel ID found");
|
||||
return '';
|
||||
};
|
||||
|
||||
// Initialize form with existing maintenance data
|
||||
const form = useForm<MaintenanceFormValues>({
|
||||
resolver: zodResolver(maintenanceFormSchema),
|
||||
defaultValues: {
|
||||
title: maintenance.title || '',
|
||||
description: maintenance.description || '',
|
||||
start_time: maintenance.start_time ? new Date(maintenance.start_time) : new Date(),
|
||||
end_time: maintenance.end_time ? new Date(maintenance.end_time) : new Date(),
|
||||
affected: maintenance.affected || '',
|
||||
priority: (maintenance.priority?.toLowerCase() || 'medium') as any,
|
||||
status: (maintenance.status?.toLowerCase() || 'scheduled') as any,
|
||||
field: (maintenance.field?.toLowerCase() || 'minor') as any,
|
||||
assigned_users: cleanUserIds(getAssignedUsers()),
|
||||
notify_subscribers: maintenance.notify_subscribers === 'yes',
|
||||
notification_channel_id: getNotificationChannelId(),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: MaintenanceFormValues) => {
|
||||
try {
|
||||
console.log("Form data before submission:", data);
|
||||
console.log("Assigned users to be submitted:", data.assigned_users);
|
||||
console.log("Notification channel to be submitted:", data.notification_channel_id);
|
||||
|
||||
// Verify dates are valid
|
||||
if (!data.start_time || isNaN(data.start_time.getTime())) {
|
||||
throw new Error("Invalid start time");
|
||||
}
|
||||
|
||||
if (!data.end_time || isNaN(data.end_time.getTime())) {
|
||||
throw new Error("Invalid end time");
|
||||
}
|
||||
|
||||
// Format the update payload
|
||||
const updateData = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
start_time: data.start_time.toISOString(),
|
||||
end_time: data.end_time.toISOString(),
|
||||
affected: data.affected,
|
||||
priority: data.priority,
|
||||
status: data.status,
|
||||
field: data.field,
|
||||
assigned_users: data.assigned_users || [], // Send as array
|
||||
notify_subscribers: data.notify_subscribers ? 'yes' : 'no',
|
||||
notification_channel_id: data.notify_subscribers && data.notification_channel_id && data.notification_channel_id !== 'none'
|
||||
? data.notification_channel_id
|
||||
: '',
|
||||
// Set notification_id to match notification_channel_id when notifications are enabled
|
||||
notification_id: data.notify_subscribers && data.notification_channel_id && data.notification_channel_id !== 'none'
|
||||
? data.notification_channel_id
|
||||
: '',
|
||||
};
|
||||
|
||||
console.log("Updating maintenance with data:", updateData);
|
||||
console.log("Assigned users being sent for update:", updateData.assigned_users);
|
||||
console.log("Notification channel being sent for update:", updateData.notification_channel_id);
|
||||
console.log("Notification ID being sent for update:", updateData.notification_id);
|
||||
|
||||
// Update the maintenance record using our service function
|
||||
await maintenanceService.updateMaintenance(maintenance.id, updateData);
|
||||
|
||||
toast({
|
||||
title: t('maintenanceUpdated'),
|
||||
description: t('maintenanceUpdatedDesc'),
|
||||
});
|
||||
|
||||
onClose();
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Error updating maintenance:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: `${t('errorUpdatingMaintenance')}: ${error.message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorUpdatingMaintenance'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { maintenanceService } from '@/services/maintenance';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { authService } from '@/services/authService';
|
||||
|
||||
// Define form schema with Zod
|
||||
export const maintenanceFormSchema = z.object({
|
||||
title: z.string().min(3, { message: "Title must be at least 3 characters" }),
|
||||
description: z.string().min(10, { message: "Description must be at least 10 characters" }),
|
||||
start_time: z.date(),
|
||||
end_time: z.date(),
|
||||
affected: z.string().min(3, { message: "Affected services must be specified" }),
|
||||
priority: z.enum(['low', 'medium', 'high']),
|
||||
status: z.enum(['scheduled', 'in_progress', 'completed', 'cancelled']),
|
||||
field: z.enum(['minor', 'moderate', 'major']),
|
||||
assigned_users: z.array(z.string()).default([]),
|
||||
notify_subscribers: z.boolean().default(false),
|
||||
notification_channel_id: z.string().optional(),
|
||||
});
|
||||
|
||||
// Infer TypeScript type from schema
|
||||
export type MaintenanceFormValues = z.infer<typeof maintenanceFormSchema>;
|
||||
|
||||
export const useMaintenanceForm = (
|
||||
onSuccess: () => void,
|
||||
onClose: () => void
|
||||
) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Get current user
|
||||
const currentUser = authService.getCurrentUser();
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<MaintenanceFormValues>({
|
||||
resolver: zodResolver(maintenanceFormSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
start_time: new Date(),
|
||||
end_time: new Date(Date.now() + 3600000), // 1 hour from now
|
||||
affected: '',
|
||||
priority: 'medium',
|
||||
status: 'scheduled',
|
||||
field: 'minor',
|
||||
assigned_users: [],
|
||||
notify_subscribers: false,
|
||||
notification_channel_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: MaintenanceFormValues) => {
|
||||
try {
|
||||
console.log("Form data before submission:", data);
|
||||
console.log("Assigned users before submission:", data.assigned_users);
|
||||
console.log("Notification channel before submission:", data.notification_channel_id);
|
||||
|
||||
// Verify dates are valid
|
||||
if (!data.start_time || isNaN(data.start_time.getTime())) {
|
||||
throw new Error("Invalid start time");
|
||||
}
|
||||
|
||||
if (!data.end_time || isNaN(data.end_time.getTime())) {
|
||||
throw new Error("Invalid end time");
|
||||
}
|
||||
|
||||
// Format the submission payload
|
||||
const formattedData = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
start_time: data.start_time.toISOString(),
|
||||
end_time: data.end_time.toISOString(),
|
||||
affected: data.affected,
|
||||
priority: data.priority,
|
||||
status: data.status,
|
||||
field: data.field,
|
||||
created_by: currentUser?.id || '',
|
||||
// Ensure assigned_users is properly formatted as an array
|
||||
assigned_users: Array.isArray(data.assigned_users) ? data.assigned_users : [],
|
||||
notify_subscribers: data.notify_subscribers ? 'yes' : 'no',
|
||||
// Set notification_channel_id correctly - avoid "none" value
|
||||
notification_channel_id: data.notify_subscribers && data.notification_channel_id && data.notification_channel_id !== 'none'
|
||||
? data.notification_channel_id
|
||||
: '',
|
||||
// Set notification_id to match notification_channel_id for consistency
|
||||
notification_id: data.notify_subscribers && data.notification_channel_id && data.notification_channel_id !== 'none'
|
||||
? data.notification_channel_id
|
||||
: '',
|
||||
};
|
||||
|
||||
console.log("Submitting maintenance with data:", formattedData);
|
||||
console.log("Assigned users being sent:", formattedData.assigned_users);
|
||||
console.log("Notification channel being sent:", formattedData.notification_channel_id);
|
||||
console.log("Notification ID being sent:", formattedData.notification_id);
|
||||
|
||||
// Create the maintenance record
|
||||
await maintenanceService.createMaintenance(formattedData);
|
||||
|
||||
toast({
|
||||
title: t('maintenanceCreated'),
|
||||
description: t('maintenanceCreatedDesc'),
|
||||
});
|
||||
|
||||
onClose();
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Error creating maintenance:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: `${t('errorCreatingMaintenance')}: ${error.message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorCreatingMaintenance'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
// Export all maintenance components
|
||||
export * from './MaintenanceTable';
|
||||
export * from './MaintenanceStatusBadge';
|
||||
export * from './MaintenanceActionsMenu';
|
||||
export * from './EmptyMaintenanceState';
|
||||
export * from './CreateMaintenanceDialog';
|
||||
export * from './MaintenanceStatusDropdown';
|
||||
export * from './MaintenanceStatusChecker';
|
||||
export * from './detail-dialog';
|
||||
|
||||
@@ -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