feat: Implement Incident Management and Tab Dashboard

This commit is contained in:
Tola Leng
2025-05-23 20:37:00 +08:00
parent 395a580032
commit 747ae5c23f
69 changed files with 4457 additions and 0 deletions
@@ -0,0 +1,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';