feat: Implement Schedule Maintenance Management and Tab Dashboard

This commit is contained in:
Tola Leng
2025-05-23 20:34:18 +08:00
parent a7a1c9a144
commit 395a580032
51 changed files with 3962 additions and 0 deletions
@@ -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;
};
@@ -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}
/>
</>
);
};
@@ -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}
/>
</>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,4 @@
export * from './DownloadPdfButton';
export * from './PrintButton';
export * from './CloseButton';
@@ -0,0 +1,3 @@
export * from './useDownloadMaintenancePdf';
export * from './usePrintMaintenance';
@@ -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 };
};
@@ -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';
@@ -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';
@@ -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>
)}
/>
);
};
@@ -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>
)}
/>
</>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
)}
/>
);
};
@@ -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>
)}
/>
);
};
@@ -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';
@@ -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';