diff --git a/application/src/components/schedule-incident/IncidentManagementTab.tsx b/application/src/components/schedule-incident/IncidentManagementTab.tsx new file mode 100644 index 0000000..59bed3d --- /dev/null +++ b/application/src/components/schedule-incident/IncidentManagementTab.tsx @@ -0,0 +1,13 @@ + +import React from 'react'; +import { IncidentManagementContainer } from './incident-management'; + +interface IncidentManagementTabProps { + refreshTrigger?: number; +} + +export const IncidentManagementTab = React.memo(({ refreshTrigger = 0 }: IncidentManagementTabProps) => { + return ; +}); + +IncidentManagementTab.displayName = 'IncidentManagementTab'; diff --git a/application/src/components/schedule-incident/ScheduleIncidentContent.tsx b/application/src/components/schedule-incident/ScheduleIncidentContent.tsx new file mode 100644 index 0000000..a3b8422 --- /dev/null +++ b/application/src/components/schedule-incident/ScheduleIncidentContent.tsx @@ -0,0 +1,126 @@ + +import React, { useState, useEffect } from 'react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Plus, CalendarClock, AlertCircle } from "lucide-react"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab"; +import { IncidentManagementTab } from "./IncidentManagementTab"; +import { CreateMaintenanceDialog } from './maintenance/CreateMaintenanceDialog'; +import { CreateIncidentDialog } from './incident/CreateIncidentDialog'; +import { useToast } from '@/hooks/use-toast'; +import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService'; + +export const ScheduleIncidentContent = () => { + const { t } = useLanguage(); + const { toast } = useToast(); + const [activeTab, setActiveTab] = useState("maintenance"); + const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false); + const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0); + + // Initialize maintenance notifications when the component mounts + useEffect(() => { + console.log("Initializing maintenance notifications"); + initMaintenanceNotifications(); + + // Clean up when the component unmounts + return () => { + console.log("Cleaning up maintenance notifications"); + stopMaintenanceNotifications(); + }; + }, []); + + const handleCreateButtonClick = () => { + if (activeTab === "maintenance") { + setCreateMaintenanceDialogOpen(true); + } else { + setCreateIncidentDialogOpen(true); + } + }; + + const handleMaintenanceCreated = () => { + // Refresh data by incrementing the refresh trigger + const newTriggerValue = refreshTrigger + 1; + console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue); + setRefreshTrigger(newTriggerValue); + + // Show success toast + toast({ + title: t('success'), + description: t('maintenanceCreatedSuccess'), + }); + }; + + const handleIncidentCreated = () => { + // Refresh data by incrementing the refresh trigger + const newTriggerValue = incidentRefreshTrigger + 1; + console.log("Incident created, refreshing data with new trigger value:", newTriggerValue); + setIncidentRefreshTrigger(newTriggerValue); + + // Show success toast + toast({ + title: t('success'), + description: t('incidentCreatedSuccess'), + }); + }; + + return ( +
+
+
+

+ {t('scheduleIncidentManagement')} +

+ +
+ + setActiveTab(value)} + > + + + + {t('scheduledMaintenance')} + + + + {t('incidentManagement')} + + + + + + + + + + + +
+ + {/* Maintenance creation dialog */} + + + {/* Incident creation dialog */} + +
+ ); +}; diff --git a/application/src/components/schedule-incident/ScheduledMaintenanceTab.tsx b/application/src/components/schedule-incident/ScheduledMaintenanceTab.tsx new file mode 100644 index 0000000..da82c1a --- /dev/null +++ b/application/src/components/schedule-incident/ScheduledMaintenanceTab.tsx @@ -0,0 +1,160 @@ + +import React, { useEffect } from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance'; +import { EmptyMaintenanceState } from './maintenance/EmptyMaintenanceState'; +import { OverviewCard } from './common/OverviewCard'; +import { Calendar, Clock, CheckCircle } from 'lucide-react'; +import { useMaintenanceData } from './hooks/useMaintenanceData'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useToast } from '@/hooks/use-toast'; +import { LoadingState } from '@/components/services/LoadingState'; + +interface ScheduledMaintenanceTabProps { + refreshTrigger?: number; +} + +export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => { + const { t } = useLanguage(); + const { toast } = useToast(); + const [manualRefresh, setManualRefresh] = React.useState(0); + + // Combine the external refresh trigger with our internal one + const combinedRefreshTrigger = refreshTrigger + manualRefresh; + + const { + loading, + filter, + setFilter, + maintenanceData, + overviewStats, + fetchMaintenanceData, + isEmpty, + error, + initialized + } = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger }); + + // Display toast when error occurs + useEffect(() => { + if (error) { + toast({ + title: t('error'), + description: error, + variant: 'destructive', + }); + } + }, [error, toast, t]); + + // Force fetch data on initial load + useEffect(() => { + console.log("ScheduledMaintenanceTab is mounted, fetching data"); + fetchMaintenanceData(true); + }, [fetchMaintenanceData]); + + // Handle maintenance updates + const handleMaintenanceUpdated = () => { + console.log("Maintenance updated, refreshing data"); + setManualRefresh(prev => prev + 1); + }; + + // Handle tab changes + const handleTabChange = (value: string) => { + setFilter(value); + }; + + // Show full-page loading state during initial load + if (loading && !initialized) { + return ; + } + + return ( + <> + {/* Status checker for automatic updates */} + + + {/* Overview Cards */} +
+ } + isLoading={loading} + color="blue" + /> + } + isLoading={loading} + color="amber" + /> + } + isLoading={loading} + color="green" + /> + } + isLoading={loading} + color="purple" + /> +
+ + + + {t('scheduledMaintenance')} + + {t('scheduledMaintenanceDesc')} + + + + + + {t('upcomingMaintenance')} + {t('ongoingMaintenance')} + {t('completedMaintenance')} + + + + + + + + + + + + + + + + + + ); +}; diff --git a/application/src/components/schedule-incident/common/OverviewCard.tsx b/application/src/components/schedule-incident/common/OverviewCard.tsx new file mode 100644 index 0000000..6c96010 --- /dev/null +++ b/application/src/components/schedule-incident/common/OverviewCard.tsx @@ -0,0 +1,112 @@ + +import React, { ReactNode } from 'react'; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTheme } from "@/contexts/ThemeContext"; + +interface OverviewCardProps { + title: string; + value: string | number; + description?: string; + icon: ReactNode; + trend?: { + value: number; + isPositive: boolean; + }; + className?: string; + valueClassName?: string; + isLoading?: boolean; + color?: string; +} + +export const OverviewCard = ({ + title, + value, + description, + icon, + trend, + className, + valueClassName, + isLoading = false, + color = "blue", +}: OverviewCardProps) => { + const { theme } = useTheme(); + + // Map color prop to gradient colors + const getGradientBackground = () => { + const colors = { + blue: theme === 'dark' + ? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)" + : "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)", + green: theme === 'dark' + ? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)" + : "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)", + amber: theme === 'dark' + ? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)" + : "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)", + red: theme === 'dark' + ? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)" + : "linear-gradient(135deg, #e53935 0%, #ef5350 100%)", + purple: theme === 'dark' + ? "linear-gradient(135deg, rgba(123, 31, 162, 0.8) 0%, rgba(156, 39, 176, 0.6) 100%)" + : "linear-gradient(135deg, #7b1fa2 0%, #9c27b0 100%)", + orange: theme === 'dark' + ? "linear-gradient(135deg, rgba(230, 81, 0, 0.8) 0%, rgba(255, 109, 0, 0.6) 100%)" + : "linear-gradient(135deg, #e65100 0%, #ff6d00 100%)", + }; + + return colors[color as keyof typeof colors] || colors.blue; + }; + + return ( + + {/* Grid Pattern Overlay */} +
+
+
+ + +
+
+

{title}

+ {isLoading ? ( + + ) : ( +

{value}

+ )} + {description && ( +

{description}

+ )} + {trend && ( +
+ + {trend.isPositive ? '↑' : '↓'} + + {Math.abs(trend.value)}% + vs last month +
+ )} +
+
+ {icon} +
+
+
+
+ ); +}; diff --git a/application/src/components/schedule-incident/hooks/index.ts b/application/src/components/schedule-incident/hooks/index.ts new file mode 100644 index 0000000..eb66169 --- /dev/null +++ b/application/src/components/schedule-incident/hooks/index.ts @@ -0,0 +1,3 @@ + +export * from './useMaintenanceData'; +export * from './useIncidentData'; diff --git a/application/src/components/schedule-incident/hooks/useIncidentData.ts b/application/src/components/schedule-incident/hooks/useIncidentData.ts new file mode 100644 index 0000000..d6b32cb --- /dev/null +++ b/application/src/components/schedule-incident/hooks/useIncidentData.ts @@ -0,0 +1,200 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { incidentService, IncidentItem } from '@/services/incident'; + +interface UseIncidentDataProps { + refreshTrigger?: number; +} + +export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) => { + const [incidents, setIncidents] = useState([]); + const [filter, setFilter] = useState("unresolved"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Use a ref to prevent multiple simultaneous fetch requests + const isFetchingRef = useRef(false); + // Use a ref to track the refresh trigger + const lastRefreshTriggerRef = useRef(refreshTrigger); + + // Simplified fetch function with improved controls to prevent duplicate calls + const fetchIncidentData = useCallback(async (force = false) => { + // Skip if already fetching + if (isFetchingRef.current) { + console.log('Already fetching data, skipping additional request'); + return; + } + + // Skip if not forced and already initialized + if (initialized && !force) { + console.log('Data already initialized and no force refresh, skipping fetch'); + return; + } + + // Set fetching flags + isFetchingRef.current = true; + + if (!initialized || force) { + setLoading(true); + } + + if (force) { + setIsRefreshing(true); + } + + setError(null); + + try { + console.log(`Fetching incident data (force=${force})`); + const allIncidents = await incidentService.getAllIncidents(force); + + if (Array.isArray(allIncidents)) { + setIncidents(allIncidents); + console.log(`Successfully set ${allIncidents.length} incidents to state`); + } else { + setIncidents([]); + console.warn('No incidents returned from service'); + } + + setInitialized(true); + setLoading(false); + setIsRefreshing(false); + } catch (error) { + console.error('Error fetching incident data:', error); + setError('Failed to load incident data. Please try again later.'); + setIncidents([]); + setInitialized(true); + setLoading(false); + setIsRefreshing(false); + } finally { + // Reset fetching flag after a slight delay to prevent rapid consecutive calls + setTimeout(() => { + isFetchingRef.current = false; + }, 500); + } + }, [initialized]); + + // Only fetch when component mounts or refreshTrigger changes + useEffect(() => { + // Skip if the refresh trigger hasn't changed (prevents duplicate effect calls) + if (refreshTrigger === lastRefreshTriggerRef.current && initialized) { + console.log('Refresh trigger unchanged, skipping fetch'); + return; + } + + // Update last refresh trigger ref + lastRefreshTriggerRef.current = refreshTrigger; + + // Create an abort controller for cleanup + const abortController = new AbortController(); + let isMounted = true; + + console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`); + + // Use a longer delay to ensure we don't trigger too many API calls + const fetchTimer = setTimeout(() => { + if (isMounted) { + fetchIncidentData(refreshTrigger > 0); + } + }, 500); // Use a longer delay + + // Cleanup function to abort any in-flight requests and clear timers + return () => { + console.log('Cleaning up incident data fetch effect'); + isMounted = false; + clearTimeout(fetchTimer); + abortController.abort(); + }; + }, [fetchIncidentData, refreshTrigger, initialized]); + + // Filter the data based on the current filter + const incidentData = useMemo(() => { + if (!initialized || incidents.length === 0) return []; + + console.log(`Filtering incidents by: ${filter}`); + + if (filter === "unresolved") { + return incidents.filter(item => { + const status = (item.status || item.impact_status || '').toLowerCase(); + return status !== 'resolved'; + }); + } else if (filter === "resolved") { + return incidents.filter(item => { + const status = (item.status || item.impact_status || '').toLowerCase(); + return status === 'resolved'; + }); + } + + return []; + }, [filter, incidents, initialized]); + + // Calculate stats for overview cards + const overviewStats = useMemo(() => { + if (!initialized || incidents.length === 0) { + return { + unresolved: 0, + resolved: 0, + critical: 0, + highPriority: 0, + avgResolutionTime: "0h" + }; + } + + const unresolvedIncidents = incidents.filter(item => { + const status = (item.status || item.impact_status || '').toLowerCase(); + return status !== 'resolved'; + }); + + const resolvedIncidents = incidents.filter(item => { + const status = (item.status || item.impact_status || '').toLowerCase(); + return status === 'resolved'; + }); + + const criticalCount = unresolvedIncidents.filter(item => + (item.priority?.toLowerCase() === 'critical') || + (item.impact?.toLowerCase() === 'critical') + ).length; + + const highPriorityCount = unresolvedIncidents.filter(item => + (item.priority?.toLowerCase() === 'high') + ).length; + + let avgResolutionTime = "0h"; + if (resolvedIncidents.length > 0) { + const totalHours = resolvedIncidents.reduce((total, item) => { + if (!item.created || !item.resolution_time) return total; + + const createdAt = new Date(item.created).getTime(); + const resolvedAt = new Date(item.resolution_time).getTime(); + const durationHours = (resolvedAt - createdAt) / (1000 * 60 * 60); + return isNaN(durationHours) ? total : total + durationHours; + }, 0); + + avgResolutionTime = `${(totalHours / resolvedIncidents.length).toFixed(1)}h`; + } + + return { + unresolved: unresolvedIncidents.length, + resolved: resolvedIncidents.length, + critical: criticalCount, + highPriority: highPriorityCount, + avgResolutionTime + }; + }, [incidents, initialized]); + + const isEmpty = !incidentData.length && initialized && !loading; + + return { + filter, + setFilter, + incidentData, + overviewStats, + fetchIncidentData, + isEmpty, + error, + loading, + initialized, + isRefreshing + }; +}; diff --git a/application/src/components/schedule-incident/hooks/useMaintenanceData.ts b/application/src/components/schedule-incident/hooks/useMaintenanceData.ts new file mode 100644 index 0000000..3af4626 --- /dev/null +++ b/application/src/components/schedule-incident/hooks/useMaintenanceData.ts @@ -0,0 +1,173 @@ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { maintenanceService, MaintenanceItem } from '@/services/maintenance'; +import { useToast } from '@/hooks/use-toast'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface UseMaintenanceDataProps { + refreshTrigger?: number; +} + +export const useMaintenanceData = ({ refreshTrigger = 0 }: UseMaintenanceDataProps) => { + const { t } = useLanguage(); + const { toast } = useToast(); + const [loading, setLoading] = useState(true); + const [allMaintenanceData, setAllMaintenanceData] = useState([]); + const [filter, setFilter] = useState("upcoming"); + const [error, setError] = useState(null); + const [initialized, setInitialized] = useState(false); + const [lastFetchTime, setLastFetchTime] = useState(0); + + // Memoize categorized data to prevent unnecessary recalculations + const categorizedData = useMemo(() => { + if (!allMaintenanceData.length) return { upcoming: [], ongoing: [], completed: [] }; + + const currentDate = new Date(); + const upcoming: MaintenanceItem[] = []; + const ongoing: MaintenanceItem[] = []; + const completed: MaintenanceItem[] = []; + + allMaintenanceData.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 }; + }, [allMaintenanceData]); + + // Optimized fetch function with improved debouncing + const fetchMaintenanceData = useCallback(async (force = false) => { + // Prevent excessive fetching with improved debounce logic + const now = Date.now(); + if (!force && now - lastFetchTime < 10000 && lastFetchTime > 0 && initialized) { + console.log("Skipping fetch - recently fetched (within 10s)"); + return; + } + + // Only show loading state for initial load, not refreshes + if (!initialized) { + setLoading(true); + } + + setError(null); + setLastFetchTime(now); + + try { + console.log("Fetching maintenance data from service..."); + const data = await maintenanceService.getMaintenanceRecords(); + console.log("Fetched maintenance data, count:", data.length); + + // Log a sample of the first item's assigned_users for debugging + if (data.length > 0) { + console.log("Sample maintenance item assigned_users:", data[0].id, data[0].assigned_users); + } + + // Update state with fetched data + setAllMaintenanceData(data); + setInitialized(true); + } catch (error) { + console.error('Error fetching maintenance data:', error); + setError('Failed to load maintenance data'); + toast({ + title: t('error'), + description: t('errorFetchingMaintenanceData'), + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }, [t, toast, lastFetchTime, initialized]); + + // Initial fetch on mount with proper cleanup + useEffect(() => { + console.log("useMaintenanceData hook mounted, fetching data"); + let isMounted = true; + + const fetchData = async () => { + try { + await fetchMaintenanceData(true); + } catch (err) { + console.error("Error in initial fetch:", err); + } + }; + + fetchData(); + + // Set up polling with longer interval (5 minutes instead of 3) + const intervalId = setInterval(() => { + if (isMounted) fetchMaintenanceData(false); + }, 300000); // 5 minutes + + return () => { + isMounted = false; + clearInterval(intervalId); + }; + }, [fetchMaintenanceData]); + + // Handle refresh trigger changes + useEffect(() => { + if (refreshTrigger > 0) { + console.log("Refresh trigger changed, forcing data fetch"); + fetchMaintenanceData(true); + } + }, [refreshTrigger, fetchMaintenanceData]); + + // Get filtered data based on current tab + const maintenanceData = useMemo(() => { + if (!initialized) return []; + return categorizedData[filter as keyof typeof categorizedData] || []; + }, [filter, categorizedData, initialized]); + + // Calculate overview stats with memoization + const overviewStats = useMemo(() => { + const { upcoming, ongoing, completed } = categorizedData; + + // Calculate total hours more efficiently + const calculateTotalHours = (items: MaintenanceItem[]) => { + return items.reduce((total, item) => { + try { + const start = new Date(item.start_time).getTime(); + const end = new Date(item.end_time).getTime(); + const durationHours = (end - start) / (1000 * 60 * 60); + return total + (isNaN(durationHours) ? 0 : durationHours); + } catch (e) { + return total; + } + }, 0).toFixed(1); + }; + + return { + upcoming: upcoming.length, + ongoing: ongoing.length, + completed: completed.length, + totalDuration: calculateTotalHours([...upcoming, ...ongoing]), + }; + }, [categorizedData]); + + const isEmpty = !loading && maintenanceData.length === 0 && initialized; + + return { + loading, + filter, + setFilter, + maintenanceData, + overviewStats, + fetchMaintenanceData, + isEmpty, + error, + initialized, + }; +}; diff --git a/application/src/components/schedule-incident/incident-management/ErrorState.tsx b/application/src/components/schedule-incident/incident-management/ErrorState.tsx new file mode 100644 index 0000000..0292164 --- /dev/null +++ b/application/src/components/schedule-incident/incident-management/ErrorState.tsx @@ -0,0 +1,37 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { Button } from '@/components/ui/button'; +import { RefreshCw } from 'lucide-react'; + +interface ErrorStateProps { + error: string | null; + onRefresh: () => void; + isRefreshing: boolean; +} + +export const ErrorState: React.FC = ({ + error, + onRefresh, + isRefreshing +}) => { + const { t } = useLanguage(); + + if (!error) return null; + + return ( +
+

{error}

+ +
+ ); +}; diff --git a/application/src/components/schedule-incident/incident-management/HeaderActions.tsx b/application/src/components/schedule-incident/incident-management/HeaderActions.tsx new file mode 100644 index 0000000..fa1e9df --- /dev/null +++ b/application/src/components/schedule-incident/incident-management/HeaderActions.tsx @@ -0,0 +1,37 @@ + +import React from 'react'; +import { CardDescription, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { RefreshCw } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface HeaderActionsProps { + onRefresh: () => void; + isRefreshing: boolean; +} + +export const HeaderActions: React.FC = ({ onRefresh, isRefreshing }) => { + const { t } = useLanguage(); + + return ( +
+
+ {t('incidentManagement')} + + {t('incidentsManagementDesc')} + +
+ +
+ ); +}; diff --git a/application/src/components/schedule-incident/incident-management/IncidentManagementContainer.tsx b/application/src/components/schedule-incident/incident-management/IncidentManagementContainer.tsx new file mode 100644 index 0000000..bca2878 --- /dev/null +++ b/application/src/components/schedule-incident/incident-management/IncidentManagementContainer.tsx @@ -0,0 +1,171 @@ + +import React, { useState, useCallback, useRef } from 'react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { useIncidentData } from '../hooks/useIncidentData'; +import { IncidentItem } from '@/services/incident'; +import { useToast } from '@/hooks/use-toast'; +import { OverviewCards } from './OverviewCards'; +import { HeaderActions } from './HeaderActions'; +import { TabContent } from './TabContent'; +import { LoadingState } from '@/components/services/LoadingState'; +import { IncidentDetailDialog } from '../incident/detail-dialog/IncidentDetailDialog'; + +interface IncidentManagementContainerProps { + refreshTrigger?: number; +} + +export const IncidentManagementContainer: React.FC = ({ + refreshTrigger = 0 +}) => { + const { t } = useLanguage(); + const { toast } = useToast(); + const [selectedIncident, setSelectedIncident] = useState(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [manualRefresh, setManualRefresh] = useState(0); + + // Use a ref to debounce multiple refresh requests + const refreshTimerRef = useRef(null); + + // Combine the external refresh trigger with our internal one + const combinedRefreshTrigger = refreshTrigger + manualRefresh; + + const { + filter, + setFilter, + incidentData, + overviewStats, + isEmpty, + loading, + error, + initialized, + isRefreshing + } = useIncidentData({ refreshTrigger: combinedRefreshTrigger }); + + // Handle incident updates with debouncing + const handleIncidentUpdated = useCallback(() => { + console.log('Incident updated, triggering refresh'); + + // Clear any existing timer + if (refreshTimerRef.current !== null) { + window.clearTimeout(refreshTimerRef.current); + } + + // Set a new timer to debounce multiple quick updates + refreshTimerRef.current = window.setTimeout(() => { + setManualRefresh(prev => prev + 1); + refreshTimerRef.current = null; + + toast({ + title: t('incidentUpdated'), + description: t('incidentUpdateSuccess'), + }); + }, 300); + + }, [t, toast]); + + // Handle tab changes + const handleTabChange = useCallback((value: string) => { + console.log(`Tab changed to: ${value}`); + setFilter(value); + }, [setFilter]); + + // Handle view incident details + const handleViewIncidentDetails = useCallback((incident: IncidentItem) => { + setSelectedIncident(incident); + setDetailDialogOpen(true); + }, []); + + // Handle manual refresh + const handleManualRefresh = useCallback(() => { + console.log('Manual refresh triggered by user'); + setManualRefresh(prev => prev + 1); + }, []); + + // Handle detail dialog close with refresh + const handleDetailDialogClose = useCallback((open: boolean) => { + setDetailDialogOpen(open); + if (!open) { + // When dialog closes, refresh data + handleManualRefresh(); + } + }, [handleManualRefresh]); + + // Clean up timer on unmount + React.useEffect(() => { + return () => { + if (refreshTimerRef.current !== null) { + window.clearTimeout(refreshTimerRef.current); + } + }; + }, []); + + // Show full-page loading state during initial load + if (loading && !initialized) { + return ; + } + + return ( + <> + {/* Overview Cards */} + + + + + + + + + + {t('unresolvedIncidents')} + {t('resolvedIncidents')} + + + + + + + + + + + + + + {/* Incident Detail Dialog */} + + + ); +}; diff --git a/application/src/components/schedule-incident/incident-management/OverviewCards.tsx b/application/src/components/schedule-incident/incident-management/OverviewCards.tsx new file mode 100644 index 0000000..454c21d --- /dev/null +++ b/application/src/components/schedule-incident/incident-management/OverviewCards.tsx @@ -0,0 +1,67 @@ + +import React from 'react'; +import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { OverviewCard } from '../common/OverviewCard'; + +interface OverviewStatsProps { + unresolved: number; + resolved: number; + critical: number; + highPriority: number; + avgResolutionTime: string; +} + +interface OverviewCardsProps { + overviewStats: OverviewStatsProps; + loading: boolean; + initialized: boolean; +} + +export const OverviewCards: React.FC = ({ + overviewStats, + loading, + initialized +}) => { + const { t } = useLanguage(); + + return ( +
+ } + isLoading={loading && initialized} + color="red" + /> + } + isLoading={loading && initialized} + color="amber" + /> + } + isLoading={loading && initialized} + color="orange" + /> + } + isLoading={loading && initialized} + color="green" + /> + } + isLoading={loading && initialized} + color="blue" + /> +
+ ); +}; diff --git a/application/src/components/schedule-incident/incident-management/TabContent.tsx b/application/src/components/schedule-incident/incident-management/TabContent.tsx new file mode 100644 index 0000000..a457694 --- /dev/null +++ b/application/src/components/schedule-incident/incident-management/TabContent.tsx @@ -0,0 +1,47 @@ + +import React from 'react'; +import { IncidentTable } from '../incident/table/IncidentTable'; +import { EmptyIncidentState } from '../incident/EmptyIncidentState'; +import { ErrorState } from './ErrorState'; +import { IncidentItem } from '@/services/incident'; + +interface TabContentProps { + error: string | null; + isEmpty: boolean; + data: IncidentItem[]; + loading: boolean; + initialized: boolean; + isRefreshing: boolean; + onIncidentUpdated: () => void; + onViewDetails: (incident: IncidentItem) => void; + onRefresh: () => void; +} + +export const TabContent: React.FC = ({ + error, + isEmpty, + data, + loading, + initialized, + isRefreshing, + onIncidentUpdated, + onViewDetails, + onRefresh +}) => { + if (error) { + return ; + } + + if (isEmpty) { + return ; + } + + return ( + + ); +}; diff --git a/application/src/components/schedule-incident/incident-management/index.ts b/application/src/components/schedule-incident/incident-management/index.ts new file mode 100644 index 0000000..4ad0804 --- /dev/null +++ b/application/src/components/schedule-incident/incident-management/index.ts @@ -0,0 +1,6 @@ + +export * from './IncidentManagementContainer'; +export * from './OverviewCards'; +export * from './TabContent'; +export * from './ErrorState'; +export * from './HeaderActions'; diff --git a/application/src/components/schedule-incident/incident/CreateIncidentDialog.tsx b/application/src/components/schedule-incident/incident/CreateIncidentDialog.tsx new file mode 100644 index 0000000..08dbf1c --- /dev/null +++ b/application/src/components/schedule-incident/incident/CreateIncidentDialog.tsx @@ -0,0 +1,96 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Form } from '@/components/ui/form'; +import { useIncidentForm } from './hooks/useIncidentForm'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + IncidentBasicFields, + IncidentAffectedFields, + IncidentConfigFields, + IncidentDetailsFields, +} from './form'; + +interface CreateIncidentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onIncidentCreated: () => void; +} + +export const CreateIncidentDialog: React.FC = ({ + open, + onOpenChange, + onIncidentCreated, +}) => { + const { t } = useLanguage(); + const { form, onSubmit } = useIncidentForm( + onIncidentCreated, + () => onOpenChange(false) + ); + + return ( + + + +
+ + {t('createIncident')} + + {t('createIncidentDesc')} + + + +
+ +
+
+

{t('basicInfo')}

+ +
+ +
+

{t('affectedSystems')}

+ +
+ +
+

{t('configuration')}

+ +
+ +
+

{t('resolutionDetails')}

+ +
+ + + + + +
+
+ +
+
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/EditIncidentDialog.tsx b/application/src/components/schedule-incident/incident/EditIncidentDialog.tsx new file mode 100644 index 0000000..4ea6a4a --- /dev/null +++ b/application/src/components/schedule-incident/incident/EditIncidentDialog.tsx @@ -0,0 +1,105 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Form } from '@/components/ui/form'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useIncidentEditForm } from './hooks/useIncidentEditForm'; +import { + IncidentBasicFields, + IncidentAffectedFields, + IncidentConfigFields, + IncidentDetailsFields, +} from './form'; +import { IncidentItem } from '@/services/incident/types'; + +interface EditIncidentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + incident: IncidentItem; + onIncidentUpdated: () => void; +} + +export const EditIncidentDialog: React.FC = ({ + open, + onOpenChange, + incident, + onIncidentUpdated, +}) => { + const { t } = useLanguage(); + + const handleClose = () => { + onOpenChange(false); + }; + + const { form, onSubmit } = useIncidentEditForm( + incident, + onIncidentUpdated, + handleClose + ); + + return ( + + + +
+ + {t('editIncident')} + + {t('editIncidentDesc')} + + + +
+ +
+
+

{t('basicInfo')}

+ +
+ +
+

{t('affectedSystems')}

+ +
+ +
+

{t('configuration')}

+ +
+ +
+

{t('resolutionDetails')}

+ +
+ + + + + +
+
+ +
+
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/EmptyIncidentState.tsx b/application/src/components/schedule-incident/incident/EmptyIncidentState.tsx new file mode 100644 index 0000000..25aefa0 --- /dev/null +++ b/application/src/components/schedule-incident/incident/EmptyIncidentState.tsx @@ -0,0 +1,18 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { AlertCircle } from 'lucide-react'; + +export const EmptyIncidentState = () => { + const { t } = useLanguage(); + + return ( +
+ +

{t('noIncidents')}

+

+ {t('noServices')} +

+
+ ); +}; diff --git a/application/src/components/schedule-incident/incident/IncidentActionsMenu.tsx b/application/src/components/schedule-incident/incident/IncidentActionsMenu.tsx new file mode 100644 index 0000000..37b6615 --- /dev/null +++ b/application/src/components/schedule-incident/incident/IncidentActionsMenu.tsx @@ -0,0 +1,114 @@ + +import React 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, Check } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { updateIncidentStatus, deleteIncident } from '@/services/incident/incidentOperations'; +import { IncidentItem } from '@/services/incident/types'; + +interface IncidentActionsMenuProps { + item: IncidentItem; + onIncidentUpdated: () => void; + onViewDetails?: (incident: IncidentItem) => void; + onEditIncident?: (incident: IncidentItem) => void; +} + +export const IncidentActionsMenu = ({ + item, + onIncidentUpdated, + onViewDetails, + onEditIncident +}: IncidentActionsMenuProps) => { + const { t } = useLanguage(); + const { toast } = useToast(); + + const handleResolveIncident = async () => { + try { + await updateIncidentStatus(item.id, 'resolved'); + toast({ + title: t('success'), + description: t('incidentResolved'), + }); + onIncidentUpdated(); + } catch (error) { + console.error('Error resolving incident:', error); + toast({ + title: t('error'), + description: t('errorResolvingIncident'), + variant: 'destructive', + }); + } + }; + + const handleDeleteIncident = async () => { + try { + await deleteIncident(item.id); + toast({ + title: t('success'), + description: t('incidentDeleted'), + }); + onIncidentUpdated(); + } catch (error) { + console.error('Error deleting incident:', error); + toast({ + title: t('error'), + description: t('errorDeletingIncident'), + variant: 'destructive', + }); + } + }; + + const handleEditClick = () => { + if (onEditIncident) { + onEditIncident(item); + } else { + console.log(`Edit incident ${item.id}`); + } + }; + + return ( + + + + + + {t('actions')} + + {onViewDetails && ( + onViewDetails(item)}> + + {t('view')} + + )} + + + {t('edit')} + + + + {t('resolve')} + + + + + {t('delete')} + + + + ); +}; diff --git a/application/src/components/schedule-incident/incident/IncidentStatusBadge.tsx b/application/src/components/schedule-incident/incident/IncidentStatusBadge.tsx new file mode 100644 index 0000000..3e76f66 --- /dev/null +++ b/application/src/components/schedule-incident/incident/IncidentStatusBadge.tsx @@ -0,0 +1,105 @@ + +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { + AlertCircle, + CheckCircle, + Gauge, + Search, + Wrench, + LucideIcon +} from 'lucide-react'; + +interface IncidentStatusBadgeProps { + status: string; +} + +type StatusConfig = { + label: string; + variant: 'outline' | 'default' | 'secondary' | 'destructive'; + icon: LucideIcon; + className?: string; +} + +export const IncidentStatusBadge = ({ status }: IncidentStatusBadgeProps) => { + const { t } = useLanguage(); + + // Normalize the status string + const normalizedStatus = (status || '').toLowerCase(); + + // Status configuration map + const statusConfigs: Record = { + 'investigating': { + label: t('investigating'), + variant: 'destructive', + icon: Search, + className: 'bg-red-100 border-red-200 text-red-700 hover:bg-red-100', + }, + 'identified': { + label: t('identified'), + variant: 'secondary', + icon: AlertCircle, + className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100', + }, + 'found_root_cause': { + label: t('rootCauseFound'), + variant: 'secondary', + icon: AlertCircle, + className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100', + }, + 'completed': { + label: t('completed'), + variant: 'default', + icon: CheckCircle, + className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100', + }, + 'in_progress': { + label: t('inProgress'), + variant: 'default', + icon: Wrench, + className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100', + }, + 'inprogress': { + label: t('inProgress'), + variant: 'default', + icon: Wrench, + className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100', + }, + 'monitoring': { + label: t('monitoring'), + variant: 'outline', + icon: Gauge, + className: 'bg-purple-100 border-purple-200 text-purple-700 hover:bg-purple-100', + }, + 'resolved': { + label: t('resolved'), + variant: 'default', + icon: CheckCircle, + className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100', + } + }; + + // Find the appropriate config, defaulting to investigating if not found + const getStatusConfig = (): StatusConfig => { + for (const [key, config] of Object.entries(statusConfigs)) { + if (normalizedStatus.includes(key)) { + return config; + } + } + return statusConfigs['investigating']; + }; + + const config = getStatusConfig(); + const Icon = config.icon; + + return ( + + + {config.label} + + ); +}; diff --git a/application/src/components/schedule-incident/incident/IncidentStatusDropdown.tsx b/application/src/components/schedule-incident/incident/IncidentStatusDropdown.tsx new file mode 100644 index 0000000..661e23a --- /dev/null +++ b/application/src/components/schedule-incident/incident/IncidentStatusDropdown.tsx @@ -0,0 +1,105 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { AlertCircle, CheckCircle, Gauge, Search, Wrench } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { updateIncidentStatus } from '@/services/incident/incidentOperations'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { IncidentStatusBadge } from './IncidentStatusBadge'; + +interface IncidentStatusDropdownProps { + status: string; + id: string; + onStatusUpdated: () => void; + disabled?: boolean; +} + +export const IncidentStatusDropdown = ({ + status, + id, + onStatusUpdated, + disabled = false +}: IncidentStatusDropdownProps) => { + const { t } = useLanguage(); + const { toast } = useToast(); + const [localStatus, setLocalStatus] = React.useState(status); + + // Update local status when prop changes + React.useEffect(() => { + setLocalStatus(status); + }, [status]); + + const statusOptions = [ + { value: 'investigating', label: t('investigating'), icon: }, + { value: 'identified', label: t('identified'), icon: }, + { value: 'found_root_cause', label: t('foundRootCause'), icon: }, + { value: 'in_progress', label: t('inProgress'), icon: }, + { value: 'monitoring', label: t('monitoring'), icon: }, + { value: 'resolved', label: t('resolved'), icon: }, + ]; + + const handleStatusChange = async (newStatus: string) => { + try { + // Don't update if the status is the same + if (localStatus === newStatus) { + return; + } + + console.log(`Changing incident status from ${localStatus} to ${newStatus}`); + + // Optimistically update the UI immediately + setLocalStatus(newStatus); + + // Make the API call in the background + await updateIncidentStatus(id, newStatus); + + toast({ + title: t('statusUpdated'), + description: t('incidentStatusUpdated'), + }); + + // Notify parent components about the status change + onStatusUpdated(); + console.log('Status update complete, UI refresh triggered'); + } catch (error) { + console.error('Error updating incident status:', error); + + // Revert to the original status on error + setLocalStatus(status); + + toast({ + title: t('error'), + description: t('failedToUpdateStatus'), + variant: 'destructive', + }); + } + }; + + return ( + + + + + + {statusOptions.map((option) => ( + { + e.stopPropagation(); // Prevent event from bubbling to table row click + handleStatusChange(option.value); + }} + > + {option.icon} + {option.label} + + ))} + + + ); +}; diff --git a/application/src/components/schedule-incident/incident/IncidentTable.tsx b/application/src/components/schedule-incident/incident/IncidentTable.tsx new file mode 100644 index 0000000..5512abc --- /dev/null +++ b/application/src/components/schedule-incident/incident/IncidentTable.tsx @@ -0,0 +1,3 @@ + +// Re-export the refactored IncidentTable component +export { IncidentTable } from './table/IncidentTable'; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailContent.tsx b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailContent.tsx new file mode 100644 index 0000000..7fe8737 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailContent.tsx @@ -0,0 +1,76 @@ + +import React from 'react'; +import { IncidentItem } from '@/services/incident/types'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { IncidentDetailHeader } from './IncidentDetailHeader'; +import { Separator } from '@/components/ui/separator'; +import { + BasicInfoSection, + TimelineSection, + AffectedSystemsSection, + ResolutionSection +} from './sections'; +import { IncidentDetailFooter } from './IncidentDetailFooter'; +import { useQuery } from '@tanstack/react-query'; +import { userService } from '@/services/userService'; + +interface IncidentDetailContentProps { + incident: IncidentItem; + onClose: () => void; + assignedUser: any | null; +} + +export const IncidentDetailContent = ({ + incident, + onClose, + assignedUser +}: IncidentDetailContentProps) => { + // Fetch assigned user details if one wasn't provided and there's an assigned_to field + const { data: fetchedUser } = useQuery({ + queryKey: ['user', incident?.assigned_to], + queryFn: async () => { + if (!incident?.assigned_to) return null; + try { + return await userService.getUser(incident.assigned_to); + } catch (error) { + console.error("Failed to fetch assigned user:", error); + return null; + } + }, + enabled: !!incident?.assigned_to && !assignedUser, + staleTime: 300000 // Cache for 5 minutes + }); + + // Use the provided assignedUser or the one we fetched + const userToDisplay = assignedUser || fetchedUser; + + return ( + +
+
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailDialog.tsx b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailDialog.tsx new file mode 100644 index 0000000..e0d26bf --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailDialog.tsx @@ -0,0 +1,54 @@ + +import React from 'react'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { IncidentItem } from '@/services/incident/types'; +import { useQuery } from '@tanstack/react-query'; +import { userService } from '@/services/userService'; +import { IncidentDetailContent } from './IncidentDetailContent'; + +interface IncidentDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + incident: IncidentItem | null; +} + +export const IncidentDetailDialog = ({ + open, + onOpenChange, + incident +}: IncidentDetailDialogProps) => { + // Fetch user data for assigned_to field + const { data: users = [] } = useQuery({ + queryKey: ['users'], + queryFn: async () => { + const usersList = await userService.getUsers(); + return Array.isArray(usersList) ? usersList : []; + }, + staleTime: 300000, // Cache for 5 minutes + enabled: !!incident?.assigned_to && open // Only run query if there's an assigned_to value and dialog is open + }); + + // Find the assigned user + const assignedUser = incident?.assigned_to + ? users.find(user => user.id === incident.assigned_to) + : null; + + if (!incident) return null; + + return ( + + + onOpenChange(false)} + assignedUser={assignedUser} + /> + + + ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailFooter.tsx b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailFooter.tsx new file mode 100644 index 0000000..e2bbcce --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailFooter.tsx @@ -0,0 +1,31 @@ + +import React from 'react'; +import { IncidentItem } from '@/services/incident'; +import { Separator } from '@/components/ui/separator'; +import { CloseButton, DownloadPdfButton, PrintButton } from './components'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface IncidentDetailFooterProps { + onClose: () => void; + incident: IncidentItem; +} + +export const IncidentDetailFooter: React.FC = ({ + onClose, + incident, +}) => { + const { t } = useLanguage(); + + return ( +
+ +
+ +
+ + +
+
+
+ ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailHeader.tsx b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailHeader.tsx new file mode 100644 index 0000000..ee34373 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailHeader.tsx @@ -0,0 +1,19 @@ + +import React from 'react'; +import { DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { IncidentItem } from '@/services/incident/types'; + +interface IncidentDetailHeaderProps { + incident: IncidentItem; +} + +export const IncidentDetailHeader = ({ incident }: IncidentDetailHeaderProps) => { + return ( + +
+ {incident.title || 'Incident Details'} + #{incident.id} +
+
+ ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailSections.tsx b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailSections.tsx new file mode 100644 index 0000000..b33f15e --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/IncidentDetailSections.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; +import { Separator } from '@/components/ui/separator'; +import { useQuery } from '@tanstack/react-query'; +import { userService } from '@/services/userService'; + +// Import all section components from the new location +import { + BasicInfoSection, + TimelineSection, + AffectedSystemsSection, + ResolutionSection +} from './sections'; + +// Re-export all section components for compatibility with existing imports +export { + BasicInfoSection, + TimelineSection, + AffectedSystemsSection, + ResolutionSection +}; + +// Legacy component - keeping this for backward compatibility with other imports +export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => { + // Fetch assigned user details if there's an assigned_to field + const { data: assignedUser } = useQuery({ + queryKey: ['user', incident?.assigned_to], + queryFn: async () => { + if (!incident?.assigned_to) return null; + try { + return await userService.getUser(incident.assigned_to); + } catch (error) { + console.error("Failed to fetch assigned user:", error); + return null; + } + }, + enabled: !!incident?.assigned_to, + staleTime: 300000 // Cache for 5 minutes + }); + + if (!incident) return null; + + return ( +
+ + + + + + + +
+ ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/components/CloseButton.tsx b/application/src/components/schedule-incident/incident/detail-dialog/components/CloseButton.tsx new file mode 100644 index 0000000..bec23d4 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/components/CloseButton.tsx @@ -0,0 +1,26 @@ + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface CloseButtonProps { + onClose: () => void; + className?: string; +} + +export const CloseButton: React.FC = ({ + onClose, + className +}) => { + const { t } = useLanguage(); + + return ( + + ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/components/DownloadPdfButton.tsx b/application/src/components/schedule-incident/incident/detail-dialog/components/DownloadPdfButton.tsx new file mode 100644 index 0000000..d3b3ebe --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/components/DownloadPdfButton.tsx @@ -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 { useDownloadIncidentPdf } from '../hooks'; +import { IncidentItem } from '@/services/incident'; + +interface DownloadPdfButtonProps { + incident: IncidentItem; + className?: string; +} + +export const DownloadPdfButton: React.FC = ({ + incident, + className +}) => { + const { t } = useLanguage(); + const { handleDownloadPDF } = useDownloadIncidentPdf(); + + return ( + + ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/components/PrintButton.tsx b/application/src/components/schedule-incident/incident/detail-dialog/components/PrintButton.tsx new file mode 100644 index 0000000..27aa1b4 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/components/PrintButton.tsx @@ -0,0 +1,26 @@ + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Printer } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { usePrintIncident } from '../hooks'; + +interface PrintButtonProps { + className?: string; +} + +export const PrintButton: React.FC = ({ className }) => { + const { t } = useLanguage(); + const { handlePrint } = usePrintIncident(); + + return ( + + ); +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/components/index.ts b/application/src/components/schedule-incident/incident/detail-dialog/components/index.ts new file mode 100644 index 0000000..b66885a --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/components/index.ts @@ -0,0 +1,4 @@ + +export * from './PrintButton'; +export * from './DownloadPdfButton'; +export * from './CloseButton'; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/hooks/index.ts b/application/src/components/schedule-incident/incident/detail-dialog/hooks/index.ts new file mode 100644 index 0000000..2862893 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/hooks/index.ts @@ -0,0 +1,3 @@ + +export * from './usePrintIncident'; +export * from './useDownloadIncidentPdf'; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/hooks/useDownloadIncidentPdf.ts b/application/src/components/schedule-incident/incident/detail-dialog/hooks/useDownloadIncidentPdf.ts new file mode 100644 index 0000000..8762384 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/hooks/useDownloadIncidentPdf.ts @@ -0,0 +1,29 @@ + +import { useToast } from '@/hooks/use-toast'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem, incidentService } from '@/services/incident'; + +export const useDownloadIncidentPdf = () => { + const { toast } = useToast(); + const { t } = useLanguage(); + + const handleDownloadPDF = async (incident: IncidentItem) => { + try { + await incidentService.generateIncidentPDF(incident); + + toast({ + title: t('success'), + description: t('pdfDownloaded'), + }); + } catch (error) { + console.error("Error generating PDF:", error); + toast({ + title: t('error'), + description: t('pdfGenerationFailed'), + variant: 'destructive', + }); + } + }; + + return { handleDownloadPDF }; +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/hooks/usePrintIncident.ts b/application/src/components/schedule-incident/incident/detail-dialog/hooks/usePrintIncident.ts new file mode 100644 index 0000000..f9554dd --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/hooks/usePrintIncident.ts @@ -0,0 +1,173 @@ + +import React from 'react'; +import { useToast } from '@/hooks/use-toast'; +import { useLanguage } from '@/contexts/LanguageContext'; + +export const usePrintIncident = () => { + const { toast } = useToast(); + const { t } = useLanguage(); + + const handlePrint = React.useCallback(() => { + 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', + }); + } + }, [t, toast]); + + return { handlePrint }; +}; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/index.ts b/application/src/components/schedule-incident/incident/detail-dialog/index.ts new file mode 100644 index 0000000..6d33ea6 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/index.ts @@ -0,0 +1,6 @@ + +export * from './IncidentDetailDialog'; +export * from './IncidentDetailHeader'; +export * from './IncidentDetailContent'; +export * from './IncidentDetailSections'; +export * from './IncidentDetailFooter'; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/AffectedSystemsSection.tsx b/application/src/components/schedule-incident/incident/detail-dialog/sections/AffectedSystemsSection.tsx new file mode 100644 index 0000000..a029b94 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/AffectedSystemsSection.tsx @@ -0,0 +1,66 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; +import { Badge } from '@/components/ui/badge'; +import { getAffectedSystemsArray } from './utils'; + +interface AffectedSystemsSectionProps { + incident: IncidentItem | null; + assignedUser?: any | null; +} + +export const AffectedSystemsSection: React.FC = ({ incident }) => { + const { t } = useLanguage(); + + if (!incident) return null; + + return ( +
+

{t('affectedSystems')}

+ +
+
+

{t('systems')}

+
+ {getAffectedSystemsArray(incident.affected_systems).map((system, idx) => ( + {system} + ))} + {getAffectedSystemsArray(incident.affected_systems).length === 0 && + {t('noSystems')} + } +
+
+ +
+
+

{t('impact')}

+
+ + {t(incident.impact?.toLowerCase() || 'low')} + +
+
+ +
+

{t('priority')}

+
+ + {t(incident.priority?.toLowerCase() || 'low')} + +
+
+
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/AssignmentSection.tsx b/application/src/components/schedule-incident/incident/detail-dialog/sections/AssignmentSection.tsx new file mode 100644 index 0000000..2b41230 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/AssignmentSection.tsx @@ -0,0 +1,44 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { User } from '@/services/userService'; +import { getUserInitials } from './utils'; + +interface AssignmentSectionProps { + incident: IncidentItem | null; + assignedUser?: User | null; +} + +export const AssignmentSection: React.FC = ({ incident, assignedUser }) => { + const { t } = useLanguage(); + + if (!incident) return null; + + return ( +
+

{t('assignment')}

+ +
+

{t('assignedTo')}

+
+ {assignedUser ? ( +
+ + + {getUserInitials(assignedUser)} + + {assignedUser.full_name || assignedUser.username} +
+ ) : incident.assigned_to ? ( + {incident.assigned_to} + ) : ( + {t('unassigned')} + )} +
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/BasicInfoSection.tsx b/application/src/components/schedule-incident/incident/detail-dialog/sections/BasicInfoSection.tsx new file mode 100644 index 0000000..e343a2d --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/BasicInfoSection.tsx @@ -0,0 +1,69 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; +import { User } from '@/services/userService'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { IncidentStatusBadge } from '../../IncidentStatusBadge'; +import { getUserInitials } from './utils'; + +interface BasicInfoSectionProps { + incident: IncidentItem | null; + assignedUser?: User | null; +} + +export const BasicInfoSection: React.FC = ({ incident, assignedUser }) => { + const { t } = useLanguage(); + + if (!incident) return null; + + return ( +
+

{t('basicInfo')}

+ +
+
+

{t('title')}

+

{incident.title || '-'}

+
+ +
+

{t('status')}

+
+ +
+
+ +
+

{t('serviceId')}

+

{incident.service_id || '-'}

+
+ +
+

{t('assignedTo')}

+
+ {assignedUser ? ( +
+ + + {getUserInitials(assignedUser)} + + {assignedUser.full_name || assignedUser.username} +
+ ) : incident.assigned_to ? ( + {incident.assigned_to} + ) : ( + {t('unassigned')} + )} +
+
+
+ +
+

{t('description')}

+

{incident.description || '-'}

+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/ImpactAnalysisSection.tsx b/application/src/components/schedule-incident/incident/detail-dialog/sections/ImpactAnalysisSection.tsx new file mode 100644 index 0000000..2e9fe90 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/ImpactAnalysisSection.tsx @@ -0,0 +1,51 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; +import { Badge } from '@/components/ui/badge'; + +interface ImpactAnalysisSectionProps { + incident: IncidentItem | null; + assignedUser?: any | null; +} + +export const ImpactAnalysisSection: React.FC = ({ incident }) => { + const { t } = useLanguage(); + + if (!incident) return null; + + return ( +
+

{t('impactAnalysis')}

+ +
+
+

{t('impact')}

+
+ + {t(incident.impact?.toLowerCase() || 'low')} + +
+
+ +
+

{t('priority')}

+
+ + {t(incident.priority?.toLowerCase() || 'low')} + +
+
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/ResolutionSection.tsx b/application/src/components/schedule-incident/incident/detail-dialog/sections/ResolutionSection.tsx new file mode 100644 index 0000000..7f94059 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/ResolutionSection.tsx @@ -0,0 +1,39 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; + +interface ResolutionSectionProps { + incident: IncidentItem | null; + assignedUser?: any | null; +} + +export const ResolutionSection: React.FC = ({ incident }) => { + const { t } = useLanguage(); + + if (!incident) return null; + + return ( +
+

{t('resolutionDetails')}

+ +
+
+

{t('rootCause')}

+

{incident.root_cause || '-'}

+
+ +
+

{t('resolutionSteps')}

+

{incident.resolution_steps || '-'}

+
+ +
+

{t('lessonsLearned')}

+

{incident.lessons_learned || '-'}

+
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/TimelineSection.tsx b/application/src/components/schedule-incident/incident/detail-dialog/sections/TimelineSection.tsx new file mode 100644 index 0000000..2f2b203 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/TimelineSection.tsx @@ -0,0 +1,45 @@ + +import React from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentItem } from '@/services/incident'; +import { formatDate } from './utils'; + +interface TimelineSectionProps { + incident: IncidentItem | null; + assignedUser?: any | null; +} + +export const TimelineSection: React.FC = ({ incident }) => { + const { t } = useLanguage(); + + if (!incident) return null; + + return ( +
+

{t('timeline')}

+ +
+
+

{t('created')}

+

{formatDate(incident.created)}

+
+ +
+

{t('lastUpdated')}

+

{formatDate(incident.updated)}

+
+ +
+

{t('incidentTime')}

+

{formatDate(incident.timestamp)}

+
+ +
+

{t('resolutionTime')}

+

{formatDate(incident.resolution_time)}

+
+
+
+ ); +}; + diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/index.ts b/application/src/components/schedule-incident/incident/detail-dialog/sections/index.ts new file mode 100644 index 0000000..a3245ff --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/index.ts @@ -0,0 +1,7 @@ + +// Export all section components +export * from './BasicInfoSection'; +export * from './TimelineSection'; +export * from './AffectedSystemsSection'; +export * from './ResolutionSection'; +export * from './utils'; diff --git a/application/src/components/schedule-incident/incident/detail-dialog/sections/utils.ts b/application/src/components/schedule-incident/incident/detail-dialog/sections/utils.ts new file mode 100644 index 0000000..b4409e1 --- /dev/null +++ b/application/src/components/schedule-incident/incident/detail-dialog/sections/utils.ts @@ -0,0 +1,32 @@ + +import { User } from '@/services/userService'; +import { format } from 'date-fns'; + +// Helper function to get user initials +export const getUserInitials = (user: User): 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(); +}; + +// Format date helper +export const formatDate = (dateStr?: string) => { + if (!dateStr) return '-'; + try { + return format(new Date(dateStr), 'PPp'); + } catch (error) { + console.error('Error formatting date:', dateStr, error); + return dateStr; + } +}; + +// Get affected systems helper +export const getAffectedSystemsArray = (systems?: string) => { + if (!systems) return []; + return systems.split(',').map(system => system.trim()).filter(Boolean); +}; diff --git a/application/src/components/schedule-incident/incident/form/IncidentAffectedFields.tsx b/application/src/components/schedule-incident/incident/form/IncidentAffectedFields.tsx new file mode 100644 index 0000000..8213bc2 --- /dev/null +++ b/application/src/components/schedule-incident/incident/form/IncidentAffectedFields.tsx @@ -0,0 +1,56 @@ + +import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { IncidentFormValues } from '../hooks/useIncidentForm'; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; + +export const IncidentAffectedFields: React.FC = () => { + const { t } = useLanguage(); + const { control } = useFormContext(); + + return ( +
+ ( + + {t('affectedSystems')} + + + + +

{t('separateSystemsWithComma')}

+
+ )} + /> + + ( + + {t('rootCause')} + +