turash/bugulma/frontend/components/ui/Timeline.tsx
2025-12-15 10:06:41 +01:00

170 lines
5.9 KiB
TypeScript

import { motion } from 'framer-motion';
import React from 'react';
import { useTranslation } from '@/hooks/useI18n';
import { CheckCircle, Clock, User } from 'lucide-react';
import { Flex, Stack } from '@/components/ui/layout';
export interface TimelineEntry {
id: string;
timestamp: string | Date;
title: string;
description?: string;
actor?: string;
action?: string;
status?: string;
oldValue?: string;
newValue?: string;
}
interface TimelineProps {
entries: TimelineEntry[];
className?: string;
}
const Timeline: React.FC<TimelineProps> = ({ entries, className = '' }) => {
const { t } = useTranslation();
const getActionIcon = (action?: string) => {
switch (action?.toLowerCase()) {
case 'status_change':
return <CheckCircle className="h-4 h-5 text-current text-success w-4 w-5" />;
case 'comment':
return <User className="h-4 h-5 text-current text-primary w-4 w-5" />;
case 'update':
return <Clock className="h-4 h-5 text-current text-warning w-4 w-5" />;
default:
return <Clock className="h-4 h-5 text-current text-muted-foreground w-4 w-5" />;
}
};
const getActionColor = (action?: string) => {
switch (action?.toLowerCase()) {
case 'status_change':
return 'border-success/20 bg-success/10';
case 'comment':
return 'border-primary/20 bg-primary/10';
case 'update':
return 'border-warning/20 bg-warning/10';
default:
return 'border-muted bg-muted/30';
}
};
const formatTimestamp = (timestamp: string | Date) => {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
let relative: string;
if (diffMinutes < 1) {
relative = 'just now';
} else if (diffMinutes < 60) {
relative = `${diffMinutes} minutes ago`;
} else if (diffHours < 24) {
relative = `${diffHours} hours ago`;
} else if (diffDays < 7) {
relative = `${diffDays} days ago`;
} else {
relative = date.toLocaleDateString();
}
const absolute = date.toLocaleString();
return { relative, absolute };
};
if (!entries || entries.length === 0) {
return (
<div className={`text-center py-8 text-muted-foreground ${className}`}>
<Clock className="h-12 h-4 mb-4 mx-auto opacity-50 text-current w-12 w-4" />
<p>{t('timeline.noEntries', 'No timeline entries')}</p>
</div>
);
}
// Sort entries by timestamp (most recent first)
const sortedEntries = [...entries].sort((a, b) => {
const dateA = typeof a.timestamp === 'string' ? new Date(a.timestamp) : a.timestamp;
const dateB = typeof b.timestamp === 'string' ? new Date(b.timestamp) : b.timestamp;
return dateB.getTime() - dateA.getTime();
});
return (
<div className={`relative ${className}`}>
{/* Timeline line */}
<div className="absolute left-6 top-0 bottom-0 w-px bg-border" />
<Stack spacing="md">
{sortedEntries.map((entry, index) => {
const timestamp = formatTimestamp(entry.timestamp);
return (
<motion.div
key={entry.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="relative flex gap-4"
>
{/* Timeline dot */}
<div
className={`relative z-10 flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 ${getActionColor(entry.action)}`}
>
{getActionIcon(entry.action)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-8">
<div className="rounded-lg border bg-card p-4 shadow-sm">
<Flex align="start" justify="between" className="mb-2">
<h4 className="font-medium text-foreground">{entry.title}</h4>
<time
className="text-xs text-muted-foreground shrink-0 ml-2"
title={timestamp.absolute}
>
{timestamp.relative}
</time>
</Flex>
{entry.description && (
<p className="text-sm text-muted-foreground mb-2">{entry.description}</p>
)}
{entry.actor && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-2">
<User className="h-3 h-4 text-current w-3 w-4" />
<span>{entry.actor}</span>
</div>
)}
{/* Status change details */}
{entry.action === 'status_change' && entry.oldValue && entry.newValue && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Status:</span>
<span className="line-through text-destructive">{entry.oldValue}</span>
<span className="text-muted-foreground"></span>
<span className="font-medium text-success">{entry.newValue}</span>
</div>
)}
{/* Notes */}
{entry.oldValue && entry.action !== 'status_change' && (
<div className="text-xs text-muted-foreground mt-2 p-2 bg-muted/50 rounded">
{entry.oldValue}
</div>
)}
</div>
</div>
</motion.div>
);
})}
</Stack>
</div>
);
};
export default React.memo(Timeline);