feat: Implement Incident Management and Tab Dashboard
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user