Files
mkd/components/NotificationPanel.tsx
2026-02-04 00:17:04 +05:00

145 lines
5.2 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { Bell, CheckCheck, Loader2 } from 'lucide-react';
import { backendApi, NotificationItem } from '../services/apiClient';
function formatNotificationDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин. назад`;
if (diff < 86400000) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
interface NotificationPanelProps {
isOpen: boolean;
onClose: () => void;
unreadCount: number;
onUnreadCountChange: (count: number) => void;
onNavigate: (entityType: string, entityId: string) => void;
}
export const NotificationPanel: React.FC<NotificationPanelProps> = ({
isOpen,
onClose,
unreadCount,
onUnreadCountChange,
onNavigate,
}) => {
const [list, setList] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const [readAllLoading, setReadAllLoading] = useState(false);
const fetchList = useCallback(async () => {
setLoading(true);
try {
const data = await backendApi.getNotifications({ limit: 50 });
setList(Array.isArray(data) ? data : []);
} catch {
setList([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
fetchList();
}
}, [isOpen, fetchList]);
const handleMarkRead = useCallback(
async (id: number) => {
try {
await backendApi.markNotificationRead(id);
setList((prev) => prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)));
onUnreadCountChange(Math.max(0, unreadCount - 1));
} catch {
// ignore
}
},
[unreadCount, onUnreadCountChange]
);
const handleReadAll = useCallback(async () => {
setReadAllLoading(true);
try {
await backendApi.markAllNotificationsRead();
setList((prev) => prev.map((n) => ({ ...n, readAt: n.readAt || new Date().toISOString() })));
onUnreadCountChange(0);
} catch {
// ignore
} finally {
setReadAllLoading(false);
}
}, [onUnreadCountChange]);
const handleItemClick = useCallback(
(item: NotificationItem) => {
if (item.entityType && item.entityId) {
onNavigate(item.entityType, item.entityId);
onClose();
if (!item.readAt) {
handleMarkRead(item.id);
}
}
},
[onNavigate, onClose, handleMarkRead]
);
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} aria-hidden="true" />
<div
className="absolute right-0 top-full mt-2 w-80 max-w-[calc(100vw-2rem)] bg-white rounded-xl shadow-2xl border border-slate-200 z-50 animate-fade-in max-h-[70vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white rounded-t-xl px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-800">Уведомления</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleReadAll}
disabled={readAllLoading}
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 hover:text-primary-700 disabled:opacity-50"
>
{readAllLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCheck className="w-3.5 h-3.5" />}
Прочитать всё
</button>
)}
</div>
<div className="overflow-y-auto flex-1 p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-primary-400 animate-spin" />
</div>
) : list.length === 0 ? (
<div className="py-8 text-center text-slate-500 text-sm">Нет уведомлений</div>
) : (
<ul className="space-y-1">
{list.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => handleItemClick(item)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
item.readAt ? 'text-slate-600 hover:bg-slate-50' : 'bg-primary-50/50 text-slate-800 hover:bg-primary-50'
}`}
>
<p className="text-xs font-bold text-slate-900 truncate">{item.title}</p>
{item.body && <p className="text-[11px] text-slate-500 mt-0.5 line-clamp-2">{item.body}</p>}
<p className="text-[10px] text-slate-400 mt-1">{formatNotificationDate(item.createdAt)}</p>
</button>
</li>
))}
</ul>
)}
</div>
</div>
</>
);
};