iloom-flatten/src/pages/washing/Dashboard.tsx

521 lines
27 KiB
TypeScript
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 } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client';
import { motion } from 'framer-motion';
import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, HelpCircle, X, BookOpen, MessageSquare, Info, Camera, FileText, Plus, TrendingUp, Building2 } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter';
import { HelpCenter, HelpButton } from '../../components/HelpCenter';
import { VersionUpdate, useVersionCheck, AboutModal } from '../../components/VersionUpdate';
import { FeedbackModal } from '../../components/FeedbackModal';
import { UserManual } from '../../components/UserManual';
import { CrashReporter } from '../../components/CrashReporter';
import type { WashingPlan } from '../../types';
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any }> = {
pending: { label: '待处理', color: 'text-amber-600', bgColor: 'bg-amber-50', icon: Clock },
processing: { label: '处理中', color: 'text-violet-500', bgColor: 'bg-violet-50', icon: TrendingUp },
completed: { label: '已完成', color: 'text-emerald-600', bgColor: 'bg-emerald-50', icon: Package }
};
const getQuickActions = (isMaster: boolean) => {
const actions = [
{ id: 'plans', icon: FileText, label: '计划总览', path: '/washing/plans', gradient: 'from-violet-400 to-fuchsia-400', description: '查看所有计划' },
{ id: 'pending', icon: Package, label: '待处理坯布', path: '/washing/pending', gradient: 'from-amber-500 to-orange-500', description: '处理待水洗坯布' },
{ id: 'completed', icon: CheckCircle, label: '已完成坯布', path: '/washing/completed', gradient: 'from-emerald-500 to-green-500', description: '查看已完成水洗' },
{ id: 'finished', icon: Warehouse, label: '成品仓库', path: '/washing/finished-warehouse', gradient: 'from-violet-400 to-fuchsia-400', description: '管理成品库存' },
{ id: 'payment', icon: CreditCard, label: '待结款', path: '/washing/payments', gradient: 'from-rose-500 to-pink-500', description: '查看待结款项' }
];
if (isMaster) {
actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-fuchsia-400 to-purple-400', description: '管理团队账号' });
}
return actions;
};
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.1 } }
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4 } }
};
export function WashingDashboard() {
const navigate = useNavigate();
const { auth, logout } = useAuth();
const { isMobile } = useResponsive();
const [plans, setPlans] = useState<WashingPlan[]>([]);
const [loading, setLoading] = useState(true);
// 帮助中心状态
const [showHelp, setShowHelp] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [showUserManual, setShowUserManual] = useState(false);
const [showFeedback, setShowFeedback] = useState(false);
const [showAbout, setShowAbout] = useState(false);
// 版本更新检查
const { showUpdate, setShowUpdate } = useVersionCheck();
// 账户卡片弹窗状态
const [showAccountCard, setShowAccountCard] = useState(false);
// 最近计划轮播索引
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
// 检查是否显示新手引导
useEffect(() => {
if (shouldShowOnboarding('washing')) {
setShowOnboarding(true);
}
}, []);
useEffect(() => {
if (!auth.company) return;
fetchPlans();
}, [auth.company]);
const fetchPlans = async () => {
const { data: washingPlans } = await supabase
.from('washing_plans')
.select('*')
.eq('washing_factory_id', auth.company!.id)
.order('created_at', { ascending: false });
setPlans(washingPlans || []);
setLoading(false);
};
const stats = {
all: plans.length,
producing: plans.filter(p => p.status === 'processing').length,
completed: plans.filter(p => p.status === 'completed').length
};
// 获取进行中的计划用于轮播
const processingPlans = plans.filter(p => p.status === 'processing').slice(0, 5);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-violet-50/40 to-fuchsia-50/30">
{/* Header - 与采购商一致 */}
<header className="bg-white/80 backdrop-blur-xl border-b border-gray-200/50 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-3 sm:py-4 flex justify-between items-center">
<div className="flex items-center gap-2 sm:gap-3">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setShowAccountCard(true)}
className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-violet-400 to-fuchsia-400 rounded-xl flex items-center justify-center shadow-lg shadow-violet-400/20 cursor-pointer overflow-hidden"
>
{(auth.user as any)?.image_url ? (
<img
src={(auth.user as any).image_url}
alt="头像"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Droplets className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
)}
</motion.button>
<div>
<h1 className="text-sm sm:text-lg font-bold text-gray-900 flex items-center gap-2 whitespace-nowrap">
<span className="sm:hidden truncate max-w-[80px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</span>
<span className="hidden sm:inline">{auth.company?.name || '水洗厂'}</span>
<span className="text-[10px] sm:text-xs bg-gradient-to-r from-violet-400 to-fuchsia-400 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span>
</h1>
<p className="text-xs text-gray-500 hidden sm:block truncate max-w-[150px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
</div>
</div>
<div className="flex items-center gap-2">
<HelpButton onClick={() => setShowHelp(true)} />
<NotificationCenter theme="violet" />
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="p-2 sm:px-3 sm:py-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
title="退出登录"
>
<LogOut className="w-5 h-5 sm:hidden" />
<span className="hidden sm:inline text-xs sm:text-sm">退</span>
</button>
</div>
</div>
</header>
<div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-violet-400 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-4 sm:space-y-6">
{/* 统计卡片 - 与采购商一致 */}
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<motion.div
variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
onClick={() => navigate('/washing/plans')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-violet-50 rounded-lg flex items-center justify-center">
<FileText className="w-3 h-3 sm:w-4 sm:h-4 text-violet-500" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.all}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div>
<motion.div
variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
onClick={() => navigate('/washing/plans?filter=processing')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-violet-50 rounded-lg flex items-center justify-center">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-violet-500" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.producing}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div>
<motion.div
variants={itemVariants}
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
onClick={() => navigate('/washing/plans?filter=completed')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<p className="text-xs sm:text-sm text-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-emerald-50 rounded-lg flex items-center justify-center">
<Package className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-600" />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-gray-900">{stats.completed}</p>
<p className="text-xs text-gray-400 mt-0.5 sm:mt-1"></p>
</motion.div>
</div>
{/* 最近计划 - 左右滑动展示 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<div className="flex justify-between items-center mb-3 sm:mb-4">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 whitespace-nowrap"></h2>
<button
onClick={() => navigate('/washing/plans')}
className="text-xs sm:text-sm text-violet-500 hover:text-violet-600 font-medium flex items-center gap-1 whitespace-nowrap"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 rotate-180" />
</button>
</div>
{processingPlans.length === 0 ? (
<div className="text-center py-8 sm:py-12 text-gray-400">
<Package className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"></p>
<button
onClick={() => navigate('/washing/plans')}
className="mt-3 px-4 py-2 bg-violet-400 text-white rounded-lg text-sm hover:bg-violet-500 transition-colors"
>
</button>
</div>
) : (
<div className="relative">
{/* 滑动容器 */}
<div className="overflow-hidden">
<motion.div
className="flex"
animate={{ x: -currentPlanIndex * 100 + '%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{processingPlans.map((plan, i) => {
const status = statusMap[plan.status] || statusMap.pending;
const StatusIcon = status.icon;
return (
<div
key={plan.id}
className="w-full flex-shrink-0 px-1"
onClick={() => navigate(`/washing/plans?id=${plan.id}`)}
>
<div className="group p-3 sm:p-4 rounded-xl border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 cursor-pointer transition-all">
{/* 计划标题行 */}
<div className="flex items-center gap-2 sm:gap-3">
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center ${status.bgColor}`}>
<StatusIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${status.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
<h3 className="font-medium text-gray-900 text-sm sm:text-base truncate">{plan.plan_code}</h3>
<span className={`px-1.5 py-0.5 text-xs rounded-full whitespace-nowrap ${status.bgColor} ${status.color}`}>
{status.label}
</span>
</div>
<p className="text-xs text-gray-500">{plan.planned_meters}</p>
</div>
</div>
</div>
</div>
);
})}
</motion.div>
</div>
{/* 左右滑动按钮 */}
{processingPlans.length > 1 && (
<>
<button
onClick={() => setCurrentPlanIndex(prev => Math.max(0, prev - 1))}
disabled={currentPlanIndex === 0}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => setCurrentPlanIndex(prev => Math.min(processingPlans.length - 1, prev + 1))}
disabled={currentPlanIndex === processingPlans.length - 1}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 w-8 h-8 bg-white shadow-lg rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-gray-600 rotate-180" />
</button>
</>
)}
{/* 指示器 */}
{processingPlans.length > 1 && (
<div className="flex items-center justify-center gap-1.5 mt-4">
{processingPlans.map((_, i) => (
<button
key={i}
onClick={() => setCurrentPlanIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${
i === currentPlanIndex ? 'bg-purple-500 w-4' : 'bg-gray-300 hover:bg-gray-400'
}`}
/>
))}
</div>
)}
</div>
)}
</motion.div>
{/* 快捷操作 - 与采购商一致 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-3 sm:p-4 md:p-6">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-3 sm:mb-4 whitespace-nowrap"></h2>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3">
{getQuickActions(auth.user?.is_master || false).map((action, index) => (
<motion.button
key={action.id}
whileHover={isMobile ? {} : { scale: 1.03, y: -2 }}
whileTap={{ scale: 0.97 }}
onClick={() => navigate(action.path)}
className="group flex flex-col items-center p-2 sm:p-3 rounded-lg sm:rounded-xl border border-gray-100 hover:border-transparent hover:shadow-md transition-all bg-gray-50/50 hover:bg-white"
>
<div className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl flex items-center justify-center mb-1 sm:mb-2 bg-gradient-to-br ${action.gradient} shadow-sm group-hover:shadow-md transition-shadow`}>
<action.icon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div>
<span className="text-[10px] sm:text-xs font-medium text-gray-700 group-hover:text-gray-900 text-center whitespace-nowrap">{action.label}</span>
</motion.button>
))}
</div>
</motion.div>
</motion.div>
)}
</div>
{/* 帮助中心弹窗 */}
<HelpCenter isOpen={showHelp} onClose={() => setShowHelp(false)} />
{/* 新手引导 */}
<OnboardingGuide
role="washing"
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
/>
{/* 用户手册 */}
<UserManual isOpen={showUserManual} onClose={() => setShowUserManual(false)} />
{/* 反馈弹窗 */}
<FeedbackModal isOpen={showFeedback} onClose={() => setShowFeedback(false)} />
{/* 关于窗口 */}
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* 版本更新弹窗 */}
<VersionUpdate
isOpen={showUpdate}
onClose={() => setShowUpdate(false)}
/>
{/* 崩溃报告 */}
<CrashReporter />
{/* 账户卡片弹窗 */}
{showAccountCard && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAccountCard(false)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="bg-gradient-to-r from-violet-400 to-fuchsia-400 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold"></h2>
<button onClick={() => setShowAccountCard(false)} className="p-2 hover:bg-white/20 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* 账户详情 */}
<div className="p-6 space-y-4">
{/* 用户信息 */}
<div className="flex items-center gap-4">
<label className="w-16 h-16 bg-gradient-to-br from-violet-400 to-fuchsia-400 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
{(auth.user as any)?.image_url ? (
<img src={(auth.user as any).image_url} alt="头像" className="w-full h-full object-cover relative z-10" />
) : (
<Droplets className="w-8 h-8 text-white relative z-10" />
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const arrayBuffer = await file.arrayBuffer();
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, arrayBuffer, {
contentType: file.type,
upsert: true
});
if (uploadError) {
console.error('头像上传失败:', uploadError);
alert('头像上传失败: ' + uploadError.message);
return;
}
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
const { error: updateError } = await supabase
.from('profiles')
.update({ image_url: publicUrl } as any)
.eq('id', user.id);
if (updateError) {
console.error('头像更新失败:', updateError);
alert('头像更新失败: ' + updateError.message);
} else {
window.location.reload();
}
} catch (err) {
console.error('头像上传出错:', err);
alert('头像上传出错,请重试');
}
}}
/>
</label>
<div>
<p className="font-semibold text-gray-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
<p className="text-sm text-gray-500">{auth.company?.name || '水洗厂'}</p>
<span className="inline-flex items-center gap-1 text-xs bg-violet-100 text-violet-600 px-2 py-0.5 rounded-full mt-1">
{auth.user?.is_master ? '主账号' : '子账号'}
</span>
</div>
</div>
{/* 功能菜单 */}
<div className="space-y-2 pt-4 border-t border-gray-100">
<button
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-violet-500" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<BookOpen className="w-5 h-5 text-violet-500" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500">使</p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-violet-500" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
<button
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Info className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
</div>
{/* 退出登录 */}
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="w-full py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium"
>
<LogOut className="w-5 h-5" />
退
</button>
</div>
</motion.div>
</div>
)}
</div>
);
}