feat: Implement Incident Management and Tab Dashboard
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+171
@@ -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';
|
||||||
+76
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+54
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+19
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+55
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+26
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+26
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export * from './PrintButton';
|
||||||
|
export * from './DownloadPdfButton';
|
||||||
|
export * from './CloseButton';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
export * from './usePrintIncident';
|
||||||
|
export * from './useDownloadIncidentPdf';
|
||||||
+29
@@ -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 };
|
||||||
|
};
|
||||||
+173
@@ -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';
|
||||||
+66
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+44
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+69
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+51
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+39
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+45
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user