Files
leocrm/frontend/src/components/notifications/NotificationItem.tsx
T

147 lines
4.1 KiB
TypeScript

/**
* NotificationItem — single notification row inside the dropdown.
*
* Props:
* notification: NotificationItem
* onMarkRead: (id: string) => void
*/
import React from 'react';
import clsx from 'clsx';
import {
Info,
Mail,
CheckSquare,
Calendar,
AlertCircle,
User,
FileText,
Bell,
type LucideIcon,
} from 'lucide-react';
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
import { useTranslation } from 'react-i18next';
// ── helpers ──
/**
* Returns a German relative-time string like "vor 5 Min" or "vor 2 Stunden".
* Falls back to "gerade eben" for < 1 min and an absolute date for > 7 days.
*/
function relativeTime(isoDate: string | null | undefined): string {
if (!isoDate) return '';
const now = Date.now();
const then = new Date(isoDate).getTime();
if (Number.isNaN(then)) return '';
const diffMs = now - then;
if (diffMs < 0) return 'gerade eben';
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min`;
const diffHrs = Math.floor(diffMin / 60);
if (diffHrs < 24) return `vor ${diffHrs} Std`;
const diffDays = Math.floor(diffHrs / 24);
if (diffDays < 7) return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
// > 7 days: show absolute date
return new Date(isoDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
/** Map notification type → lucide icon. */
const typeIconMap: Record<string, LucideIcon> = {
info: Info,
email: Mail,
mail: Mail,
task: CheckSquare,
calendar: Calendar,
event: Calendar,
alert: AlertCircle,
warning: AlertCircle,
error: AlertCircle,
contact: User,
user: User,
document: FileText,
file: FileText,
};
function getIconForType(type: string): LucideIcon {
return typeIconMap[type] ?? Bell;
}
// ── component ──
export interface NotificationItemProps {
notification: NotificationItemType;
onMarkRead: (id: string) => void;
}
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const { t } = useTranslation();
const isUnread = notification.read_at == null;
const Icon = getIconForType(notification.type);
const handleClick = () => {
if (isUnread) {
onMarkRead(notification.id);
}
};
return (
<button
onClick={handleClick}
className={clsx(
'w-full text-left flex items-start gap-3 px-3 py-2.5 hover:bg-secondary-50 transition-colors cursor-pointer',
'border-b border-secondary-100 last:border-b-0 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isUnread && 'bg-primary-50/40',
)}
role="menuitem"
aria-label={notification.title}
>
{/* Icon */}
<span
className={clsx(
'flex-shrink-0 mt-0.5 w-8 h-8 rounded-full flex items-center justify-center',
isUnread ? 'bg-primary-100 text-primary-600' : 'bg-secondary-100 text-secondary-500',
)}
aria-hidden="true"
>
<Icon className="w-4 h-4" strokeWidth={2} />
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={clsx(
'text-sm truncate',
isUnread ? 'font-semibold text-secondary-900' : 'font-medium text-secondary-700',
)}
>
{notification.title}
</p>
{isUnread && (
<span
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
aria-label={t('notificationItem.ungelesen')}
title={t('notificationItem.ungelesen')}
/>
)}
</div>
{notification.body && (
<p className="text-xs text-secondary-500 truncate mt-0.5">
{notification.body}
</p>
)}
{notification.created_at && (
<p className="text-xs text-secondary-400 mt-1">
{relativeTime(notification.created_at)}
</p>
)}
</div>
</button>
);
}