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,76 @@
import React from 'react';
import { IncidentItem } from '@/services/incident/types';
import { ScrollArea } from '@/components/ui/scroll-area';
import { IncidentDetailHeader } from './IncidentDetailHeader';
import { Separator } from '@/components/ui/separator';
import {
BasicInfoSection,
TimelineSection,
AffectedSystemsSection,
ResolutionSection
} from './sections';
import { IncidentDetailFooter } from './IncidentDetailFooter';
import { useQuery } from '@tanstack/react-query';
import { userService } from '@/services/userService';
interface IncidentDetailContentProps {
incident: IncidentItem;
onClose: () => void;
assignedUser: any | null;
}
export const IncidentDetailContent = ({
incident,
onClose,
assignedUser
}: IncidentDetailContentProps) => {
// Fetch assigned user details if one wasn't provided and there's an assigned_to field
const { data: fetchedUser } = useQuery({
queryKey: ['user', incident?.assigned_to],
queryFn: async () => {
if (!incident?.assigned_to) return null;
try {
return await userService.getUser(incident.assigned_to);
} catch (error) {
console.error("Failed to fetch assigned user:", error);
return null;
}
},
enabled: !!incident?.assigned_to && !assignedUser,
staleTime: 300000 // Cache for 5 minutes
});
// Use the provided assignedUser or the one we fetched
const userToDisplay = assignedUser || fetchedUser;
return (
<ScrollArea className="h-[80vh] print:h-auto print:overflow-visible">
<div className="px-6 py-6">
<div className="print-section header-print">
<IncidentDetailHeader incident={incident} />
</div>
<div className="space-y-8 print-compact-spacing">
<div className="print-section">
<BasicInfoSection incident={incident} assignedUser={userToDisplay} />
</div>
<Separator className="print:border-blue-200" />
<div className="print-section">
<TimelineSection incident={incident} assignedUser={userToDisplay} />
</div>
<Separator className="print:border-blue-200" />
<div className="print-section">
<AffectedSystemsSection incident={incident} assignedUser={userToDisplay} />
</div>
<Separator className="print:border-blue-200" />
<div className="print-section">
<ResolutionSection incident={incident} assignedUser={userToDisplay} />
</div>
<IncidentDetailFooter onClose={onClose} incident={incident} />
</div>
</div>
</ScrollArea>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { IncidentItem } from '@/services/incident/types';
import { useQuery } from '@tanstack/react-query';
import { userService } from '@/services/userService';
import { IncidentDetailContent } from './IncidentDetailContent';
interface IncidentDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
incident: IncidentItem | null;
}
export const IncidentDetailDialog = ({
open,
onOpenChange,
incident
}: IncidentDetailDialogProps) => {
// Fetch user data for assigned_to field
const { data: users = [] } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const usersList = await userService.getUsers();
return Array.isArray(usersList) ? usersList : [];
},
staleTime: 300000, // Cache for 5 minutes
enabled: !!incident?.assigned_to && open // Only run query if there's an assigned_to value and dialog is open
});
// Find the assigned user
const assignedUser = incident?.assigned_to
? users.find(user => user.id === incident.assigned_to)
: null;
if (!incident) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="dialog-content sm:max-w-[700px] max-h-[90vh] p-0
print:max-w-none print:max-h-none print:overflow-visible
print:shadow-none print:m-0 print:p-0 print:border-none
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
>
<IncidentDetailContent
incident={incident}
onClose={() => onOpenChange(false)}
assignedUser={assignedUser}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,31 @@
import React from 'react';
import { IncidentItem } from '@/services/incident';
import { Separator } from '@/components/ui/separator';
import { CloseButton, DownloadPdfButton, PrintButton } from './components';
import { useLanguage } from '@/contexts/LanguageContext';
interface IncidentDetailFooterProps {
onClose: () => void;
incident: IncidentItem;
}
export const IncidentDetailFooter: React.FC<IncidentDetailFooterProps> = ({
onClose,
incident,
}) => {
const { t } = useLanguage();
return (
<div className="print:hidden">
<Separator className="my-6" />
<div className="flex justify-between items-center">
<CloseButton onClose={onClose} />
<div className="flex gap-2">
<DownloadPdfButton incident={incident} />
<PrintButton />
</div>
</div>
</div>
);
};
@@ -0,0 +1,19 @@
import React from 'react';
import { DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { IncidentItem } from '@/services/incident/types';
interface IncidentDetailHeaderProps {
incident: IncidentItem;
}
export const IncidentDetailHeader = ({ incident }: IncidentDetailHeaderProps) => {
return (
<DialogHeader className="mb-4">
<div className="flex items-center gap-2">
<DialogTitle className="text-xl">{incident.title || 'Incident Details'}</DialogTitle>
<span className="text-sm text-muted-foreground">#{incident.id}</span>
</div>
</DialogHeader>
);
};
@@ -0,0 +1,55 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { Separator } from '@/components/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { userService } from '@/services/userService';
// Import all section components from the new location
import {
BasicInfoSection,
TimelineSection,
AffectedSystemsSection,
ResolutionSection
} from './sections';
// Re-export all section components for compatibility with existing imports
export {
BasicInfoSection,
TimelineSection,
AffectedSystemsSection,
ResolutionSection
};
// Legacy component - keeping this for backward compatibility with other imports
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
// Fetch assigned user details if there's an assigned_to field
const { data: assignedUser } = useQuery({
queryKey: ['user', incident?.assigned_to],
queryFn: async () => {
if (!incident?.assigned_to) return null;
try {
return await userService.getUser(incident.assigned_to);
} catch (error) {
console.error("Failed to fetch assigned user:", error);
return null;
}
},
enabled: !!incident?.assigned_to,
staleTime: 300000 // Cache for 5 minutes
});
if (!incident) return null;
return (
<div className="space-y-6">
<BasicInfoSection incident={incident} assignedUser={assignedUser} />
<Separator />
<TimelineSection incident={incident} assignedUser={assignedUser} />
<Separator />
<AffectedSystemsSection incident={incident} assignedUser={assignedUser} />
<Separator />
<ResolutionSection incident={incident} assignedUser={assignedUser} />
</div>
);
};
@@ -0,0 +1,26 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/contexts/LanguageContext';
interface CloseButtonProps {
onClose: () => void;
className?: string;
}
export const CloseButton: React.FC<CloseButtonProps> = ({
onClose,
className
}) => {
const { t } = useLanguage();
return (
<Button
variant="secondary"
onClick={onClose}
className={className}
>
{t('close', 'common')}
</Button>
);
};
@@ -0,0 +1,31 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { useDownloadIncidentPdf } from '../hooks';
import { IncidentItem } from '@/services/incident';
interface DownloadPdfButtonProps {
incident: IncidentItem;
className?: string;
}
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
incident,
className
}) => {
const { t } = useLanguage();
const { handleDownloadPDF } = useDownloadIncidentPdf();
return (
<Button
className={`flex items-center gap-2 ${className || ''}`}
onClick={() => handleDownloadPDF(incident)}
variant="outline"
>
<Download className="h-4 w-4" />
{t('downloadPdf', 'incident')}
</Button>
);
};
@@ -0,0 +1,26 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Printer } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { usePrintIncident } from '../hooks';
interface PrintButtonProps {
className?: string;
}
export const PrintButton: React.FC<PrintButtonProps> = ({ className }) => {
const { t } = useLanguage();
const { handlePrint } = usePrintIncident();
return (
<Button
className={`flex items-center gap-2 ${className || ''}`}
onClick={handlePrint}
variant="default"
>
<Printer className="h-4 w-4" />
{t('print', 'incident')}
</Button>
);
};
@@ -0,0 +1,4 @@
export * from './PrintButton';
export * from './DownloadPdfButton';
export * from './CloseButton';
@@ -0,0 +1,3 @@
export * from './usePrintIncident';
export * from './useDownloadIncidentPdf';
@@ -0,0 +1,29 @@
import { useToast } from '@/hooks/use-toast';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem, incidentService } from '@/services/incident';
export const useDownloadIncidentPdf = () => {
const { toast } = useToast();
const { t } = useLanguage();
const handleDownloadPDF = async (incident: IncidentItem) => {
try {
await incidentService.generateIncidentPDF(incident);
toast({
title: t('success'),
description: t('pdfDownloaded'),
});
} catch (error) {
console.error("Error generating PDF:", error);
toast({
title: t('error'),
description: t('pdfGenerationFailed'),
variant: 'destructive',
});
}
};
return { handleDownloadPDF };
};
@@ -0,0 +1,173 @@
import React from 'react';
import { useToast } from '@/hooks/use-toast';
import { useLanguage } from '@/contexts/LanguageContext';
export const usePrintIncident = () => {
const { toast } = useToast();
const { t } = useLanguage();
const handlePrint = React.useCallback(() => {
try {
// Add print-specific stylesheet temporarily
const style = document.createElement('style');
style.id = 'print-style';
style.textContent = `
@page {
size: A4;
margin: 10mm;
}
@media print {
body * {
visibility: hidden;
}
.dialog-content, .dialog-content * {
visibility: visible;
}
.dialog-content {
position: absolute !important;
left: 0;
top: 0;
width: 100%;
height: auto;
padding: 15mm !important;
margin: 0 !important;
background-color: white !important;
box-shadow: none;
overflow: visible !important;
display: block !important;
transform: none !important;
}
/* Remove any black backgrounds */
html, body {
background-color: white !important;
color: black !important;
}
/* Optimize spacing for single page */
.print-section {
margin-bottom: 2mm !important;
page-break-inside: avoid !important;
}
/* Reduce vertical spacing */
h4 {
margin-bottom: 1mm !important;
margin-top: 1mm !important;
color: #1e40af !important; /* blue-800 */
font-weight: bold !important;
}
.print-compact-text {
font-size: 9pt !important;
line-height: 1.2 !important;
}
.print-compact-spacing > * {
margin-top: 1mm !important;
margin-bottom: 1mm !important;
}
.print-grid {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 1mm !important;
}
.badge-print {
background-color: #dbeafe !important; /* blue-100 */
color: #1e40af !important; /* blue-800 */
border: 1px solid #93c5fd !important; /* blue-300 */
padding: 0.5mm 1mm !important;
margin: 0.5mm !important;
display: inline-block !important;
font-size: 8pt !important;
}
/* More compact spacing */
.print-compact-margin {
margin: 1mm 0 !important;
}
.print-smaller-font {
font-size: 8pt !important;
}
.header-print {
background-color: #1e40af !important; /* blue-800 */
color: #ffffff !important;
padding: 2mm 3mm !important;
margin-bottom: 2mm !important;
}
/* More compact header */
.header-print h1 {
font-size: 12pt !important;
margin-bottom: 0 !important;
}
.header-print p {
font-size: 9pt !important;
margin-top: 1mm !important;
}
/* Footer stays at bottom */
.footer-print {
font-size: 7pt !important;
color: #6b7280 !important; /* gray-500 */
border-top: 1px solid #e5e7eb !important; /* gray-200 */
position: fixed !important;
bottom: 10mm !important;
left: 15mm !important;
right: 15mm !important;
padding-top: 2mm !important;
background-color: white !important;
}
/* Hide any unnecessary elements */
.print-hide {
display: none !important;
}
/* Optimize description text */
.print-description {
max-height: 100px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
}
`;
document.head.appendChild(style);
// Wait a bit to ensure styles are applied
setTimeout(() => {
window.print();
// Remove the style after printing dialog closes
setTimeout(() => {
const printStyle = document.getElementById('print-style');
if (printStyle) {
printStyle.remove();
}
}, 1000);
}, 100);
toast({
title: t('success'),
description: t('printJobStarted'),
});
} catch (error) {
console.error("Error printing:", error);
toast({
title: t('error'),
description: t('printingFailed'),
variant: 'destructive',
});
}
}, [t, toast]);
return { handlePrint };
};
@@ -0,0 +1,6 @@
export * from './IncidentDetailDialog';
export * from './IncidentDetailHeader';
export * from './IncidentDetailContent';
export * from './IncidentDetailSections';
export * from './IncidentDetailFooter';
@@ -0,0 +1,66 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { Badge } from '@/components/ui/badge';
import { getAffectedSystemsArray } from './utils';
interface AffectedSystemsSectionProps {
incident: IncidentItem | null;
assignedUser?: any | null;
}
export const AffectedSystemsSection: React.FC<AffectedSystemsSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('affectedSystems')}</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('systems')}</h4>
<div className="mt-1 flex flex-wrap gap-1">
{getAffectedSystemsArray(incident.affected_systems).map((system, idx) => (
<Badge key={idx} variant="outline">{system}</Badge>
))}
{getAffectedSystemsArray(incident.affected_systems).length === 0 &&
<span className="text-muted-foreground italic">{t('noSystems')}</span>
}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
<div className="mt-1">
<Badge variant={
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
incident.impact?.toLowerCase() === 'high' ? 'default' :
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.impact?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
<div className="mt-1">
<Badge variant={
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
incident.priority?.toLowerCase() === 'high' ? 'default' :
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.priority?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User } from '@/services/userService';
import { getUserInitials } from './utils';
interface AssignmentSectionProps {
incident: IncidentItem | null;
assignedUser?: User | null;
}
export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident, assignedUser }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('assignment')}</h3>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
<div className="mt-1">
{assignedUser ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
</Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span>
</div>
) : incident.assigned_to ? (
<span>{incident.assigned_to}</span>
) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { User } from '@/services/userService';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { IncidentStatusBadge } from '../../IncidentStatusBadge';
import { getUserInitials } from './utils';
interface BasicInfoSectionProps {
incident: IncidentItem | null;
assignedUser?: User | null;
}
export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, assignedUser }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2 print-compact-text">
<h3 className="font-semibold text-lg">{t('basicInfo')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print-grid">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('title')}</h4>
<p className="mt-1">{incident.title || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('status')}</h4>
<div className="mt-1">
<IncidentStatusBadge status={incident.status || incident.impact_status || 'investigating'} />
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('serviceId')}</h4>
<p className="mt-1">{incident.service_id || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
<div className="mt-1">
{assignedUser ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
</Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span>
</div>
) : incident.assigned_to ? (
<span>{incident.assigned_to}</span>
) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span>
)}
</div>
</div>
</div>
<div className="mt-2">
<h4 className="text-sm font-medium text-muted-foreground">{t('description')}</h4>
<p className="mt-1 whitespace-pre-line print-description">{incident.description || '-'}</p>
</div>
</div>
);
};
@@ -0,0 +1,51 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { Badge } from '@/components/ui/badge';
interface ImpactAnalysisSectionProps {
incident: IncidentItem | null;
assignedUser?: any | null;
}
export const ImpactAnalysisSection: React.FC<ImpactAnalysisSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('impactAnalysis')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
<div className="mt-1">
<Badge variant={
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
incident.impact?.toLowerCase() === 'high' ? 'default' :
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.impact?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
<div className="mt-1">
<Badge variant={
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
incident.priority?.toLowerCase() === 'high' ? 'default' :
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(incident.priority?.toLowerCase() || 'low')}
</Badge>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,39 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
interface ResolutionSectionProps {
incident: IncidentItem | null;
assignedUser?: any | null;
}
export const ResolutionSection: React.FC<ResolutionSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('resolutionDetails')}</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('rootCause')}</h4>
<p className="mt-1 whitespace-pre-line">{incident.root_cause || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionSteps')}</h4>
<p className="mt-1 whitespace-pre-line">{incident.resolution_steps || '-'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('lessonsLearned')}</h4>
<p className="mt-1 whitespace-pre-line">{incident.lessons_learned || '-'}</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,45 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { formatDate } from './utils';
interface TimelineSectionProps {
incident: IncidentItem | null;
assignedUser?: any | null;
}
export const TimelineSection: React.FC<TimelineSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('timeline')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('created')}</h4>
<p className="mt-1">{formatDate(incident.created)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</h4>
<p className="mt-1">{formatDate(incident.updated)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('incidentTime')}</h4>
<p className="mt-1">{formatDate(incident.timestamp)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionTime')}</h4>
<p className="mt-1">{formatDate(incident.resolution_time)}</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,7 @@
// Export all section components
export * from './BasicInfoSection';
export * from './TimelineSection';
export * from './AffectedSystemsSection';
export * from './ResolutionSection';
export * from './utils';
@@ -0,0 +1,32 @@
import { User } from '@/services/userService';
import { format } from 'date-fns';
// Helper function to get user initials
export const getUserInitials = (user: User): string => {
if (user.full_name) {
const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) {
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
}
return user.full_name.substring(0, 2).toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
// Format date helper
export const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
try {
return format(new Date(dateStr), 'PPp');
} catch (error) {
console.error('Error formatting date:', dateStr, error);
return dateStr;
}
};
// Get affected systems helper
export const getAffectedSystemsArray = (systems?: string) => {
if (!systems) return [];
return systems.split(',').map(system => system.trim()).filter(Boolean);
};