feat: Implement Incident Management and Tab Dashboard

This commit is contained in:
Tola Leng
2025-05-23 20:37:00 +08:00
parent 395a580032
commit 747ae5c23f
69 changed files with 4457 additions and 0 deletions
@@ -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 <IncidentManagementContainer refreshTrigger={refreshTrigger} />;
});
IncidentManagementTab.displayName = 'IncidentManagementTab';
@@ -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 (
<main className="flex-1 flex flex-col overflow-auto bg-background p-6">
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-foreground">
{t('scheduleIncidentManagement')}
</h2>
<Button
className="text-primary-foreground"
onClick={handleCreateButtonClick}
>
<Plus className="w-4 h-4 mr-2" />
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
</Button>
</div>
<Tabs
defaultValue="maintenance"
className="w-full"
onValueChange={(value) => setActiveTab(value)}
>
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="maintenance">
<CalendarClock className="w-4 h-4 mr-2" />
{t('scheduledMaintenance')}
</TabsTrigger>
<TabsTrigger value="incidents">
<AlertCircle className="w-4 h-4 mr-2" />
{t('incidentManagement')}
</TabsTrigger>
</TabsList>
<TabsContent value="maintenance" className="space-y-4">
<ScheduledMaintenanceTab refreshTrigger={refreshTrigger} />
</TabsContent>
<TabsContent value="incidents" className="space-y-4">
<IncidentManagementTab refreshTrigger={incidentRefreshTrigger} />
</TabsContent>
</Tabs>
</div>
{/* Maintenance creation dialog */}
<CreateMaintenanceDialog
open={createMaintenanceDialogOpen}
onOpenChange={setCreateMaintenanceDialogOpen}
onMaintenanceCreated={handleMaintenanceCreated}
/>
{/* Incident creation dialog */}
<CreateIncidentDialog
open={createIncidentDialogOpen}
onOpenChange={setCreateIncidentDialogOpen}
onIncidentCreated={handleIncidentCreated}
/>
</main>
);
};
@@ -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 <LoadingState />;
}
return (
<>
{/* Status checker for automatic updates */}
<MaintenanceStatusChecker
maintenanceData={maintenanceData}
onStatusUpdated={handleMaintenanceUpdated}
/>
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<OverviewCard
title={t('upcomingMaintenance')}
value={loading ? '...' : overviewStats.upcoming}
icon={<Calendar className="h-5 w-5 text-white" />}
isLoading={loading}
color="blue"
/>
<OverviewCard
title={t('ongoingMaintenance')}
value={loading ? '...' : overviewStats.ongoing}
icon={<Clock className="h-5 w-5 text-white" />}
isLoading={loading}
color="amber"
/>
<OverviewCard
title={t('completedMaintenance')}
value={loading ? '...' : overviewStats.completed}
icon={<CheckCircle className="h-5 w-5 text-white" />}
isLoading={loading}
color="green"
/>
<OverviewCard
title={t('totalScheduledHours')}
value={loading ? '...' : `${overviewStats.totalDuration}h`}
icon={<Clock className="h-5 w-5 text-white" />}
isLoading={loading}
color="purple"
/>
</div>
<Card>
<CardHeader>
<CardTitle>{t('scheduledMaintenance')}</CardTitle>
<CardDescription>
{t('scheduledMaintenanceDesc')}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs
defaultValue="upcoming"
value={filter}
className="w-full"
onValueChange={handleTabChange}
>
<TabsList className="mb-6">
<TabsTrigger value="upcoming">{t('upcomingMaintenance')}</TabsTrigger>
<TabsTrigger value="ongoing">{t('ongoingMaintenance')}</TabsTrigger>
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
</TabsList>
<TabsContent value="upcoming" className="space-y-4">
<MaintenanceTable
data={maintenanceData}
isLoading={loading && initialized}
onMaintenanceUpdated={handleMaintenanceUpdated}
/>
</TabsContent>
<TabsContent value="ongoing" className="space-y-4">
<MaintenanceTable
data={maintenanceData}
isLoading={loading && initialized}
onMaintenanceUpdated={handleMaintenanceUpdated}
/>
</TabsContent>
<TabsContent value="completed" className="space-y-4">
<MaintenanceTable
data={maintenanceData}
isLoading={loading && initialized}
onMaintenanceUpdated={handleMaintenanceUpdated}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</>
);
};
@@ -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 (
<Card
className={cn(
"border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10",
className
)}
style={{
background: getGradientBackground()
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardContent className="p-6 relative z-10">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/90 mb-1">{title}</p>
{isLoading ? (
<Skeleton className="h-8 w-16 bg-white/20" />
) : (
<h3 className={cn("text-3xl font-bold text-white", valueClassName)}>{value}</h3>
)}
{description && (
<p className="text-sm text-white/80 mt-1">{description}</p>
)}
{trend && (
<div className={`flex items-center mt-2 text-sm ${trend.isPositive ? 'text-green-100' : 'text-red-100'}`}>
<span className="mr-1">
{trend.isPositive ? '↑' : '↓'}
</span>
<span>{Math.abs(trend.value)}%</span>
<span className="ml-1 text-white/70">vs last month</span>
</div>
)}
</div>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
{icon}
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,3 @@
export * from './useMaintenanceData';
export * from './useIncidentData';
@@ -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<IncidentItem[]>([]);
const [filter, setFilter] = useState("unresolved");
const [error, setError] = useState<string | null>(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
};
};
@@ -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<MaintenanceItem[]>([]);
const [filter, setFilter] = useState("upcoming");
const [error, setError] = useState<string | null>(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,
};
};
@@ -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<ErrorStateProps> = ({
error,
onRefresh,
isRefreshing
}) => {
const { t } = useLanguage();
if (!error) return null;
return (
<div className="text-center py-8">
<p className="text-muted-foreground mb-4">{error}</p>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="flex items-center gap-2"
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{t('tryAgain')}
</Button>
</div>
);
};
@@ -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<HeaderActionsProps> = ({ onRefresh, isRefreshing }) => {
const { t } = useLanguage();
return (
<div className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('incidentManagement')}</CardTitle>
<CardDescription>
{t('incidentsManagementDesc')}
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
className="ml-auto"
title={t('refreshData')}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="sr-only">{t('refresh')}</span>
</Button>
</div>
);
};
@@ -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<IncidentManagementContainerProps> = ({
refreshTrigger = 0
}) => {
const { t } = useLanguage();
const { toast } = useToast();
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [manualRefresh, setManualRefresh] = useState(0);
// Use a ref to debounce multiple refresh requests
const refreshTimerRef = useRef<number | null>(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 <LoadingState />;
}
return (
<>
{/* Overview Cards */}
<OverviewCards
overviewStats={overviewStats}
loading={loading}
initialized={initialized}
/>
<Card>
<CardHeader>
<HeaderActions
onRefresh={handleManualRefresh}
isRefreshing={isRefreshing}
/>
</CardHeader>
<CardContent>
<Tabs value={filter} className="w-full" onValueChange={handleTabChange}>
<TabsList className="mb-6">
<TabsTrigger value="unresolved">{t('unresolvedIncidents')}</TabsTrigger>
<TabsTrigger value="resolved">{t('resolvedIncidents')}</TabsTrigger>
</TabsList>
<TabsContent value="unresolved" className="space-y-4">
<TabContent
error={error}
isEmpty={isEmpty}
data={incidentData}
loading={loading}
initialized={initialized}
isRefreshing={isRefreshing}
onIncidentUpdated={handleIncidentUpdated}
onViewDetails={handleViewIncidentDetails}
onRefresh={handleManualRefresh}
/>
</TabsContent>
<TabsContent value="resolved" className="space-y-4">
<TabContent
error={error}
isEmpty={isEmpty}
data={incidentData}
loading={loading}
initialized={initialized}
isRefreshing={isRefreshing}
onIncidentUpdated={handleIncidentUpdated}
onViewDetails={handleViewIncidentDetails}
onRefresh={handleManualRefresh}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Incident Detail Dialog */}
<IncidentDetailDialog
open={detailDialogOpen}
onOpenChange={handleDetailDialogClose}
incident={selectedIncident}
/>
</>
);
};
@@ -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<OverviewCardsProps> = ({
overviewStats,
loading,
initialized
}) => {
const { t } = useLanguage();
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<OverviewCard
title={t('activeIncidents')}
value={overviewStats.unresolved.toString()}
icon={<AlertCircle className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
color="red"
/>
<OverviewCard
title={t('criticalIssues')}
value={overviewStats.critical.toString()}
icon={<AlertTriangle className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
color="amber"
/>
<OverviewCard
title={t('highPriority')}
value={overviewStats.highPriority.toString()}
icon={<Flag className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
color="orange"
/>
<OverviewCard
title={t('resolvedIncidents')}
value={overviewStats.resolved.toString()}
icon={<CheckCircle className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
color="green"
/>
<OverviewCard
title={t('avgResolutionTime')}
value={overviewStats.avgResolutionTime}
icon={<Clock className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
color="blue"
/>
</div>
);
};
@@ -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<TabContentProps> = ({
error,
isEmpty,
data,
loading,
initialized,
isRefreshing,
onIncidentUpdated,
onViewDetails,
onRefresh
}) => {
if (error) {
return <ErrorState error={error} onRefresh={onRefresh} isRefreshing={isRefreshing} />;
}
if (isEmpty) {
return <EmptyIncidentState />;
}
return (
<IncidentTable
data={data}
onIncidentUpdated={onIncidentUpdated}
onViewDetails={onViewDetails}
isLoading={loading && initialized}
/>
);
};
@@ -0,0 +1,6 @@
export * from './IncidentManagementContainer';
export * from './OverviewCards';
export * from './TabContent';
export * from './ErrorState';
export * from './HeaderActions';
@@ -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<CreateIncidentDialogProps> = ({
open,
onOpenChange,
onIncidentCreated,
}) => {
const { t } = useLanguage();
const { form, onSubmit } = useIncidentForm(
onIncidentCreated,
() => onOpenChange(false)
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
<ScrollArea className="h-[80vh]">
<div className="px-1 py-2">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl">{t('createIncident')}</DialogTitle>
<DialogDescription>
{t('createIncidentDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-8 pb-4">
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
<IncidentBasicFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
<IncidentAffectedFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
<IncidentConfigFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
<IncidentDetailsFields />
</div>
<DialogFooter className="pt-4 mt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('cancel')}
</Button>
<Button type="submit">
{form.formState.isSubmitting ? t('creating') : t('create')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
@@ -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<EditIncidentDialogProps> = ({
open,
onOpenChange,
incident,
onIncidentUpdated,
}) => {
const { t } = useLanguage();
const handleClose = () => {
onOpenChange(false);
};
const { form, onSubmit } = useIncidentEditForm(
incident,
onIncidentUpdated,
handleClose
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
<ScrollArea className="h-[80vh]">
<div className="px-1 py-2">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl">{t('editIncident')}</DialogTitle>
<DialogDescription>
{t('editIncidentDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-8 pb-4">
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
<IncidentBasicFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
<IncidentAffectedFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
<IncidentConfigFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
<IncidentDetailsFields />
</div>
<DialogFooter className="pt-4 mt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleClose}
>
{t('cancel')}
</Button>
<Button type="submit">
{form.formState.isSubmitting ? t('updating') : t('update')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
@@ -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 (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<AlertCircle className="w-12 h-12 mb-4" />
<h3 className="text-lg font-medium mb-2">{t('noIncidents')}</h3>
<p className="text-sm text-center max-w-md">
{t('noServices')}
</p>
</div>
);
};
@@ -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 (
<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" className="bg-background">
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{onViewDetails && (
<DropdownMenuItem onClick={() => onViewDetails(item)}>
<Eye className="mr-2 h-4 w-4" />
{t('view')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleEditClick}>
<Edit className="mr-2 h-4 w-4" />
{t('edit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleResolveIncident}>
<Check className="mr-2 h-4 w-4" />
{t('resolve')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDeleteIncident}
className="text-red-600 focus:text-red-600"
>
<Trash className="mr-2 h-4 w-4" />
{t('delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -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<string, StatusConfig> = {
'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 (
<Badge
variant={config.variant}
className={`flex items-center gap-1 ${config.className}`}
>
<Icon className="h-3 w-3" />
<span>{config.label}</span>
</Badge>
);
};
@@ -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: <Search className="h-4 w-4 mr-2" /> },
{ value: 'identified', label: t('identified'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
{ value: 'found_root_cause', label: t('foundRootCause'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
{ value: 'in_progress', label: t('inProgress'), icon: <Wrench className="h-4 w-4 mr-2" /> },
{ value: 'monitoring', label: t('monitoring'), icon: <Gauge className="h-4 w-4 mr-2" /> },
{ value: 'resolved', label: t('resolved'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
];
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 (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
<IncidentStatusBadge status={localStatus} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-background border border-border shadow-md z-50">
{statusOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center cursor-pointer"
onClick={(e) => {
e.stopPropagation(); // Prevent event from bubbling to table row click
handleStatusChange(option.value);
}}
>
{option.icon}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,3 @@
// Re-export the refactored IncidentTable component
export { IncidentTable } from './table/IncidentTable';
@@ -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 (
<ScrollArea className="h-[80vh] print:h-auto print:overflow-visible">
<div className="px-6 py-6">
<div className="print-section header-print">
<IncidentDetailHeader incident={incident} />
</div>
<div className="space-y-8 print-compact-spacing">
<div className="print-section">
<BasicInfoSection incident={incident} assignedUser={userToDisplay} />
</div>
<Separator className="print:border-blue-200" />
<div className="print-section">
<TimelineSection incident={incident} assignedUser={userToDisplay} />
</div>
<Separator className="print:border-blue-200" />
<div className="print-section">
<AffectedSystemsSection incident={incident} assignedUser={userToDisplay} />
</div>
<Separator className="print:border-blue-200" />
<div className="print-section">
<ResolutionSection incident={incident} assignedUser={userToDisplay} />
</div>
<IncidentDetailFooter onClose={onClose} incident={incident} />
</div>
</div>
</ScrollArea>
);
};
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="dialog-content sm:max-w-[700px] max-h-[90vh] p-0
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"
>
<IncidentDetailContent
incident={incident}
onClose={() => onOpenChange(false)}
assignedUser={assignedUser}
/>
</DialogContent>
</Dialog>
);
};
@@ -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<IncidentDetailFooterProps> = ({
onClose,
incident,
}) => {
const { t } = useLanguage();
return (
<div className="print:hidden">
<Separator className="my-6" />
<div className="flex justify-between items-center">
<CloseButton onClose={onClose} />
<div className="flex gap-2">
<DownloadPdfButton incident={incident} />
<PrintButton />
</div>
</div>
</div>
);
};
@@ -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 (
<DialogHeader className="mb-4">
<div className="flex items-center gap-2">
<DialogTitle className="text-xl">{incident.title || 'Incident Details'}</DialogTitle>
<span className="text-sm text-muted-foreground">#{incident.id}</span>
</div>
</DialogHeader>
);
};
@@ -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 (
<div className="space-y-6">
<BasicInfoSection incident={incident} assignedUser={assignedUser} />
<Separator />
<TimelineSection incident={incident} assignedUser={assignedUser} />
<Separator />
<AffectedSystemsSection incident={incident} assignedUser={assignedUser} />
<Separator />
<ResolutionSection incident={incident} assignedUser={assignedUser} />
</div>
);
};
@@ -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<CloseButtonProps> = ({
onClose,
className
}) => {
const { t } = useLanguage();
return (
<Button
variant="secondary"
onClick={onClose}
className={className}
>
{t('close', 'common')}
</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 { useDownloadIncidentPdf } from '../hooks';
import { IncidentItem } from '@/services/incident';
interface DownloadPdfButtonProps {
incident: IncidentItem;
className?: string;
}
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
incident,
className
}) => {
const { t } = useLanguage();
const { handleDownloadPDF } = useDownloadIncidentPdf();
return (
<Button
className={`flex items-center gap-2 ${className || ''}`}
onClick={() => handleDownloadPDF(incident)}
variant="outline"
>
<Download className="h-4 w-4" />
{t('downloadPdf', 'incident')}
</Button>
);
};
@@ -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<PrintButtonProps> = ({ className }) => {
const { t } = useLanguage();
const { handlePrint } = usePrintIncident();
return (
<Button
className={`flex items-center gap-2 ${className || ''}`}
onClick={handlePrint}
variant="default"
>
<Printer className="h-4 w-4" />
{t('print', 'incident')}
</Button>
);
};
@@ -0,0 +1,4 @@
export * from './PrintButton';
export * from './DownloadPdfButton';
export * from './CloseButton';
@@ -0,0 +1,3 @@
export * from './usePrintIncident';
export * from './useDownloadIncidentPdf';
@@ -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 };
};
@@ -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 };
};
@@ -0,0 +1,6 @@
export * from './IncidentDetailDialog';
export * from './IncidentDetailHeader';
export * from './IncidentDetailContent';
export * from './IncidentDetailSections';
export * from './IncidentDetailFooter';
@@ -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<AffectedSystemsSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('affectedSystems')}</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('systems')}</h4>
<div className="mt-1 flex flex-wrap gap-1">
{getAffectedSystemsArray(incident.affected_systems).map((system, idx) => (
<Badge key={idx} variant="outline">{system}</Badge>
))}
{getAffectedSystemsArray(incident.affected_systems).length === 0 &&
<span className="text-muted-foreground italic">{t('noSystems')}</span>
}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
<div className="mt-1">
<Badge variant={
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
incident.impact?.toLowerCase() === 'high' ? 'default' :
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.impact?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
<div className="mt-1">
<Badge variant={
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
incident.priority?.toLowerCase() === 'high' ? 'default' :
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.priority?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -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<AssignmentSectionProps> = ({ incident, assignedUser }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('assignment')}</h3>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
<div className="mt-1">
{assignedUser ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
</Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span>
</div>
) : incident.assigned_to ? (
<span>{incident.assigned_to}</span>
) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span>
)}
</div>
</div>
</div>
);
};
@@ -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<BasicInfoSectionProps> = ({ incident, assignedUser }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2 print-compact-text">
<h3 className="font-semibold text-lg">{t('basicInfo')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print-grid">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('title')}</h4>
<p className="mt-1">{incident.title || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('status')}</h4>
<div className="mt-1">
<IncidentStatusBadge status={incident.status || incident.impact_status || 'investigating'} />
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('serviceId')}</h4>
<p className="mt-1">{incident.service_id || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
<div className="mt-1">
{assignedUser ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
</Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span>
</div>
) : incident.assigned_to ? (
<span>{incident.assigned_to}</span>
) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span>
)}
</div>
</div>
</div>
<div className="mt-2">
<h4 className="text-sm font-medium text-muted-foreground">{t('description')}</h4>
<p className="mt-1 whitespace-pre-line print-description">{incident.description || '-'}</p>
</div>
</div>
);
};
@@ -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<ImpactAnalysisSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('impactAnalysis')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
<div className="mt-1">
<Badge variant={
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
incident.impact?.toLowerCase() === 'high' ? 'default' :
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.impact?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
<div className="mt-1">
<Badge variant={
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
incident.priority?.toLowerCase() === 'high' ? 'default' :
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.priority?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
</div>
</div>
);
};
@@ -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<ResolutionSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('resolutionDetails')}</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('rootCause')}</h4>
<p className="mt-1 whitespace-pre-line">{incident.root_cause || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionSteps')}</h4>
<p className="mt-1 whitespace-pre-line">{incident.resolution_steps || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('lessonsLearned')}</h4>
<p className="mt-1 whitespace-pre-line">{incident.lessons_learned || '-'}</p>
</div>
</div>
</div>
);
};
@@ -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<TimelineSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('timeline')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('created')}</h4>
<p className="mt-1">{formatDate(incident.created)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</h4>
<p className="mt-1">{formatDate(incident.updated)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('incidentTime')}</h4>
<p className="mt-1">{formatDate(incident.timestamp)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionTime')}</h4>
<p className="mt-1">{formatDate(incident.resolution_time)}</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,7 @@
// Export all section components
export * from './BasicInfoSection';
export * from './TimelineSection';
export * from './AffectedSystemsSection';
export * from './ResolutionSection';
export * from './utils';
@@ -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);
};
@@ -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<IncidentFormValues>();
return (
<div className="space-y-4">
<FormField
control={control}
name="affected_systems"
render={({ field }) => (
<FormItem>
<FormLabel>{t('affectedSystems')}</FormLabel>
<FormControl>
<Input placeholder={t('enterAffectedSystems')} {...field} />
</FormControl>
<FormMessage />
<p className="text-sm text-muted-foreground">{t('separateSystemsWithComma')}</p>
</FormItem>
)}
/>
<FormField
control={control}
name="root_cause"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rootCause')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterRootCause')}
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,201 @@
import React, { useEffect } 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';
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 IncidentBasicFields: React.FC = () => {
const { t } = useLanguage();
const form = useFormContext<IncidentFormValues>();
const currentAssignedUserId = form.watch('assigned_to');
// For assigned users functionality
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
// Update selectedUserIds when form value changes (for edit mode)
useEffect(() => {
if (currentAssignedUserId) {
setSelectedUserIds([currentAssignedUserId]);
}
}, [currentAssignedUserId]);
// Fetch users for assignment
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
try {
const usersList = await userService.getUsers();
console.log("Fetched users for incident assignment:", usersList);
return Array.isArray(usersList) ? usersList : [];
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
},
staleTime: 300000 // Cache for 5 minutes
});
// Add user to assigned_to
const addUser = (userId: string) => {
// For now, we're using a single user assignment
console.log("Setting user ID in form:", userId);
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
setSelectedUserIds([userId]);
};
// Remove assigned user
const removeUser = () => {
form.setValue('assigned_to', '', { shouldValidate: true, shouldDirty: true });
setSelectedUserIds([]);
};
// Get selected user data
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
// 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 (
<div className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t('title')}</FormLabel>
<FormControl>
<Input placeholder={t('enterIncidentTitle')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterIncidentDescription')}
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="service_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('serviceId')}</FormLabel>
<FormControl>
<Input placeholder={t('enterServiceId')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="assigned_to"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex items-center gap-1">
<Users className="h-4 w-4" /> {t('assignedTo')}
</FormLabel>
<div className="space-y-3">
<FormControl>
<select
id="assigned-user-select"
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
const selectedValue = e.target.value;
console.log("Selected user ID:", selectedValue);
if (selectedValue) {
addUser(selectedValue);
}
}}
onBlur={field.onBlur}
disabled={field.disabled}
name={field.name}
>
<option value="">{t('selectAssignedUser')}</option>
{users.map(user => (
<option
key={user.id}
value={user.id}
>
{user.full_name || user.username}
</option>
))}
</select>
</FormControl>
{selectedUser ? (
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
<Badge key={selectedUser.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
<Avatar className="h-5 w-5 mr-1">
<AvatarImage src={selectedUser.avatar} alt={selectedUser.full_name || selectedUser.username} />
<AvatarFallback className="text-[10px]">
{getUserInitials(selectedUser)}
</AvatarFallback>
</Avatar>
<span>{selectedUser.full_name || selectedUser.username}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
onClick={removeUser}
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('noAssignedUser')}
</div>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,119 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
export const IncidentConfigFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<IncidentFormValues>();
return (
<div className="grid grid-cols-1 gap-4">
<FormField
control={control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t('status')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectIncidentStatus')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="investigating">{t('investigating')}</SelectItem>
<SelectItem value="identified">{t('identified')}</SelectItem>
<SelectItem value="found_root_cause">{t('foundRootCause')}</SelectItem>
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
<SelectItem value="monitoring">{t('monitoring')}</SelectItem>
<SelectItem value="resolved">{t('resolved')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="impact"
render={({ field }) => (
<FormItem className="space-y-2">
<FormLabel>{t('impact')}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex space-x-1"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="minor" id="minor" />
<Label htmlFor="minor">{t('minor')}</Label>
</div>
<span></span>
<div className="flex items-center space-x-2">
<RadioGroupItem value="major" id="major" />
<Label htmlFor="major">{t('major')}</Label>
</div>
<span></span>
<div className="flex items-center space-x-2">
<RadioGroupItem value="critical" id="critical" />
<Label htmlFor="critical">{t('critical')}</Label>
</div>
<span></span>
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" />
<Label htmlFor="none">{t('none')}</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>{t('priority')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectPriorityLevel')} />
</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>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,59 @@
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 { Textarea } from '@/components/ui/textarea';
export const IncidentDetailsFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<IncidentFormValues>();
return (
<div className="space-y-4">
<FormField
control={control}
name="resolution_steps"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resolutionSteps')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterResolutionSteps')}
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="lessons_learned"
render={({ field }) => (
<FormItem>
<FormLabel>{t('lessonsLearned')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterLessonsLearned')}
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,5 @@
export * from './IncidentBasicFields';
export * from './IncidentAffectedFields';
export * from './IncidentConfigFields';
export * from './IncidentDetailsFields';
@@ -0,0 +1,84 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useToast } from '@/hooks/use-toast';
import { incidentService, IncidentItem } from '@/services/incident';
import { useLanguage } from '@/contexts/LanguageContext';
import { incidentFormSchema, IncidentFormValues } from './useIncidentForm';
export const useIncidentEditForm = (
incident: IncidentItem,
onSuccess: () => void,
onClose: () => void
) => {
const { t } = useLanguage();
const { toast } = useToast();
// Initialize form with existing incident data
const form = useForm<IncidentFormValues>({
resolver: zodResolver(incidentFormSchema),
defaultValues: {
title: incident.title || '',
description: incident.description || '',
affected_systems: incident.affected_systems || '',
status: (incident.status?.toLowerCase() || incident.impact_status?.toLowerCase() || 'investigating') as any,
impact: (incident.impact?.toLowerCase() || 'minor') as any,
priority: (incident.priority?.toLowerCase() || 'medium') as any,
service_id: incident.service_id || '',
assigned_to: incident.assigned_to || '',
root_cause: incident.root_cause || '',
resolution_steps: incident.resolution_steps || '',
lessons_learned: incident.lessons_learned || '',
},
});
const onSubmit = async (data: IncidentFormValues) => {
try {
console.log("Form data for update:", data);
console.log("Assigned user ID for update:", data.assigned_to);
await incidentService.updateIncident(incident.id, {
title: data.title,
description: data.description,
status: data.status,
affected_systems: data.affected_systems,
impact: data.impact,
priority: data.priority,
service_id: data.service_id,
assigned_to: data.assigned_to, // This is the user ID from the form
root_cause: data.root_cause,
resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned,
});
toast({
title: t('incidentUpdated'),
description: t('incidentUpdatedDesc'),
});
onClose();
onSuccess();
} catch (error) {
console.error('Error updating incident:', error);
if (error instanceof Error) {
toast({
title: t('error'),
description: `${t('errorUpdatingIncident')}: ${error.message}`,
variant: 'destructive',
});
} else {
toast({
title: t('error'),
description: t('errorUpdatingIncident'),
variant: 'destructive',
});
}
}
};
return {
form,
onSubmit: form.handleSubmit(onSubmit),
};
};
@@ -0,0 +1,105 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { pb } from '@/lib/pocketbase';
import { useToast } from '@/hooks/use-toast';
import { incidentService, CreateIncidentInput } from '@/services/incident';
import { useLanguage } from '@/contexts/LanguageContext';
// Define the schema for our incident form
export const incidentFormSchema = z.object({
title: z.string().min(1, { message: 'Title is required' }),
description: z.string().min(1, { message: 'Description is required' }),
affected_systems: z.string().min(1, { message: 'Affected systems are required' }),
status: z.enum(['investigating', 'found_root_cause', 'in_progress', 'monitoring', 'resolved']),
impact: z.enum(['none', 'minor', 'major', 'critical']),
priority: z.enum(['low', 'medium', 'high', 'critical']),
service_id: z.string().optional(),
assigned_to: z.string().optional(),
root_cause: z.string().optional(),
resolution_steps: z.string().optional(),
lessons_learned: z.string().optional(),
});
export type IncidentFormValues = z.infer<typeof incidentFormSchema>;
export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
const { t } = useLanguage();
const { toast } = useToast();
const form = useForm<IncidentFormValues>({
resolver: zodResolver(incidentFormSchema),
defaultValues: {
title: '',
description: '',
affected_systems: '',
status: 'investigating',
impact: 'minor',
priority: 'medium',
service_id: '',
assigned_to: '',
root_cause: '',
resolution_steps: '',
lessons_learned: '',
},
});
const onSubmit = async (data: IncidentFormValues) => {
try {
console.log("Form data before submission:", data);
const formattedData: CreateIncidentInput = {
title: data.title,
description: data.description,
status: data.status,
affected_systems: data.affected_systems,
impact: data.impact,
priority: data.priority,
service_id: data.service_id,
assigned_to: data.assigned_to,
root_cause: data.root_cause,
resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned,
timestamp: new Date().toISOString(),
created_by: pb.authStore.model?.id || '',
};
console.log("Formatted data for API:", formattedData);
await incidentService.createIncident(formattedData);
toast({
title: t('incidentCreated'),
description: t('incidentCreatedDesc'),
});
console.log("Incident created successfully, about to call onSuccess and onClose");
form.reset();
onClose();
onSuccess();
} catch (error) {
console.error('Error creating incident:', error);
if (error instanceof Error) {
console.error('Error details:', error.message);
toast({
title: t('error'),
description: `${t('errorCreatingIncident')}: ${error.message}`,
variant: 'destructive',
});
} else {
toast({
title: t('error'),
description: t('errorCreatingIncident'),
variant: 'destructive',
});
}
}
};
return {
form,
onSubmit: form.handleSubmit(onSubmit),
};
};
@@ -0,0 +1,9 @@
export * from './IncidentTable';
export * from './IncidentActionsMenu';
export * from './IncidentStatusBadge';
export * from './IncidentStatusDropdown';
export * from './CreateIncidentDialog';
export * from './EditIncidentDialog';
export * from './EmptyIncidentState';
export * from './detail-dialog';
@@ -0,0 +1,150 @@
import React, { useState, useCallback } from 'react';
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { format } from 'date-fns';
import { IncidentItem } from '@/services/incident/types';
import { IncidentTableRow } from './IncidentTableRow';
import { IncidentDetailDialog } from '../detail-dialog/IncidentDetailDialog';
import { EditIncidentDialog } from '../EditIncidentDialog';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentTableSkeleton } from './IncidentTableSkeleton';
interface IncidentTableProps {
data: IncidentItem[];
isLoading: boolean;
onIncidentUpdated: () => void;
onViewDetails?: (incident: IncidentItem) => void;
onEditIncident?: (incident: IncidentItem) => void;
}
export const IncidentTable = ({
data,
isLoading,
onIncidentUpdated,
onViewDetails,
onEditIncident
}: IncidentTableProps) => {
const { t } = useLanguage();
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const formatDate = useCallback((dateString: string | undefined) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'PPp');
} catch (error) {
console.error('Error formatting date:', dateString, error);
return dateString;
}
}, []);
const getAffectedSystemsArray = useCallback((affectedSystems: string | undefined): string[] => {
if (!affectedSystems) return [];
return affectedSystems.split(',').map(system => system.trim()).filter(Boolean);
}, []);
const handleViewDetails = useCallback((incident: IncidentItem) => {
setSelectedIncident(incident);
setIsDetailOpen(true);
}, []);
const handleEditIncident = useCallback((incident: IncidentItem) => {
setSelectedIncident(incident);
setIsEditOpen(true);
}, []);
// Handle status updates efficiently
const handleIncidentUpdated = useCallback(() => {
console.log("Incident updated in IncidentTable, propagating event");
onIncidentUpdated();
}, [onIncidentUpdated]);
// Handle dialog closing
const handleDetailDialogClose = useCallback((open: boolean) => {
setIsDetailOpen(open);
if (!open) {
onIncidentUpdated();
}
}, [onIncidentUpdated]);
// Handle edit dialog closing
const handleEditDialogClose = useCallback((open: boolean) => {
setIsEditOpen(open);
if (!open) {
onIncidentUpdated();
}
}, [onIncidentUpdated]);
if (isLoading) {
return <IncidentTableSkeleton />;
}
// Add a safety check to prevent map of undefined error
if (!data || !Array.isArray(data)) {
console.error('Data is not an array:', data);
return (
<div className="p-4 text-center">
<p>No incident data available</p>
</div>
);
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('title')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('priority')}</TableHead>
<TableHead>{t('time')}</TableHead>
<TableHead>{t('affected')}</TableHead>
<TableHead>{t('impact')}</TableHead>
<TableHead>{t('assignedTo')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<IncidentTableRow
key={item.id}
item={item}
formatDate={formatDate}
getAffectedSystemsArray={getAffectedSystemsArray}
onViewDetails={onViewDetails || handleViewDetails}
onEditIncident={onEditIncident || handleEditIncident}
onIncidentUpdated={handleIncidentUpdated}
t={t}
/>
))}
</TableBody>
</Table>
</div>
{/* Incident detail dialog */}
<IncidentDetailDialog
open={isDetailOpen}
onOpenChange={handleDetailDialogClose}
incident={selectedIncident}
/>
{/* Edit incident dialog */}
{selectedIncident && (
<EditIncidentDialog
open={isEditOpen}
onOpenChange={handleEditDialogClose}
incident={selectedIncident}
onIncidentUpdated={onIncidentUpdated}
/>
)}
</>
);
};
@@ -0,0 +1,119 @@
import React, { memo, useState } from 'react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Eye } from 'lucide-react';
import { IncidentStatusDropdown } from '../IncidentStatusDropdown';
import { IncidentActionsMenu } from '../IncidentActionsMenu';
import { IncidentItem } from '@/services/incident';
import { AssignedUserCell } from './IncidentTableUtils';
interface IncidentTableRowProps {
item: IncidentItem;
formatDate: (date: string | undefined) => string;
getAffectedSystemsArray: (systems: string | undefined) => string[];
onViewDetails?: (incident: IncidentItem) => void;
onEditIncident?: (incident: IncidentItem) => void;
onIncidentUpdated: () => void;
t: (key: string) => string;
}
export const IncidentTableRow = memo(({
item,
formatDate,
getAffectedSystemsArray,
onViewDetails,
onEditIncident,
onIncidentUpdated,
t
}: IncidentTableRowProps) => {
// Use local state for optimistic UI updates
const [localItem, setLocalItem] = useState(item);
// Update local state when props change
React.useEffect(() => {
setLocalItem(item);
}, [item]);
// Handle status updates locally
const handleStatusUpdated = () => {
console.log("Status updated in TableRow, calling onIncidentUpdated");
onIncidentUpdated();
};
return (
<TableRow
key={localItem.id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => onViewDetails && onViewDetails(localItem)}
>
<TableCell className="font-medium max-w-[200px] truncate">
{localItem.title || localItem.description || '-'}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<IncidentStatusDropdown
status={localItem.impact_status || localItem.status || 'investigating'}
id={localItem.id}
onStatusUpdated={handleStatusUpdated}
/>
</TableCell>
<TableCell>
<Badge variant={
localItem.priority?.toLowerCase() === 'critical' ? 'destructive' :
localItem.priority?.toLowerCase() === 'high' ? 'default' :
localItem.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(localItem.priority?.toLowerCase() || 'low')}
</Badge>
</TableCell>
<TableCell>{formatDate(localItem.created)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{getAffectedSystemsArray(localItem.affected_systems).map((system, idx) => (
<Badge key={`${localItem.id}-system-${idx}`} variant="outline">{system}</Badge>
))}
{getAffectedSystemsArray(localItem.affected_systems).length === 0 && '-'}
</div>
</TableCell>
<TableCell>
<Badge variant={
localItem.impact?.toLowerCase() === 'critical' ? 'destructive' :
localItem.impact?.toLowerCase() === 'high' ? 'default' :
localItem.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(localItem.impact?.toLowerCase() || 'low')}
</Badge>
</TableCell>
<TableCell>
<AssignedUserCell userId={localItem.assigned_to} />
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end items-center space-x-2">
{onViewDetails && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onViewDetails(localItem);
}}
className="h-8 w-8 p-0"
>
<Eye className="h-4 w-4" />
<span className="sr-only">{t('view')}</span>
</Button>
)}
<IncidentActionsMenu
item={localItem}
onIncidentUpdated={onIncidentUpdated}
onViewDetails={onViewDetails}
onEditIncident={onEditIncident}
/>
</div>
</TableCell>
</TableRow>
);
});
IncidentTableRow.displayName = 'IncidentTableRow';
@@ -0,0 +1,27 @@
import React from 'react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
export const IncidentTableRowSkeleton = () => (
<TableRow>
<TableCell><Skeleton className="h-5 w-[180px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[120px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[60px]" /></TableCell>
</TableRow>
);
export const IncidentTableSkeleton = () => (
<div className="overflow-x-auto">
<TableRow>
{Array(3).fill(0).map((_, index) => (
<IncidentTableRowSkeleton key={`skeleton-${index}`} />
))}
</TableRow>
</div>
);
@@ -0,0 +1,57 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { userService, User } from '@/services/userService';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
// 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();
};
interface AssignedUserCellProps {
userId?: string;
}
export const AssignedUserCell: React.FC<AssignedUserCellProps> = ({ userId }) => {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
if (!userId) return null;
try {
return await userService.getUser(userId);
} catch (error) {
console.error("Failed to fetch assigned user:", error);
return null;
}
},
enabled: !!userId,
staleTime: 300000 // Cache for 5 minutes
});
if (isLoading) {
return <Skeleton className="h-6 w-20" />;
}
if (!user || !userId) {
return <span>-</span>;
}
return (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
<AvatarFallback>{getUserInitials(user)}</AvatarFallback>
</Avatar>
<span className="truncate max-w-[100px]">{user.full_name || user.username}</span>
</div>
);
};
@@ -0,0 +1,5 @@
export * from './IncidentTable';
export * from './IncidentTableRow';
export * from './IncidentTableSkeleton';
export * from './IncidentTableUtils';
@@ -0,0 +1,18 @@
// Export all components for easier imports
export * from './ScheduleIncidentContent';
export * from './ScheduledMaintenanceTab';
export * from './IncidentManagementTab';
export * from './maintenance/MaintenanceTable';
export * from './maintenance/MaintenanceStatusBadge';
export * from './maintenance/MaintenanceActionsMenu';
export * from './maintenance/EmptyMaintenanceState';
export * from './maintenance/CreateMaintenanceDialog';
export * from './incident/IncidentTable';
export * from './incident/IncidentStatusBadge';
export * from './incident/IncidentActionsMenu';
export * from './incident/EmptyIncidentState';
export * from './incident/CreateIncidentDialog';
export * from './incident-management';
export * from './hooks';
@@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react';
import { Header } from "@/components/dashboard/Header";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { ScheduleIncidentContent } from "@/components/schedule-incident/ScheduleIncidentContent";
import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom";
import { initMaintenanceNotifications, stopMaintenanceNotifications } from "@/services/maintenance/maintenanceNotificationService";
const ScheduleIncident = () => {
// State for sidebar collapse functionality
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const toggleSidebar = () => setSidebarCollapsed(prev => !prev);
// Get current theme and language
const { theme } = useTheme();
const { t } = useLanguage();
// Get current user
const currentUser = authService.getCurrentUser();
const navigate = useNavigate();
// Initialize maintenance notifications
useEffect(() => {
console.log("Initializing maintenance notifications");
initMaintenanceNotifications();
// Clean up on unmount
return () => {
console.log("Stopping maintenance notifications");
stopMaintenanceNotifications();
};
}, []);
// Handle logout
const handleLogout = () => {
authService.logout();
navigate("/login");
};
return (
<div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1">
<Header
currentUser={currentUser}
onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
<ScheduleIncidentContent />
</div>
</div>
);
};
export default ScheduleIncident;
@@ -0,0 +1,14 @@
import { IncidentItem } from './types';
// Export functions to update and invalidate the cache
export const updateCache = (data: IncidentItem[]) => {
console.log(`Updating cache with ${data.length} incidents`);
// The actual cache is now maintained in incidentFetch.ts
};
// Reset cache and request state
export const invalidateCache = () => {
console.log('Invalidating incidents cache');
// The invalidation is handled in incidentFetch.ts implementation
};
@@ -0,0 +1,147 @@
import { pb } from '@/lib/pocketbase';
import { IncidentItem } from './types';
import { normalizeFetchedItem } from './incidentUtils';
import { updateCache, invalidateCache } from './incidentCache';
// Internal state variables instead of importing them
let incidentsCache: {
data: IncidentItem[];
timestamp: number;
} | null = null;
let pendingRequest: Promise<any> | null = null;
let isRequestInProgress = false;
// Cache duration in milliseconds (30 minutes)
const CACHE_DURATION = 30 * 60 * 1000;
// Check if cache is valid
export const isCacheValid = (): boolean => {
const now = Date.now();
return !!(incidentsCache && (now - incidentsCache.timestamp < CACHE_DURATION));
};
// Get all incidents with caching and error handling
export const getAllIncidents = async (forceRefresh = false): Promise<IncidentItem[]> => {
// If a request is in progress, wait for it to complete rather than making a new one
if (isRequestInProgress) {
console.log('Request already in progress, waiting for completion');
try {
if (pendingRequest) {
await pendingRequest;
}
return incidentsCache?.data || [];
} catch (error) {
console.error('Error in existing incidents request:', error);
return incidentsCache?.data || [];
}
}
// Use cache if available, not expired, and not forced refresh
if (!forceRefresh && isCacheValid()) {
console.log('Using cached incidents data from', new Date(incidentsCache!.timestamp).toLocaleTimeString());
return incidentsCache!.data;
}
try {
console.log('Fetching all incidents from API...');
isRequestInProgress = true;
// Implement timeout for the request
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 20000); // Longer timeout (20s)
});
// Create the fetch promise with a unique request key to prevent conflicts
const now = Date.now();
const requestKey = `incidents-${now}`;
const fetchPromise = pb.collection('incidents').getList(1, 100, {
sort: '-created',
requestKey,
$cancelKey: requestKey,
});
// Store the pending request
pendingRequest = Promise.race([fetchPromise, timeoutPromise]);
// Race between fetch and timeout
const result = await pendingRequest;
// Clear request flags
pendingRequest = null;
isRequestInProgress = false;
if (!result || !result.items) {
console.warn('No incidents found in API response');
return [];
}
const normalizedItems = result.items.map(normalizeFetchedItem);
// Update cache
updateCache(normalizedItems);
console.log(`Fetched ${normalizedItems.length} incidents at ${new Date().toLocaleTimeString()}`);
return normalizedItems;
} catch (error) {
if ((error as any)?.isAbort) {
console.log("Request aborted:", error);
return incidentsCache?.data || [];
}
console.error('Error fetching incidents:', error);
// Clear states to allow retry
pendingRequest = null;
isRequestInProgress = false;
// Improve error message
if (error instanceof Error) {
throw new Error(`Failed to fetch incidents: ${error.message}`);
}
// Still return cached data even on error
if (incidentsCache) {
console.log('Returning stale cached data after error');
return incidentsCache.data;
}
return [];
}
};
// Get incident by id
export const getIncidentById = async (id: string): Promise<IncidentItem | null> => {
try {
console.log(`Fetching incident with ID: ${id}`);
// First check if the incident exists in the cache
if (isCacheValid() && incidentsCache) {
const cachedIncident = incidentsCache.data.find(incident => incident.id === id);
if (cachedIncident) {
console.log('Incident found in cache');
return cachedIncident;
}
}
// If not in cache, fetch from API
const result = await pb.collection('incidents').getOne(id);
if (!result) {
console.warn(`No incident found with ID: ${id}`);
return null;
}
const normalizedIncident = normalizeFetchedItem(result);
return normalizedIncident;
} catch (error) {
console.error(`Error fetching incident with ID ${id}:`, error);
if (error instanceof Error) {
throw new Error(`Failed to fetch incident: ${error.message}`);
}
return null;
}
};
@@ -0,0 +1,109 @@
import { pb } from '@/lib/pocketbase';
import { CreateIncidentInput, IncidentItem } from './types';
import { formatStatus } from './incidentUtils';
import { invalidateCache } from './incidentCache';
// Update incident status
export const updateIncidentStatus = async (id: string, status: string): Promise<void> => {
try {
const formattedStatus = formatStatus(status);
console.log(`Updating incident ${id} status to ${status} (formatted: ${formattedStatus})`);
// Update both status and impact_status fields
await pb.collection('incidents').update(id, {
status: formattedStatus,
impact_status: status.toLowerCase(), // Set impact_status to the lowercase status value
...(status.toLowerCase() === 'resolved' ? { resolution_time: new Date().toISOString() } : {})
});
// Invalidate cache after update
invalidateCache();
console.log(`Incident ${id} status updated successfully to ${status}`);
} catch (error) {
console.error('Error updating incident status:', error);
throw error;
}
};
// Delete incident
export const deleteIncident = async (id: string): Promise<void> => {
try {
await pb.collection('incidents').delete(id);
// Invalidate cache after deletion
invalidateCache();
console.log(`Incident ${id} deleted successfully`);
} catch (error) {
console.error('Error deleting incident:', error);
throw error;
}
};
// Create incident
export const createIncident = async (data: CreateIncidentInput): Promise<void> => {
try {
// Format the payload according to API requirements
const payload = {
title: data.title,
description: data.description,
status: formatStatus(data.status),
impact_status: data.status.toLowerCase(),
// Use lowercase for impact and priority to match API expectations
impact: data.impact.toLowerCase(),
affected_systems: data.affected_systems,
priority: data.priority.toLowerCase(),
service_id: data.service_id || '',
assigned_to: data.assigned_to || '', // Direct user ID assignment
root_cause: data.root_cause || '',
resolution_steps: data.resolution_steps || '',
lessons_learned: data.lessons_learned || '',
timestamp: data.timestamp || new Date().toISOString(),
created_by: data.created_by,
resolution_time: data.status.toLowerCase() === 'resolved' ? new Date().toISOString() : null,
};
console.log('Creating new incident with payload:', payload);
await pb.collection('incidents').create(payload);
// Invalidate cache after create
invalidateCache();
console.log('Incident created successfully');
} catch (error) {
console.error('Error creating incident:', error);
throw error;
}
};
// Update incident
export const updateIncident = async (id: string, data: Partial<IncidentItem>): Promise<void> => {
try {
console.log(`Updating incident ${id} with:`, data);
// Make sure impact and priority are lowercase
const payload = {
...data,
impact: data.impact?.toLowerCase(),
priority: data.priority?.toLowerCase(),
status: data.status ? formatStatus(data.status) : undefined,
impact_status: data.status ? data.status.toLowerCase() : undefined,
...(data.status?.toLowerCase() === 'resolved' && !data.resolution_time
? { resolution_time: new Date().toISOString() }
: {})
};
console.log("Final payload for update:", payload);
await pb.collection('incidents').update(id, payload);
// Invalidate cache after update
invalidateCache();
console.log(`Incident ${id} updated successfully`);
} catch (error) {
console.error('Error updating incident:', error);
throw error;
}
};
@@ -0,0 +1,5 @@
import { generateIncidentPDF } from './pdf';
export { generateIncidentPDF };
@@ -0,0 +1,22 @@
import { CreateIncidentInput, IncidentItem, UpdateIncidentInput } from './types';
import { createIncident, updateIncident, updateIncidentStatus, deleteIncident } from './incidentOperations';
import { getAllIncidents, getIncidentById } from './incidentFetch';
import { generateIncidentPDF } from './incidentPdfService';
export const incidentService = {
// Fetch operations
getAllIncidents,
getIncidentById,
// CRUD operations
createIncident,
updateIncident,
updateIncidentStatus,
deleteIncident,
// PDF operations
generateIncidentPDF,
};
export default incidentService;
@@ -0,0 +1,29 @@
import { pb } from '@/lib/pocketbase';
import { IncidentItem } from './types';
// Helper function to get the API URL
export const getApiUrl = (): string => {
try {
return pb.baseUrl;
} catch (error) {
console.error('Error getting API URL:', error);
return '';
}
};
// Normalize fetched items to ensure consistent data structure
export const normalizeFetchedItem = (item: any): IncidentItem => {
return {
...item,
affected_systems: item.affected_systems || '',
status: item.impact_status || item.status || 'Investigating',
impact: item.impact || 'Low',
priority: item.priority || 'Low',
} as IncidentItem;
};
// Format status with first letter capitalized
export const formatStatus = (status: string): string => {
return status.charAt(0).toUpperCase() + status.slice(1);
};
@@ -0,0 +1,12 @@
export * from './types';
export * from './incidentFetch';
export * from './incidentOperations';
export * from './incidentCache';
export * from './incidentUtils';
export * from './incidentPdfService';
export * from './incidentService';
// Export the incidentService as the default export
export { incidentService } from './incidentService';
@@ -0,0 +1,112 @@
import jsPDF from 'jspdf';
import { IncidentItem } from '../types';
import {
addBasicInfoSection,
addDescriptionSection,
addAffectedSystemsSection,
addRootCauseSection,
addResolutionSection,
addAssignmentSection,
addLessonsLearnedSection
} from './sections';
import { addHeader, addFooter } from './headerFooter';
/**
* Generate a PDF for an incident report
*/
export const generatePdf = async (incident: IncidentItem): Promise<string> => {
// Validate incident data
if (!incident?.id) {
console.error('Invalid incident data for PDF generation');
throw new Error('Invalid incident data');
}
try {
// Create new PDF document with portrait orientation
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Set title and filename
const title = incident.title || `Incident Report #${incident.id}`;
const filename = `incident-report-${incident.id}.pdf`;
// Add metadata
doc.setProperties({
title: title,
subject: 'Incident Report',
author: 'ReamStack System',
creator: 'ReamStack',
});
// Add header section
let yPos = addHeader(doc, incident);
// Add basic information section
yPos = addBasicInfoSection(doc, incident, yPos);
// Add description section
yPos = addDescriptionSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add affected systems section
yPos = addAffectedSystemsSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add root cause section
yPos = addRootCauseSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add resolution steps section
yPos = addResolutionSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add assignment section
yPos = addAssignmentSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add lessons learned section if available
addLessonsLearnedSection(doc, incident, yPos);
// Add footer to all pages
addFooter(doc);
// Save the PDF
doc.save(filename);
console.log('PDF generated successfully:', filename);
return filename;
} catch (error) {
console.error('Error generating incident PDF:', error);
throw new Error(`Failed to generate PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
@@ -0,0 +1,63 @@
import jsPDF from 'jspdf';
import { IncidentItem } from '../types';
import { fonts } from './utils';
/**
* Add the PDF header with title and metadata
*/
export const addHeader = (doc: jsPDF, incident: IncidentItem): number => {
// Set initial y position
let yPos = 15;
// Add header with title
doc.setFont(fonts.bold);
doc.setFontSize(18);
doc.setTextColor(0, 0, 0);
doc.text('INCIDENT REPORT', 105, yPos, { align: 'center' });
// Add incident title
yPos += 8;
doc.setFontSize(14);
const title = incident.title || `Incident Report #${incident.id}`;
doc.text(title, 105, yPos, { align: 'center' });
// Add current date
yPos += 8;
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.text(`Generated on: ${new Date().toLocaleDateString()}`, 105, yPos, { align: 'center' });
// Add ReamStack logo or text
yPos += 8;
doc.setFontSize(12);
doc.setFont(fonts.italic);
doc.text('ReamStack Incident Management', 105, yPos, { align: 'center' });
// Add horizontal line
yPos += 5;
doc.setLineWidth(0.5);
doc.line(15, yPos, 195, yPos);
// Return next Y position with some padding
return yPos + 10;
};
/**
* Add footer to all pages
*/
export const addFooter = (doc: jsPDF): void => {
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(100, 100, 100);
doc.text(
`Page ${i} of ${pageCount} | Generated by ReamStack Incident Management System`,
105,
285,
{ align: 'center' }
);
}
};
@@ -0,0 +1,12 @@
import { generatePdf } from './generator';
import { IncidentItem } from '../types';
/**
* Generate and download PDF for incident report
*/
export const generateIncidentPDF = async (incident: IncidentItem): Promise<void> => {
await generatePdf(incident);
return Promise.resolve();
};
@@ -0,0 +1,192 @@
import jsPDF from 'jspdf';
import { IncidentItem } from '../types';
import { fonts, formatDate, capitalize } from './utils';
import autoTable from 'jspdf-autotable';
/**
* Add the incident overview section to the PDF
*/
export const addBasicInfoSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Incident Overview', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
yPos += 10;
// Basic information table
autoTable(doc, {
startY: yPos,
head: [['Field', 'Information']],
body: [
['ID', incident.id],
['Status', capitalize(incident.status || 'Unknown')],
['Priority', capitalize(incident.priority || 'Unknown')],
['Impact', capitalize(incident.impact || 'Unknown')],
['Created', formatDate(incident.created)],
['Last Updated', formatDate(incident.updated)],
],
headStyles: {
fillColor: [30, 64, 175], // Blue-800
textColor: [255, 255, 255],
fontStyle: 'bold',
},
alternateRowStyles: {
fillColor: [241, 245, 249], // Slate-100
},
margin: { left: 15, right: 15 },
});
// Return the new Y position after the table
return (doc as any).lastAutoTable.finalY + 10;
};
/**
* Add the description section to the PDF
*/
export const addDescriptionSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Description', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add incident description
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const description = incident.description || 'No description provided';
const splitDescription = doc.splitTextToSize(description, 180);
doc.text(splitDescription, 15, yPos + 10);
// Update y position after the description
return yPos + 15 + (splitDescription.length * 5);
};
/**
* Add the affected systems section to the PDF
*/
export const addAffectedSystemsSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Affected Systems', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add affected systems
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const affectedSystems = incident.affected_systems || 'None specified';
const systemsList = affectedSystems.split(',').map(system => system.trim());
let currentY = yPos + 10;
systemsList.forEach((system) => {
doc.text(`${system}`, 15, currentY);
currentY += 5;
});
return currentY + 5; // Return updated Y position with some padding
};
/**
* Add the root cause section to the PDF
*/
export const addRootCauseSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Root Cause', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add root cause
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const rootCause = incident.root_cause || 'Root cause not identified yet';
const splitRootCause = doc.splitTextToSize(rootCause, 180);
doc.text(splitRootCause, 15, yPos + 10);
// Update y position after the root cause
return yPos + 15 + (splitRootCause.length * 5);
};
/**
* Add the resolution steps section to the PDF
*/
export const addResolutionSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Resolution Steps', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Create resolution data table - Using resolution_steps
const resolutionSteps = incident.resolution_steps || 'No resolution steps provided';
const splitResolutionSteps = doc.splitTextToSize(resolutionSteps, 180);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text(splitResolutionSteps, 15, yPos + 10);
return yPos + 20 + (splitResolutionSteps.length * 5);
};
/**
* Add the assignment information section to the PDF
*/
export const addAssignmentSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Assignment Information', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add assigned user info
yPos += 10;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const assignedTo = incident.assigned_to || 'Not assigned';
doc.text(`Assigned to: ${assignedTo}`, 15, yPos);
return yPos + 10;
};
/**
* Add the lessons learned section to the PDF if available
*/
export const addLessonsLearnedSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
if (!incident.lessons_learned) {
return yPos; // No lessons learned, return current position
}
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Lessons Learned', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add lessons learned
const lessonsLearned = incident.lessons_learned;
const splitLessonsLearned = doc.splitTextToSize(lessonsLearned, 180);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text(splitLessonsLearned, 15, yPos + 10);
return yPos + 15 + (splitLessonsLearned.length * 5);
};
@@ -0,0 +1,26 @@
import { format } from 'date-fns';
// Font configuration for the PDF
export const fonts = {
normal: 'Helvetica',
bold: 'Helvetica-Bold',
italic: 'Helvetica-Oblique',
};
// Helper function to format dates
export const formatDate = (dateString: string | undefined): string => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'PPp');
} catch (e) {
return dateString || 'N/A';
}
};
// Helper function to capitalize first letter
export const capitalize = (str: string): string => {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
};
@@ -0,0 +1,57 @@
// Define the type for incident item
export type IncidentItem = {
id: string;
service_id?: string;
timestamp?: string;
description: string;
assigned_to?: string;
resolution_time?: string;
impact: string;
affected_systems: string;
root_cause?: string;
resolution_steps?: string;
lessons_learned?: string;
operational_status_id?: string;
server_id?: string;
priority: string;
status: string;
impact_status?: string;
created: string;
updated: string;
category?: string;
title: string;
};
// Define the input type for creating an incident
export type CreateIncidentInput = {
title: string;
description: string;
status: string;
impact: string;
affected_systems: string;
priority: string;
service_id?: string;
assigned_to?: string;
root_cause?: string;
resolution_steps?: string;
lessons_learned?: string;
timestamp?: string;
created_by: string;
};
// Define the input type for updating an incident
export type UpdateIncidentInput = {
id: string;
title?: string;
description?: string;
status?: string;
impact?: string;
affected_systems?: string;
priority?: string;
service_id?: string;
assigned_to?: string;
root_cause?: string;
resolution_steps?: string;
lessons_learned?: string;
};