diff --git a/src/components/versioning/EntityVersionHistory.tsx b/src/components/versioning/EntityVersionHistory.tsx new file mode 100644 index 00000000..0dc47416 --- /dev/null +++ b/src/components/versioning/EntityVersionHistory.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import { Clock, GitBranch, User, FileText, RotateCcw, GitCompare } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { VersionComparisonDialog } from './VersionComparisonDialog'; +import { RollbackDialog } from './RollbackDialog'; +import { useEntityVersions } from '@/hooks/useEntityVersions'; + +interface EntityVersionHistoryProps { + entityType: string; + entityId: string; + entityName: string; +} + +const changeTypeColors = { + created: 'bg-green-500/10 text-green-700 dark:text-green-400', + updated: 'bg-blue-500/10 text-blue-700 dark:text-blue-400', + deleted: 'bg-red-500/10 text-red-700 dark:text-red-400', + restored: 'bg-purple-500/10 text-purple-700 dark:text-purple-400', + archived: 'bg-gray-500/10 text-gray-700 dark:text-gray-400', +}; + +export function EntityVersionHistory({ entityType, entityId, entityName }: EntityVersionHistoryProps) { + const { versions, loading, rollbackToVersion } = useEntityVersions(entityType, entityId); + const [selectedVersions, setSelectedVersions] = useState([]); + const [compareDialogOpen, setCompareDialogOpen] = useState(false); + const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false); + const [selectedVersionForRollback, setSelectedVersionForRollback] = useState(null); + + const handleVersionSelect = (versionId: string) => { + setSelectedVersions(prev => { + if (prev.includes(versionId)) { + return prev.filter(id => id !== versionId); + } + if (prev.length >= 2) { + return [prev[1], versionId]; + } + return [...prev, versionId]; + }); + }; + + const handleCompare = () => { + if (selectedVersions.length === 2) { + setCompareDialogOpen(true); + } + }; + + const handleRollback = (versionId: string) => { + setSelectedVersionForRollback(versionId); + setRollbackDialogOpen(true); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (versions.length === 0) { + return ( +
+ +

No version history available

+
+ ); + } + + return ( +
+ {/* Header Actions */} +
+
+

Version History

+

{versions.length} versions

+
+ + {selectedVersions.length === 2 && ( + + )} +
+ + {/* Timeline */} + +
+ {/* Timeline line */} +
+ + {versions.map((version, index) => ( + handleVersionSelect(version.id)} + > + {/* Timeline dot */} +
+ +
+ {/* Header */} +
+
+ + {version.change_type} + + Version {version.version_number} + {version.is_current && ( + Current + )} +
+ + {!version.is_current && ( + + )} +
+ + {/* Metadata */} +
+
+ + + + + + + {version.changer_profile?.username || 'Unknown'} +
+ +
+ + {formatDistanceToNow(new Date(version.changed_at), { addSuffix: true })} +
+ + {version.submission_id && ( +
+ + Submission +
+ )} +
+ + {/* Change Reason */} + {version.change_reason && ( + <> + +

{version.change_reason}

+ + )} +
+ + ))} +
+ + + {/* Dialogs */} + {selectedVersions.length === 2 && ( + + )} + + {selectedVersionForRollback && ( + { + await rollbackToVersion(selectedVersionForRollback, reason); + setRollbackDialogOpen(false); + setSelectedVersionForRollback(null); + }} + /> + )} +
+ ); +} diff --git a/src/components/versioning/FieldHistoryDialog.tsx b/src/components/versioning/FieldHistoryDialog.tsx new file mode 100644 index 00000000..5fc21ae1 --- /dev/null +++ b/src/components/versioning/FieldHistoryDialog.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Clock, Plus, Minus, Edit } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { supabase } from '@/integrations/supabase/client'; + +interface FieldHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + entityType: string; + entityId: string; + fieldName: string; +} + +interface FieldChange { + id: string; + field_name: string; + old_value: any; + new_value: any; + change_type: 'added' | 'modified' | 'removed'; + created_at: string; + version: { + version_number: number; + changed_by: string; + changed_at: string; + changer_profile: { + username: string; + }; + }; +} + +export function FieldHistoryDialog({ + open, + onOpenChange, + entityType, + entityId, + fieldName, +}: FieldHistoryDialogProps) { + const [changes, setChanges] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchFieldHistory = async () => { + if (!open) return; + + setLoading(true); + try { + // Get all versions for this entity + const { data: versions, error: versionsError } = await supabase + .from('entity_versions') + .select('id, version_number, changed_by, changed_at') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .order('version_number', { ascending: false }); + + if (versionsError) throw versionsError; + + // Get profiles + const userIds = [...new Set(versions?.map(v => v.changed_by).filter(Boolean) || [])]; + const { data: profiles } = await supabase + .from('profiles') + .select('user_id, username') + .in('user_id', userIds); + + // Get field history for all these versions + const versionIds = versions?.map(v => v.id) || []; + + const { data: fieldHistory, error: historyError } = await supabase + .from('entity_field_history') + .select('*') + .eq('field_name', fieldName) + .in('version_id', versionIds) + .order('created_at', { ascending: false }); + + if (historyError) throw historyError; + + // Merge the data + const mergedData = fieldHistory?.map(change => ({ + ...change, + version: { + ...versions?.find(v => v.id === change.version_id), + changer_profile: profiles?.find(p => p.user_id === versions?.find(v => v.id === change.version_id)?.changed_by) + }, + })) || []; + + setChanges(mergedData as FieldChange[]); + } catch (error) { + console.error('Error fetching field history:', error); + } finally { + setLoading(false); + } + }; + + fetchFieldHistory(); + }, [open, entityType, entityId, fieldName]); + + const formatValue = (value: any): string => { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'object') return JSON.stringify(value, null, 2); + return String(value); + }; + + const formatFieldName = (name: string): string => { + return name + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }; + + const getChangeIcon = (type: string) => { + switch (type) { + case 'added': + return ; + case 'removed': + return ; + case 'modified': + return ; + default: + return null; + } + }; + + const getChangeBadgeColor = (type: string) => { + switch (type) { + case 'added': + return 'bg-green-500/10 text-green-700 dark:text-green-400'; + case 'removed': + return 'bg-red-500/10 text-red-700 dark:text-red-400'; + case 'modified': + return 'bg-blue-500/10 text-blue-700 dark:text-blue-400'; + default: + return ''; + } + }; + + return ( + + + + Field History: {formatFieldName(fieldName)} + + + + {loading ? ( +
+
+
+ ) : changes.length > 0 ? ( +
+ {changes.map((change) => ( +
+ {/* Header */} +
+
+ {getChangeIcon(change.change_type)} + + {change.change_type} + + + Version {change.version?.version_number} + +
+ +
+ {change.version?.changer_profile?.username} + + + {formatDistanceToNow(new Date(change.created_at), { addSuffix: true })} + +
+
+ + {/* Values */} +
+ {change.old_value !== null && ( +
+
Previous Value
+
+ {formatValue(change.old_value)} +
+
+ )} + + {change.new_value !== null && ( +
+
New Value
+
+ {formatValue(change.new_value)} +
+
+ )} +
+
+ ))} +
+ ) : ( +
+

No history found for this field

+
+ )} + + +
+ ); +} diff --git a/src/components/versioning/HistoricalEntityCard.tsx b/src/components/versioning/HistoricalEntityCard.tsx new file mode 100644 index 00000000..bdb1a84e --- /dev/null +++ b/src/components/versioning/HistoricalEntityCard.tsx @@ -0,0 +1,113 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Calendar, MapPin, ArrowRight, Building2 } from 'lucide-react'; +import { format } from 'date-fns'; + +interface HistoricalEntityCardProps { + type: 'park' | 'ride'; + entity: { + id: string; + name: string; + operated_from?: string; + operated_until?: string; + closure_reason?: string; + removal_reason?: string; + location?: { + name: string; + city?: string; + country: string; + }; + successor?: { + id: string; + name: string; + slug: string; + }; + relocated_to?: { + id: string; + name: string; + slug: string; + }; + }; + onViewDetails?: () => void; +} + +export function HistoricalEntityCard({ type, entity, onViewDetails }: HistoricalEntityCardProps) { + const reason = type === 'park' ? entity.closure_reason : entity.removal_reason; + const hasSuccessor = type === 'park' ? !!entity.successor : !!entity.relocated_to; + const successorInfo = type === 'park' ? entity.successor : entity.relocated_to; + + return ( + + +
+
+ + {entity.name} + + Historical + + + + + {entity.location?.city && `${entity.location.city}, `} + {entity.location?.country || entity.location?.name} + +
+
+
+ + + {/* Operating Dates */} +
+
+ + Operated: +
+
+ {entity.operated_from && format(new Date(entity.operated_from), 'MMM d, yyyy')} + {' - '} + {entity.operated_until && format(new Date(entity.operated_until), 'MMM d, yyyy')} +
+
+ + {/* Closure/Removal Reason */} + {reason && ( +
+
+ {type === 'park' ? 'Closure Reason' : 'Removal Reason'} +
+

{reason}

+
+ )} + + {/* Successor/Relocation Info */} + {hasSuccessor && successorInfo && ( +
+ + + {type === 'park' ? 'Succeeded by' : 'Relocated to'}: + + +
+ )} + + {/* View Details Button */} + {onViewDetails && ( + + )} +
+
+ ); +} diff --git a/src/components/versioning/RollbackDialog.tsx b/src/components/versioning/RollbackDialog.tsx new file mode 100644 index 00000000..ae4f5351 --- /dev/null +++ b/src/components/versioning/RollbackDialog.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { AlertTriangle } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +interface RollbackDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + versionId: string; + entityType: string; + entityId: string; + entityName: string; + onRollback: (reason: string) => Promise; +} + +export function RollbackDialog({ + open, + onOpenChange, + versionId, + entityName, + onRollback, +}: RollbackDialogProps) { + const [reason, setReason] = useState(''); + const [loading, setLoading] = useState(false); + + const handleRollback = async () => { + if (!reason.trim()) return; + + setLoading(true); + try { + await onRollback(reason); + setReason(''); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + return ( + + + + Restore Previous Version + + You are about to restore "{entityName}" to a previous version. This will create a new version with the restored data. + + + + + + + This action will restore the entity to its previous state. The current version will be preserved in history. + + + +
+ +