Chever John c641831251
fix: handle plans API returning array instead of {plans:[]} object
The purchaser plans endpoint returns data as a plain array, not wrapped
in {plans:[...]}. Added Array.isArray check to handle both formats.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 02:07:18 +08:00

471 lines
25 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 { purchaserApi } from '../../api';
import { motion } from 'framer-motion';
import {
FileText,
Plus,
Warehouse,
Factory,
ArrowLeft,
Users,
TrendingUp,
Package,
Clock,
Building2,
CheckCircle2,
DollarSign,
Droplets,
LogOut,
ChevronRight,
Home,
MoreHorizontal,
Calendar,
Hash,
Target,
Activity,
CreditCard
} 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 type { ProductionPlan } from '../../types';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { AccountProfileCard } from '../../components/AccountProfileCard';
import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock },
producing: { label: '生产中', color: 'text-blue-600', bgColor: 'bg-blue-50', borderColor: 'border-blue-200', icon: TrendingUp },
completed: { label: '已完成', color: 'text-emerald-600', bgColor: 'bg-emerald-50', borderColor: 'border-emerald-200', icon: Package }
};
export function PurchaserDashboard() {
const navigate = useNavigate();
const { auth, logout } = useAuth();
const { isMobile } = useResponsive();
const [plans, setPlans] = useState<ProductionPlan[]>([]);
const [loading, setLoading] = useState(true);
const [dataLoaded, setDataLoaded] = useState(false);
// 帮助中心状态
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 [showAccountCard, setShowAccountCard] = useState(false);
// 版本更新检查
const { showUpdate, setShowUpdate } = useVersionCheck();
// 检查是否显示新手引导
useEffect(() => {
if (shouldShowOnboarding('purchaser')) {
setShowOnboarding(true);
}
}, []);
useEffect(() => {
if (!auth.company) {
setLoading(false);
return;
}
// 如果数据已加载,不再重新加载
if (dataLoaded && plans.length > 0) {
setLoading(false);
return;
}
fetchPlans();
}, [auth.company, dataLoaded, plans.length]);
const fetchPlans = async () => {
try {
const res = await purchaserApi.listPlans();
// API may return {plans: [...]} object or plain array
const rawData = res.data;
const allPlans = Array.isArray(rawData) ? rawData : (rawData?.plans || []);
const filteredPlans = allPlans.filter(p =>
!p.process_steps?.some(s => s.status === STEP_STATUS.REJECTED)
);
setPlans(filteredPlans);
} catch (err) {
console.error('Failed to fetch plans:', err);
} finally {
setLoading(false);
setDataLoaded(true);
}
};
// 监听页面可见性变化,返回页面时刷新数据
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && auth.company) {
// 页面重新可见时刷新数据
fetchPlans();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [auth.company]);
const stats = {
all: plans.length,
producing: plans.filter(p => p.status === PLAN_STATUS.PRODUCING).length,
completed: plans.filter(p => p.status === PLAN_STATUS.COMPLETED).length,
pending: plans.filter(p => p.status === PLAN_STATUS.PENDING).length
};
// 获取最近计划(桌面端显示更多)
const recentPlans = plans.slice(0, isMobile ? 5 : 10);
const producingPlans = plans.filter(p => p.status === PLAN_STATUS.PRODUCING).slice(0, isMobile ? 5 : 6);
// 桌面端统计卡片数据
const statCards = [
{ id: 'all', label: '所有计划', value: stats.all, sublabel: '累计创建', icon: FileText, color: 'from-blue-500 to-indigo-600', bgColor: 'bg-blue-50', textColor: 'text-blue-600', path: '/purchaser/plans' },
{ id: 'producing', label: '进行中', value: stats.producing, sublabel: '正在生产', icon: TrendingUp, color: 'from-emerald-500 to-teal-600', bgColor: 'bg-emerald-50', textColor: 'text-emerald-600', path: '/purchaser/plans?filter=producing' },
{ id: 'completed', label: '已完成', value: stats.completed, sublabel: '已入库', icon: Package, color: 'from-amber-500 to-orange-600', bgColor: 'bg-amber-50', textColor: 'text-amber-600', path: '/purchaser/plans?filter=completed' },
{ id: 'pending', label: '待确定', value: stats.pending, sublabel: '等待确认', icon: Clock, color: 'from-purple-500 to-violet-600', bgColor: 'bg-purple-50', textColor: 'text-purple-600', path: '/purchaser/plans?filter=pending' }
];
// 快捷操作数据
const quickActions = [
{ id: 'new', icon: Plus, label: '新建纺织计划', path: '/purchaser/plans/new', gradient: 'from-amber-400 to-orange-500', description: '创建新纺织计划' },
{ id: 'washing', icon: Droplets, label: '新建水洗计划', path: '/purchaser/washing-plans/new', gradient: 'from-cyan-400 to-blue-500', description: '创建水洗计划' },
{ id: 'warehouse', icon: Warehouse, label: '产品信息', path: '/purchaser/warehouse', gradient: 'from-emerald-400 to-teal-500', description: '管理产品库存' },
{ id: 'washing-warehouse', icon: Droplets, label: '水洗仓库', path: '/purchaser/finished-warehouse', gradient: 'from-blue-400 to-indigo-500', description: '管理水洗库存' },
{ id: 'factories', icon: Factory, label: '工厂管理', path: '/purchaser/factories', gradient: 'from-rose-400 to-pink-500', description: '管理合作工厂' },
{ id: 'payables', icon: CreditCard, label: '应付账款', path: '/purchaser/accounts-payable', gradient: 'from-violet-400 to-purple-500', description: '查看应付账款' }
];
// 移动端当前计划索引(用于滑动)
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-amber-50/30 to-orange-50/30">
{/* 主内容区 - 由 PurchaserLayout 提供侧边栏(仅桌面端) */}
<div>
{/* Header - 与纺织厂保持一致 */}
<header className="bg-white/80 backdrop-blur-xl border-b border-slate-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">
<button
onClick={() => setShowAccountCard(true)}
className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-amber-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg shadow-amber-500/20 cursor-pointer overflow-hidden active:scale-95 transition-transform"
>
{(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'; }}
/>
) : (
<Building2 className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
)}
</button>
<div>
<h1 className="text-sm sm:text-lg font-bold text-slate-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-amber-500 to-orange-500 text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap"></span>
</h1>
<p className="text-xs text-slate-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="amber" />
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="p-2 sm:px-3 sm:py-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-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 ? (
<LoadingSpinner text="加载仪表盘数据..." className="py-20" />
) : (
<div className="space-y-4 sm:space-y-6 animate-fade-in">
{/* 统计卡片 - 移动端3列紧凑布局桌面端4列 */}
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4">
{/* 移动端只显示3个统计卡片 */}
{statCards.slice(0, isMobile ? 3 : 4).map((stat) => (
<div
key={stat.id}
onClick={() => navigate(stat.path)}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-slate-200 cursor-pointer hover:border-amber-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-slate-500">{stat.label}</p>
<div className={`w-6 h-6 sm:w-8 sm:h-8 ${stat.bgColor} rounded-lg flex items-center justify-center`}>
<stat.icon className={`w-3 h-3 sm:w-4 sm:h-4 ${stat.textColor}`} />
</div>
</div>
<p className="text-xl sm:text-3xl font-bold text-slate-900">{stat.value}</p>
<p className="text-xs text-slate-400 mt-0.5 sm:mt-1">{stat.sublabel}</p>
</div>
))}
</div>
{/* 最近计划 - 移动端左右滑动,桌面端列表 */}
<div className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 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-slate-900 whitespace-nowrap"></h2>
<button
onClick={() => navigate('/purchaser/plans')}
className="text-xs sm:text-sm text-amber-600 hover:text-amber-700 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>
{recentPlans.length === 0 ? (
<div className="text-center py-8 sm:py-12 text-slate-400">
<Package className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
) : (
<div className="relative">
{/* 滑动容器 - 移动端 */}
{isMobile ? (
<>
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${currentPlanIndex * 100}%)` }}
>
{recentPlans.map((plan) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
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(`/purchaser/plans?id=${plan.id}`)}
>
<div className="group p-3 sm:p-4 rounded-xl border border-slate-200 hover:border-amber-200 hover:bg-amber-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-slate-900 text-sm sm:text-base truncate">{plan.product_name}</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-slate-500">{plan.fabric_code}</p>
</div>
</div>
{/* 进度信息 */}
<div className="mt-3 sm:mt-4">
<div className="flex justify-between text-xs text-slate-500 mb-1.5">
<span> {progress}%</span>
<span>{plan.completed_quantity}/{plan.target_quantity}</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5 sm:h-2 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 左右滑动按钮 */}
{recentPlans.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-slate-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-slate-600" />
</button>
<button
onClick={() => setCurrentPlanIndex(prev => Math.min(recentPlans.length - 1, prev + 1))}
disabled={currentPlanIndex === recentPlans.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-slate-50 transition-colors z-10"
>
<ArrowLeft className="w-4 h-4 text-slate-600 rotate-180" />
</button>
</>
)}
{/* 指示器 */}
{recentPlans.length > 1 && (
<div className="flex items-center justify-center gap-1.5 mt-4">
{recentPlans.map((_, i) => (
<button
key={i}
onClick={() => setCurrentPlanIndex(i)}
className={`w-2 h-2 rounded-full transition-all ${
i === currentPlanIndex ? 'bg-amber-500 w-4' : 'bg-slate-300 hover:bg-slate-400'
}`}
/>
))}
</div>
)}
</>
) : (
/* 桌面端列表布局 */
<div className="space-y-3">
{recentPlans.map((plan) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
const status = statusMap[plan.status] || statusMap.pending;
const StatusIcon = status.icon;
return (
<motion.div
key={plan.id}
whileHover={{ scale: 1.01 }}
onClick={() => navigate(`/purchaser/plans?id=${plan.id}`)}
className="group p-4 rounded-xl border border-slate-200 hover:border-amber-300 hover:shadow-md cursor-pointer transition-all bg-slate-50/50 hover:bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${status.bgColor} border ${status.borderColor} flex items-center justify-center`}>
<StatusIcon className={`w-6 h-6 ${status.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900 truncate">{plan.product_name}</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${status.bgColor} ${status.color} border ${status.borderColor}`}>
{status.label}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-500">
<span className="flex items-center gap-1">
<Hash className="w-3.5 h-3.5" />
{plan.fabric_code}
</span>
<span className="flex items-center gap-1">
<Target className="w-3.5 h-3.5" />
{plan.target_quantity}
</span>
<span className="flex items-center gap-1">
<Activity className="w-3.5 h-3.5" />
{progress}%
</span>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-amber-500 transition-colors" />
</div>
{/* 进度条 */}
<div className="mt-3">
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className={`h-full rounded-full ${
progress >= 100 ? 'bg-gradient-to-r from-emerald-500 to-teal-500' :
progress >= 50 ? 'bg-gradient-to-r from-amber-500 to-orange-500' :
'bg-gradient-to-r from-blue-500 to-indigo-500'
}`}
/>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</div>
)}
</div>
{/* 快捷操作 - 移动端显示 */}
{isMobile && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-3 sm:p-4">
<h2 className="text-sm font-semibold text-slate-900 mb-3 whitespace-nowrap"></h2>
<div className="grid grid-cols-3 gap-2">
{quickActions.map((action) => (
<button
key={action.id}
onClick={() => navigate(action.path)}
className="group flex flex-col items-center p-2 rounded-lg border border-slate-100 hover:border-transparent hover:shadow-md transition-all bg-slate-50/50 hover:bg-white active:scale-95"
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-1.5 bg-gradient-to-br ${action.gradient} shadow-sm group-hover:shadow-md transition-shadow`}>
<action.icon className="w-5 h-5 text-white" />
</div>
<span className="text-[10px] font-medium text-slate-700 group-hover:text-slate-900 text-center whitespace-nowrap">{action.label}</span>
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* 帮助中心弹窗 */}
<HelpCenter isOpen={showHelp} onClose={() => setShowHelp(false)} />
{/* 新手引导 */}
<OnboardingGuide
role="purchaser"
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)}
/>
{/* 账户卡片弹窗 */}
<AccountProfileCard
isOpen={showAccountCard}
onClose={() => setShowAccountCard(false)}
role="purchaser"
onShowUserManual={() => setShowUserManual(true)}
onShowFeedback={() => setShowFeedback(true)}
onShowAbout={() => setShowAbout(true)}
/>
</div>
);
}