mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 17:11:23 -05:00
Add versioning system tables
This commit is contained in:
212
src/components/versioning/EntityVersionHistory.tsx
Normal file
212
src/components/versioning/EntityVersionHistory.tsx
Normal file
@@ -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<string[]>([]);
|
||||
const [compareDialogOpen, setCompareDialogOpen] = useState(false);
|
||||
const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false);
|
||||
const [selectedVersionForRollback, setSelectedVersionForRollback] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No version history available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Version History</h3>
|
||||
<p className="text-sm text-muted-foreground">{versions.length} versions</p>
|
||||
</div>
|
||||
|
||||
{selectedVersions.length === 2 && (
|
||||
<Button onClick={handleCompare} size="sm">
|
||||
<GitCompare className="h-4 w-4 mr-2" />
|
||||
Compare Selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="relative space-y-4">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-6 top-0 bottom-0 w-px bg-border" />
|
||||
|
||||
{versions.map((version, index) => (
|
||||
<Card
|
||||
key={version.id}
|
||||
className={`relative pl-16 pr-4 py-4 cursor-pointer transition-colors ${
|
||||
selectedVersions.includes(version.id)
|
||||
? 'border-primary bg-accent'
|
||||
: 'hover:border-primary/50'
|
||||
} ${version.is_current ? 'border-primary shadow-md' : ''}`}
|
||||
onClick={() => handleVersionSelect(version.id)}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={`absolute left-4 top-4 w-5 h-5 rounded-full border-4 border-background ${
|
||||
version.is_current
|
||||
? 'bg-primary'
|
||||
: 'bg-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Badge variant="outline" className={changeTypeColors[version.change_type]}>
|
||||
{version.change_type}
|
||||
</Badge>
|
||||
<span className="font-medium">Version {version.version_number}</span>
|
||||
{version.is_current && (
|
||||
<Badge variant="default">Current</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!version.is_current && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRollback(version.id);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={version.changer_profile?.avatar_url || undefined} />
|
||||
<AvatarFallback>
|
||||
<User className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{version.changer_profile?.username || 'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{formatDistanceToNow(new Date(version.changed_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
|
||||
{version.submission_id && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>Submission</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Reason */}
|
||||
{version.change_reason && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-sm italic">{version.change_reason}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Dialogs */}
|
||||
{selectedVersions.length === 2 && (
|
||||
<VersionComparisonDialog
|
||||
open={compareDialogOpen}
|
||||
onOpenChange={setCompareDialogOpen}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
fromVersionId={selectedVersions[0]}
|
||||
toVersionId={selectedVersions[1]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedVersionForRollback && (
|
||||
<RollbackDialog
|
||||
open={rollbackDialogOpen}
|
||||
onOpenChange={setRollbackDialogOpen}
|
||||
versionId={selectedVersionForRollback}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
entityName={entityName}
|
||||
onRollback={async (reason) => {
|
||||
await rollbackToVersion(selectedVersionForRollback, reason);
|
||||
setRollbackDialogOpen(false);
|
||||
setSelectedVersionForRollback(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/components/versioning/FieldHistoryDialog.tsx
Normal file
216
src/components/versioning/FieldHistoryDialog.tsx
Normal file
@@ -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<FieldChange[]>([]);
|
||||
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 <Plus className="h-4 w-4 text-green-500" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-4 w-4 text-red-500" />;
|
||||
case 'modified':
|
||||
return <Edit className="h-4 w-4 text-blue-500" />;
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Field History: {formatFieldName(fieldName)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[500px] pr-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : changes.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{changes.map((change) => (
|
||||
<div key={change.id} className="border rounded-lg p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getChangeIcon(change.change_type)}
|
||||
<Badge variant="outline" className={getChangeBadgeColor(change.change_type)}>
|
||||
{change.change_type}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Version {change.version?.version_number}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{change.version?.changer_profile?.username}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{formatDistanceToNow(new Date(change.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{change.old_value !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground font-medium">Previous Value</div>
|
||||
<div className="p-3 rounded-md bg-muted font-mono text-xs whitespace-pre-wrap">
|
||||
{formatValue(change.old_value)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.new_value !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground font-medium">New Value</div>
|
||||
<div
|
||||
className={`p-3 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||
change.change_type === 'added'
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400 font-semibold'
|
||||
: change.change_type === 'modified'
|
||||
? 'bg-blue-500/10 text-blue-700 dark:text-blue-400 font-semibold'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{formatValue(change.new_value)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No history found for this field</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
113
src/components/versioning/HistoricalEntityCard.tsx
Normal file
113
src/components/versioning/HistoricalEntityCard.tsx
Normal file
@@ -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 (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{entity.name}
|
||||
<Badge variant="secondary" className="bg-gray-500/10">
|
||||
Historical
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 mt-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{entity.location?.city && `${entity.location.city}, `}
|
||||
{entity.location?.country || entity.location?.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Operating Dates */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Operated:</span>
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{entity.operated_from && format(new Date(entity.operated_from), 'MMM d, yyyy')}
|
||||
{' - '}
|
||||
{entity.operated_until && format(new Date(entity.operated_until), 'MMM d, yyyy')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Closure/Removal Reason */}
|
||||
{reason && (
|
||||
<div className="p-3 rounded-md bg-muted">
|
||||
<div className="text-xs text-muted-foreground font-medium mb-1">
|
||||
{type === 'park' ? 'Closure Reason' : 'Removal Reason'}
|
||||
</div>
|
||||
<p className="text-sm">{reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Successor/Relocation Info */}
|
||||
{hasSuccessor && successorInfo && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-primary/5 border border-primary/20">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm">
|
||||
{type === 'park' ? 'Succeeded by' : 'Relocated to'}:
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary"
|
||||
onClick={() => {
|
||||
window.location.href = `/${type}s/${successorInfo.slug}`;
|
||||
}}
|
||||
>
|
||||
{successorInfo.name}
|
||||
<ArrowRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Details Button */}
|
||||
{onViewDetails && (
|
||||
<Button variant="outline" className="w-full" onClick={onViewDetails}>
|
||||
View Full History
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
92
src/components/versioning/RollbackDialog.tsx
Normal file
92
src/components/versioning/RollbackDialog.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restore Previous Version</DialogTitle>
|
||||
<DialogDescription>
|
||||
You are about to restore "{entityName}" to a previous version. This will create a new version with the restored data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="default" className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-900 dark:text-yellow-200">
|
||||
This action will restore the entity to its previous state. The current version will be preserved in history.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rollback-reason">Reason for rollback *</Label>
|
||||
<Textarea
|
||||
id="rollback-reason"
|
||||
placeholder="Explain why you're rolling back to this version..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This reason will be logged in the version history for audit purposes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRollback}
|
||||
disabled={!reason.trim() || loading}
|
||||
>
|
||||
{loading ? 'Restoring...' : 'Restore Version'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
157
src/components/versioning/VersionComparisonDialog.tsx
Normal file
157
src/components/versioning/VersionComparisonDialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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 { Separator } from '@/components/ui/separator';
|
||||
import { ArrowRight, Plus, Minus, Edit } from 'lucide-react';
|
||||
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||
|
||||
interface VersionComparisonDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
fromVersionId: string;
|
||||
toVersionId: string;
|
||||
}
|
||||
|
||||
export function VersionComparisonDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
entityType,
|
||||
entityId,
|
||||
fromVersionId,
|
||||
toVersionId,
|
||||
}: VersionComparisonDialogProps) {
|
||||
const { versions, compareVersions } = useEntityVersions(entityType, entityId);
|
||||
const [diff, setDiff] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fromVersion = versions.find(v => v.id === fromVersionId);
|
||||
const toVersion = versions.find(v => v.id === toVersionId);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDiff = async () => {
|
||||
if (open && fromVersionId && toVersionId) {
|
||||
setLoading(true);
|
||||
const result = await compareVersions(fromVersionId, toVersionId);
|
||||
setDiff(result);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDiff();
|
||||
}, [open, fromVersionId, toVersionId]);
|
||||
|
||||
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 = (fieldName: string): string => {
|
||||
return fieldName
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Compare Versions</DialogTitle>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">Version {fromVersion?.version_number}</Badge>
|
||||
<span>{new Date(fromVersion?.changed_at || '').toLocaleString()}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">Version {toVersion?.version_number}</Badge>
|
||||
<span>{new Date(toVersion?.changed_at || '').toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[500px] pr-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : diff && Object.keys(diff).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(diff).map(([fieldName, changes]: [string, any]) => {
|
||||
const isAdded = changes.from === null;
|
||||
const isRemoved = changes.to === null;
|
||||
const isModified = changes.from !== null && changes.to !== null;
|
||||
|
||||
return (
|
||||
<div key={fieldName} className="border rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdded && <Plus className="h-4 w-4 text-green-500" />}
|
||||
{isRemoved && <Minus className="h-4 w-4 text-red-500" />}
|
||||
{isModified && <Edit className="h-4 w-4 text-blue-500" />}
|
||||
<span className="font-medium">{formatFieldName(fieldName)}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
isAdded
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: isRemoved
|
||||
? 'bg-red-500/10 text-red-700 dark:text-red-400'
|
||||
: 'bg-blue-500/10 text-blue-700 dark:text-blue-400'
|
||||
}
|
||||
>
|
||||
{isAdded ? 'Added' : isRemoved ? 'Removed' : 'Modified'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* From value */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground font-medium">Before</div>
|
||||
<div
|
||||
className={`p-3 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||
isRemoved
|
||||
? 'bg-red-500/10 text-red-700 dark:text-red-400 line-through'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{formatValue(changes.from)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* To value */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground font-medium">After</div>
|
||||
<div
|
||||
className={`p-3 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||
isAdded
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400 font-semibold'
|
||||
: isModified
|
||||
? 'bg-blue-500/10 text-blue-700 dark:text-blue-400 font-semibold'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{formatValue(changes.to)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No differences found between these versions</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user