2026-05-26 05:15:10 +00:00
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
2026-05-27 03:17:46 +00:00
|
|
|
|
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
|
|
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
2026-05-26 05:15:10 +00:00
|
|
|
|
import { supabase } from '../supabase/client';
|
|
|
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
|
|
|
|
|
|
|
|
interface Notification {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
type: 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system';
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
is_read: boolean;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
plan_id?: string | null;
|
|
|
|
|
|
sender_company_name?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const typeConfig: Record<string, { icon: any; color: string; bgColor: string }> = {
|
|
|
|
|
|
step_completed: { icon: Check, color: 'text-emerald-600', bgColor: 'bg-emerald-50' },
|
|
|
|
|
|
plan_created: { icon: FileText, color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
|
|
|
|
|
plan_updated: { icon: AlertCircle, color: 'text-amber-600', bgColor: 'bg-amber-50' },
|
2026-05-27 03:17:46 +00:00
|
|
|
|
plan_confirmed: { icon: Check, color: 'text-emerald-600', bgColor: 'bg-emerald-50' },
|
|
|
|
|
|
plan_changed: { icon: Edit3, color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
|
|
|
|
|
plan_cancelled: { icon: X, color: 'text-red-600', bgColor: 'bg-red-50' },
|
|
|
|
|
|
plan_rejected: { icon: AlertCircle, color: 'text-rose-600', bgColor: 'bg-rose-50' },
|
2026-05-26 05:15:10 +00:00
|
|
|
|
inventory_in: { icon: Package, color: 'text-indigo-600', bgColor: 'bg-indigo-50' },
|
|
|
|
|
|
payment_pending: { icon: CreditCard, color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
|
|
|
|
|
system: { icon: Bell, color: 'text-gray-600', bgColor: 'bg-gray-50' }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-27 03:17:46 +00:00
|
|
|
|
interface NotificationCenterProps {
|
2026-05-28 05:54:23 +00:00
|
|
|
|
theme?: 'amber' | 'emerald' | 'purple' | 'blue' | 'violet';
|
2026-05-27 03:17:46 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const themeConfig = {
|
|
|
|
|
|
amber: { bg: 'bg-amber-100', hover: 'hover:bg-amber-200', icon: 'text-amber-700' },
|
|
|
|
|
|
emerald: { bg: 'bg-emerald-100', hover: 'hover:bg-emerald-200', icon: 'text-emerald-700' },
|
|
|
|
|
|
purple: { bg: 'bg-purple-100', hover: 'hover:bg-purple-200', icon: 'text-purple-700' },
|
|
|
|
|
|
blue: { bg: 'bg-blue-100', hover: 'hover:bg-blue-200', icon: 'text-blue-700' },
|
2026-05-28 05:54:23 +00:00
|
|
|
|
violet: { bg: 'bg-violet-100', hover: 'hover:bg-violet-200', icon: 'text-violet-500' },
|
2026-05-27 03:17:46 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps = {}) {
|
2026-05-26 05:15:10 +00:00
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
|
|
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
|
|
|
|
const { auth } = useAuth();
|
2026-05-27 03:17:46 +00:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const colors = themeConfig[theme];
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!auth.user) return;
|
|
|
|
|
|
fetchNotifications();
|
2026-05-28 05:54:23 +00:00
|
|
|
|
const channel = subscribeToNotifications();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
channel.unsubscribe();
|
|
|
|
|
|
};
|
2026-05-26 05:15:10 +00:00
|
|
|
|
}, [auth.user]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchNotifications = async () => {
|
|
|
|
|
|
const { data } = await supabase
|
|
|
|
|
|
.from('notifications')
|
|
|
|
|
|
.select('*, sender_company_id(name)')
|
|
|
|
|
|
.eq('recipient_id', auth.user!.id)
|
|
|
|
|
|
.order('created_at', { ascending: false })
|
|
|
|
|
|
.limit(20);
|
|
|
|
|
|
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
const formatted = data.map(n => ({
|
|
|
|
|
|
id: n.id,
|
|
|
|
|
|
type: n.type as Notification['type'],
|
|
|
|
|
|
title: n.title,
|
|
|
|
|
|
content: n.content,
|
|
|
|
|
|
is_read: n.is_read || false,
|
|
|
|
|
|
created_at: n.created_at || '',
|
|
|
|
|
|
plan_id: n.plan_id,
|
|
|
|
|
|
sender_company_name: n.sender_company_id?.name
|
|
|
|
|
|
}));
|
|
|
|
|
|
setNotifications(formatted);
|
|
|
|
|
|
setUnreadCount(formatted.filter(n => !n.is_read).length);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const subscribeToNotifications = () => {
|
2026-05-28 05:54:23 +00:00
|
|
|
|
const channel = supabase.channel('notifications');
|
|
|
|
|
|
|
|
|
|
|
|
channel
|
2026-05-26 05:15:10 +00:00
|
|
|
|
.on('postgres_changes', {
|
|
|
|
|
|
event: 'INSERT',
|
|
|
|
|
|
schema: 'public',
|
|
|
|
|
|
table: 'notifications',
|
|
|
|
|
|
filter: `recipient_id=eq.${auth.user!.id}`
|
|
|
|
|
|
}, () => {
|
|
|
|
|
|
fetchNotifications();
|
|
|
|
|
|
})
|
2026-05-28 05:54:23 +00:00
|
|
|
|
.subscribe((status) => {
|
|
|
|
|
|
if (status === 'SUBSCRIBED') {
|
|
|
|
|
|
console.log('Successfully subscribed to notifications');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return channel;
|
2026-05-26 05:15:10 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const markAsRead = async (id: string) => {
|
|
|
|
|
|
await supabase
|
|
|
|
|
|
.from('notifications')
|
|
|
|
|
|
.update({ is_read: true, read_at: new Date().toISOString() })
|
|
|
|
|
|
.eq('id', id);
|
|
|
|
|
|
|
|
|
|
|
|
setNotifications(prev => prev.map(n =>
|
|
|
|
|
|
n.id === id ? { ...n, is_read: true } : n
|
|
|
|
|
|
));
|
|
|
|
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const markAllAsRead = async () => {
|
|
|
|
|
|
await supabase
|
|
|
|
|
|
.from('notifications')
|
|
|
|
|
|
.update({ is_read: true, read_at: new Date().toISOString() })
|
|
|
|
|
|
.eq('recipient_id', auth.user!.id)
|
|
|
|
|
|
.eq('is_read', false);
|
|
|
|
|
|
|
|
|
|
|
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
|
|
|
|
|
setUnreadCount(0);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatTime = (date: string) => {
|
|
|
|
|
|
const d = new Date(date);
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const diff = now.getTime() - d.getTime();
|
|
|
|
|
|
const minutes = Math.floor(diff / 60000);
|
|
|
|
|
|
const hours = Math.floor(diff / 3600000);
|
|
|
|
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
|
|
|
|
|
|
|
|
if (minutes < 1) return '刚刚';
|
|
|
|
|
|
if (minutes < 60) return `${minutes}分钟前`;
|
|
|
|
|
|
if (hours < 24) return `${hours}小时前`;
|
|
|
|
|
|
if (days < 7) return `${days}天前`;
|
|
|
|
|
|
return d.toLocaleDateString('zh-CN');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* 通知按钮 */}
|
|
|
|
|
|
<motion.button
|
|
|
|
|
|
whileHover={{ scale: 1.05 }}
|
|
|
|
|
|
whileTap={{ scale: 0.95 }}
|
|
|
|
|
|
onClick={() => setIsOpen(true)}
|
2026-05-27 03:17:46 +00:00
|
|
|
|
className={`relative flex items-center justify-center w-10 h-10 rounded-xl ${colors.bg} ${colors.hover} transition-colors`}
|
2026-05-26 05:15:10 +00:00
|
|
|
|
>
|
2026-05-27 03:17:46 +00:00
|
|
|
|
<Bell className={`w-5 h-5 ${colors.icon} flex-shrink-0`} strokeWidth={2} />
|
2026-05-26 05:15:10 +00:00
|
|
|
|
{unreadCount > 0 && (
|
|
|
|
|
|
<motion.span
|
|
|
|
|
|
initial={{ scale: 0 }}
|
|
|
|
|
|
animate={{ scale: 1 }}
|
2026-05-27 03:17:46 +00:00
|
|
|
|
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center border-2 border-white"
|
2026-05-26 05:15:10 +00:00
|
|
|
|
>
|
|
|
|
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
|
|
|
|
</motion.span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</motion.button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 通知弹窗 */}
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{isOpen && (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0 }}
|
2026-05-27 03:17:46 +00:00
|
|
|
|
className="fixed inset-0 z-[9999] flex items-start justify-end p-4"
|
2026-05-26 05:15:10 +00:00
|
|
|
|
onClick={() => setIsOpen(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
|
|
|
|
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
|
|
|
|
|
transition={{ duration: 0.2 }}
|
|
|
|
|
|
className="bg-white rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden mt-16 mr-4"
|
|
|
|
|
|
onClick={e => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 头部 */}
|
|
|
|
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Bell className="w-5 h-5 text-amber-500" />
|
|
|
|
|
|
<h2 className="font-semibold text-gray-900">通知中心</h2>
|
|
|
|
|
|
{unreadCount > 0 && (
|
|
|
|
|
|
<span className="px-2 py-0.5 bg-red-100 text-red-600 text-xs rounded-full">
|
|
|
|
|
|
{unreadCount} 未读
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{unreadCount > 0 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={markAllAsRead}
|
|
|
|
|
|
className="text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CheckCheck className="w-3 h-3" />
|
|
|
|
|
|
全部已读
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setIsOpen(false)}
|
|
|
|
|
|
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="w-5 h-5 text-gray-400" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 通知列表 */}
|
|
|
|
|
|
<div className="overflow-y-auto max-h-[60vh]">
|
|
|
|
|
|
{notifications.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-12 text-gray-400">
|
|
|
|
|
|
<Bell className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
|
|
|
|
|
<p>暂无通知</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="divide-y divide-gray-100">
|
|
|
|
|
|
{notifications.map((notification, index) => {
|
|
|
|
|
|
const config = typeConfig[notification.type] || typeConfig.system;
|
|
|
|
|
|
const Icon = config.icon;
|
|
|
|
|
|
|
2026-05-27 03:17:46 +00:00
|
|
|
|
const handleNotificationClick = async () => {
|
|
|
|
|
|
await markAsRead(notification.id);
|
|
|
|
|
|
// 如果有 plan_id,跳转到计划总览页面
|
|
|
|
|
|
if (notification.plan_id) {
|
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
|
// 根据用户角色跳转到对应页面
|
|
|
|
|
|
const role = auth.company?.role;
|
|
|
|
|
|
if (role === 'purchaser') {
|
|
|
|
|
|
navigate('/purchaser/plans');
|
|
|
|
|
|
} else if (role === 'textile') {
|
|
|
|
|
|
navigate('/textile/plans');
|
|
|
|
|
|
} else if (role === 'washing') {
|
|
|
|
|
|
navigate('/washing/plans');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 05:15:10 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
key={notification.id}
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: index * 0.05 }}
|
2026-05-27 03:17:46 +00:00
|
|
|
|
onClick={handleNotificationClick}
|
2026-05-26 05:15:10 +00:00
|
|
|
|
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
|
|
|
|
|
|
!notification.is_read ? 'bg-blue-50/30' : ''
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${config.bgColor}`}>
|
|
|
|
|
|
<Icon className={`w-5 h-5 ${config.color}`} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
|
<h3 className={`font-medium text-sm ${!notification.is_read ? 'text-gray-900' : 'text-gray-600'}`}>
|
|
|
|
|
|
{notification.title}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
{!notification.is_read && (
|
|
|
|
|
|
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1.5" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
|
|
|
|
|
{notification.content}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="flex items-center gap-2 mt-2">
|
|
|
|
|
|
<Clock className="w-3 h-3 text-gray-400" />
|
|
|
|
|
|
<span className="text-xs text-gray-400">
|
|
|
|
|
|
{formatTime(notification.created_at)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{notification.sender_company_name && (
|
|
|
|
|
|
<span className="text-xs text-gray-400">
|
|
|
|
|
|
· {notification.sender_company_name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|