2025-12-03 21:56:50 +00:00
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
getTrackHistory,
|
|
|
|
|
TrackHistory as TrackHistoryItem,
|
|
|
|
|
TrackHistoryError,
|
|
|
|
|
TrackHistoryAction,
|
|
|
|
|
} from '../services/trackHistoryService';
|
|
|
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|
|
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import {
|
|
|
|
|
History,
|
|
|
|
|
Calendar,
|
|
|
|
|
Plus,
|
|
|
|
|
Edit,
|
|
|
|
|
Trash2,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* TrackHistory Component
|
|
|
|
|
* T0330: Composant pour afficher l'historique des modifications d'un track avec timeline
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
interface TrackHistoryProps {
|
2025-12-13 02:34:34 +00:00
|
|
|
trackId: string;
|
2025-12-03 21:56:50 +00:00
|
|
|
className?: string;
|
|
|
|
|
limit?: number;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
export function TrackHistory({
|
|
|
|
|
trackId,
|
|
|
|
|
className,
|
|
|
|
|
limit = 50,
|
|
|
|
|
}: TrackHistoryProps) {
|
2025-12-03 21:56:50 +00:00
|
|
|
const [history, setHistory] = useState<TrackHistoryItem[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [total, setTotal] = useState(0);
|
|
|
|
|
const [currentOffset, setCurrentOffset] = useState(0);
|
|
|
|
|
const [currentLimit] = useState(limit);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadHistory();
|
|
|
|
|
}, [trackId, currentOffset]);
|
|
|
|
|
|
|
|
|
|
const loadHistory = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const data = await getTrackHistory(trackId, {
|
|
|
|
|
limit: currentLimit,
|
|
|
|
|
offset: currentOffset,
|
|
|
|
|
});
|
|
|
|
|
setHistory(data.history);
|
|
|
|
|
setTotal(data.total);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof TrackHistoryError) {
|
|
|
|
|
setError(err.message);
|
|
|
|
|
} else {
|
2025-12-13 02:34:34 +00:00
|
|
|
setError("Impossible de charger l'historique");
|
2025-12-03 21:56:50 +00:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
return new Intl.DateTimeFormat('fr-FR', {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'long',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
}).format(date);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActionIcon = (action: TrackHistoryAction) => {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'created':
|
|
|
|
|
return Plus;
|
|
|
|
|
case 'updated':
|
|
|
|
|
return Edit;
|
|
|
|
|
case 'deleted':
|
|
|
|
|
return Trash2;
|
|
|
|
|
case 'published':
|
|
|
|
|
return Eye;
|
|
|
|
|
case 'unpublished':
|
|
|
|
|
return EyeOff;
|
|
|
|
|
case 'restored':
|
|
|
|
|
return RotateCcw;
|
|
|
|
|
default:
|
|
|
|
|
return History;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActionLabel = (action: TrackHistoryAction): string => {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'created':
|
|
|
|
|
return 'Créé';
|
|
|
|
|
case 'updated':
|
|
|
|
|
return 'Modifié';
|
|
|
|
|
case 'deleted':
|
|
|
|
|
return 'Supprimé';
|
|
|
|
|
case 'published':
|
|
|
|
|
return 'Publié';
|
|
|
|
|
case 'unpublished':
|
|
|
|
|
return 'Dépublié';
|
|
|
|
|
case 'restored':
|
|
|
|
|
return 'Restauré';
|
|
|
|
|
default:
|
|
|
|
|
return action;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActionColor = (action: TrackHistoryAction): string => {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'created':
|
|
|
|
|
return 'text-green-600 bg-green-50';
|
|
|
|
|
case 'updated':
|
|
|
|
|
return 'text-blue-600 bg-blue-50';
|
|
|
|
|
case 'deleted':
|
|
|
|
|
return 'text-red-600 bg-red-50';
|
|
|
|
|
case 'published':
|
|
|
|
|
return 'text-purple-600 bg-purple-50';
|
|
|
|
|
case 'unpublished':
|
|
|
|
|
return 'text-orange-600 bg-orange-50';
|
|
|
|
|
case 'restored':
|
|
|
|
|
return 'text-cyan-600 bg-cyan-50';
|
|
|
|
|
default:
|
|
|
|
|
return 'text-gray-600 bg-gray-50';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseValue = (value?: string): any => {
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(value);
|
|
|
|
|
} catch {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatValue = (value: any): string => {
|
|
|
|
|
if (value === null || value === undefined) return '';
|
|
|
|
|
if (typeof value === 'string') return value;
|
|
|
|
|
if (typeof value === 'object') {
|
|
|
|
|
return JSON.stringify(value, null, 2);
|
|
|
|
|
}
|
|
|
|
|
return String(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePreviousPage = () => {
|
|
|
|
|
if (currentOffset > 0) {
|
|
|
|
|
setCurrentOffset(Math.max(0, currentOffset - currentLimit));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleNextPage = () => {
|
|
|
|
|
if (currentOffset + currentLimit < total) {
|
|
|
|
|
setCurrentOffset(currentOffset + currentLimit);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn('flex items-center justify-center p-4', className)}>
|
|
|
|
|
<LoadingSpinner size="sm" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn('p-4', className)}>
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
<AlertDescription>{error}</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasPreviousPage = currentOffset > 0;
|
|
|
|
|
const hasNextPage = currentOffset + currentLimit < total;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn('space-y-4', className)}>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<History className="h-5 w-5" />
|
2025-12-13 02:34:34 +00:00
|
|
|
<h3 className="text-lg font-semibold">
|
|
|
|
|
Historique des modifications
|
|
|
|
|
</h3>
|
2025-12-03 21:56:50 +00:00
|
|
|
{total > 0 && (
|
|
|
|
|
<span className="text-sm text-muted-foreground">({total})</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{history.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
|
|
|
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
|
|
|
<p>Aucune modification enregistrée</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
{/* Timeline line */}
|
|
|
|
|
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
|
|
|
|
|
|
|
|
|
|
{/* Timeline items */}
|
|
|
|
|
<div className="space-y-6">
|
2025-12-13 02:34:34 +00:00
|
|
|
{history.map((item) => {
|
2025-12-03 21:56:50 +00:00
|
|
|
const Icon = getActionIcon(item.action);
|
|
|
|
|
const actionColor = getActionColor(item.action);
|
|
|
|
|
const oldValue = parseValue(item.old_value);
|
|
|
|
|
const newValue = parseValue(item.new_value);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={item.id} className="relative flex gap-4">
|
|
|
|
|
{/* Timeline dot */}
|
2025-12-13 02:34:34 +00:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 border-background',
|
|
|
|
|
actionColor,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2025-12-03 21:56:50 +00:00
|
|
|
<Icon className="h-5 w-5" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
<div className="flex-1 space-y-2 pb-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2025-12-13 02:34:34 +00:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-sm font-semibold',
|
|
|
|
|
actionColor.split(' ')[0],
|
|
|
|
|
)}
|
|
|
|
|
>
|
2025-12-03 21:56:50 +00:00
|
|
|
{getActionLabel(item.action)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
#{item.id}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
|
<Calendar className="h-3 w-3" />
|
|
|
|
|
<span>{formatDate(item.created_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Values comparison */}
|
|
|
|
|
{(oldValue !== null || newValue !== null) && (
|
|
|
|
|
<div className="space-y-2 rounded-lg border bg-muted/50 p-3 text-sm">
|
|
|
|
|
{oldValue !== null && (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
|
|
|
|
Ancienne valeur:
|
|
|
|
|
</div>
|
|
|
|
|
<pre className="text-xs bg-background rounded p-2 overflow-x-auto">
|
|
|
|
|
{formatValue(oldValue)}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{newValue !== null && (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
|
|
|
|
Nouvelle valeur:
|
|
|
|
|
</div>
|
|
|
|
|
<pre className="text-xs bg-background rounded p-2 overflow-x-auto">
|
|
|
|
|
{formatValue(newValue)}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Pagination */}
|
|
|
|
|
{total > currentLimit && (
|
|
|
|
|
<div className="flex items-center justify-between border-t pt-4">
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
2025-12-13 02:34:34 +00:00
|
|
|
Affichage {currentOffset + 1} -{' '}
|
|
|
|
|
{Math.min(currentOffset + currentLimit, total)} sur {total}
|
2025-12-03 21:56:50 +00:00
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handlePreviousPage}
|
|
|
|
|
disabled={!hasPreviousPage}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
|
|
|
Précédent
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleNextPage}
|
|
|
|
|
disabled={!hasNextPage}
|
|
|
|
|
>
|
|
|
|
|
Suivant
|
|
|
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|