2026-05-26 05:15:10 +00:00
|
|
|
|
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';
|
2026-06-04 13:57:36 +00:00
|
|
|
|
import {
|
|
|
|
|
|
FileText,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Warehouse,
|
|
|
|
|
|
Factory,
|
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
TrendingUp,
|
|
|
|
|
|
Package,
|
|
|
|
|
|
Clock,
|
|
|
|
|
|
Building2,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
DollarSign,
|
|
|
|
|
|
Droplets,
|
|
|
|
|
|
LogOut,
|
|
|
|
|
|
HelpCircle,
|
|
|
|
|
|
X,
|
|
|
|
|
|
BookOpen,
|
|
|
|
|
|
MessageSquare,
|
|
|
|
|
|
Info,
|
|
|
|
|
|
Camera,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Home,
|
|
|
|
|
|
MoreHorizontal,
|
|
|
|
|
|
Calendar,
|
|
|
|
|
|
Hash,
|
|
|
|
|
|
Target,
|
|
|
|
|
|
Activity,
|
|
|
|
|
|
CreditCard
|
|
|
|
|
|
} from 'lucide-react';
|
2026-05-26 05:15:10 +00:00
|
|
|
|
import { useResponsive } from '../../hooks/useResponsive';
|
2026-05-27 03:17:46 +00:00
|
|
|
|
import { OnboardingGuide, shouldShowOnboarding } from '../../components/OnboardingGuide';
|
2026-05-26 05:15:10 +00:00
|
|
|
|
import { NotificationCenter } from '../../components/NotificationCenter';
|
2026-05-27 03:17:46 +00:00
|
|
|
|
import { HelpCenter, HelpButton } from '../../components/HelpCenter';
|
|
|
|
|
|
import { VersionUpdate, useVersionCheck, AboutModal } from '../../components/VersionUpdate';
|
|
|
|
|
|
import { FeedbackModal } from '../../components/FeedbackModal';
|
|
|
|
|
|
import { UserManual } from '../../components/UserManual';
|
2026-05-26 05:15:10 +00:00
|
|
|
|
import type { ProductionPlan } from '../../types';
|
2026-06-04 15:09:31 +00:00
|
|
|
|
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
2026-06-04 13:57:36 +00:00
|
|
|
|
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 }
|
2026-05-26 05:15:10 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function PurchaserDashboard() {
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const { auth, logout } = useAuth();
|
|
|
|
|
|
const { isMobile } = useResponsive();
|
|
|
|
|
|
const [plans, setPlans] = useState<ProductionPlan[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-06-04 13:57:36 +00:00
|
|
|
|
const [dataLoaded, setDataLoaded] = useState(false);
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
2026-05-27 03:17:46 +00:00
|
|
|
|
// 帮助中心状态
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-05-28 05:54:23 +00:00
|
|
|
|
// 账户卡片弹窗状态
|
|
|
|
|
|
const [showAccountCard, setShowAccountCard] = useState(false);
|
|
|
|
|
|
|
2026-05-27 03:17:46 +00:00
|
|
|
|
// 版本更新检查
|
|
|
|
|
|
const { showUpdate, setShowUpdate } = useVersionCheck();
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否显示新手引导
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (shouldShowOnboarding('purchaser')) {
|
|
|
|
|
|
setShowOnboarding(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-26 05:15:10 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!auth.company) return;
|
2026-06-04 13:57:36 +00:00
|
|
|
|
// 如果数据已加载,不再重新加载
|
|
|
|
|
|
if (dataLoaded && plans.length > 0) {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-26 05:15:10 +00:00
|
|
|
|
fetchPlans();
|
2026-06-04 13:57:36 +00:00
|
|
|
|
}, [auth.company, dataLoaded, plans.length]);
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
|
|
|
|
|
const fetchPlans = async () => {
|
2026-05-27 03:17:46 +00:00
|
|
|
|
const { data: planData } = await supabase
|
2026-05-26 05:15:10 +00:00
|
|
|
|
.from('production_plans')
|
|
|
|
|
|
.select('*')
|
|
|
|
|
|
.eq('purchaser_id', auth.company!.id)
|
|
|
|
|
|
.order('created_at', { ascending: false });
|
2026-05-27 03:17:46 +00:00
|
|
|
|
|
|
|
|
|
|
// 获取流程步骤,检测 rejected 状态
|
|
|
|
|
|
const planIds = (planData || []).map(p => p.id);
|
|
|
|
|
|
const { data: stepsData } = await supabase
|
|
|
|
|
|
.from('plan_process_steps')
|
|
|
|
|
|
.select('plan_id, status')
|
|
|
|
|
|
.in('plan_id', planIds);
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤掉有 rejected 步骤的计划
|
|
|
|
|
|
const rejectedPlanIds = new Set<string>();
|
|
|
|
|
|
(stepsData || []).forEach(step => {
|
|
|
|
|
|
if (step.status === 'rejected') {
|
|
|
|
|
|
rejectedPlanIds.add(step.plan_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const filteredPlans = (planData || []).filter(p => !rejectedPlanIds.has(p.id));
|
|
|
|
|
|
setPlans(filteredPlans);
|
2026-05-26 05:15:10 +00:00
|
|
|
|
setLoading(false);
|
2026-06-04 13:57:36 +00:00
|
|
|
|
setDataLoaded(true);
|
2026-05-26 05:15:10 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 13:57:36 +00:00
|
|
|
|
// 监听页面可见性变化,返回页面时刷新数据
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleVisibilityChange = () => {
|
|
|
|
|
|
if (document.visibilityState === 'visible' && auth.company) {
|
|
|
|
|
|
// 页面重新可见时刷新数据
|
|
|
|
|
|
fetchPlans();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
|
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
|
|
}, [auth.company]);
|
|
|
|
|
|
|
2026-05-26 05:15:10 +00:00
|
|
|
|
const stats = {
|
|
|
|
|
|
all: plans.length,
|
|
|
|
|
|
producing: plans.filter(p => p.status === 'producing').length,
|
2026-06-04 13:57:36 +00:00
|
|
|
|
completed: plans.filter(p => p.status === 'completed').length,
|
|
|
|
|
|
pending: plans.filter(p => p.status === 'pending').length
|
2026-05-26 05:15:10 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 13:57:36 +00:00
|
|
|
|
// 获取最近计划(桌面端显示更多)
|
|
|
|
|
|
const recentPlans = plans.slice(0, isMobile ? 5 : 10);
|
|
|
|
|
|
const producingPlans = plans.filter(p => p.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: '查看应付账款' }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 移动端当前计划索引(用于滑动)
|
2026-05-28 05:54:23 +00:00
|
|
|
|
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<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"
|
2026-05-26 05:15:10 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
{(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="退出登录"
|
2026-05-26 05:15:10 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<LogOut className="w-5 h-5 sm:hidden" />
|
|
|
|
|
|
<span className="hidden sm:inline text-xs sm:text-sm">退出</span>
|
|
|
|
|
|
</button>
|
2026-05-26 05:15:10 +00:00
|
|
|
|
</div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
2026-06-04 13:57:36 +00:00
|
|
|
|
{/* 页面内容 */}
|
|
|
|
|
|
<div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
|
|
|
|
|
|
{loading ? (
|
2026-06-04 15:09:31 +00:00
|
|
|
|
<LoadingSpinner text="加载仪表盘数据..." className="py-20" />
|
2026-06-04 13:57:36 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
2026-05-26 05:15:10 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-04 13:57:36 +00:00
|
|
|
|
{/* 最近计划 - 移动端左右滑动,桌面端列表 */}
|
|
|
|
|
|
<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>
|
2026-05-26 05:15:10 +00:00
|
|
|
|
<button
|
2026-06-04 13:57:36 +00:00
|
|
|
|
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"
|
2026-05-26 05:15:10 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
查看全部
|
|
|
|
|
|
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 rotate-180" />
|
2026-05-26 05:15:10 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
|
|
|
|
|
|
{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">
|
2026-05-28 05:54:23 +00:00
|
|
|
|
<div
|
2026-06-04 13:57:36 +00:00
|
|
|
|
className="flex transition-transform duration-300 ease-out"
|
|
|
|
|
|
style={{ transform: `translateX(-${currentPlanIndex * 100}%)` }}
|
2026-05-28 05:54:23 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
{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}`} />
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<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}`}>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
{status.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<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>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-amber-500 transition-colors" />
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
{/* 进度条 */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
|
2026-06-04 13:57:36 +00:00
|
|
|
|
{/* 快捷操作 - 移动端显示 */}
|
|
|
|
|
|
{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) => (
|
2026-05-28 05:54:23 +00:00
|
|
|
|
<button
|
2026-06-04 13:57:36 +00:00
|
|
|
|
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"
|
2026-05-28 05:54:23 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<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>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</button>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-05-26 05:15:10 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-26 05:15:10 +00:00
|
|
|
|
</div>
|
2026-05-27 03:17:46 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 帮助中心弹窗 */}
|
|
|
|
|
|
<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)}
|
|
|
|
|
|
/>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 账户卡片弹窗 */}
|
|
|
|
|
|
{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()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 头部 */}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<div className="bg-gradient-to-r from-amber-500 to-orange-500 p-6 text-white">
|
2026-05-28 05:54:23 +00:00
|
|
|
|
<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">
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<label className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group">
|
2026-05-28 05:54:23 +00:00
|
|
|
|
{(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" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Building2 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')
|
2026-06-04 13:57:36 +00:00
|
|
|
|
.upload(fileName, arrayBuffer, { contentType: file.type, upsert: true });
|
2026-05-28 05:54:23 +00:00
|
|
|
|
if (uploadError) {
|
|
|
|
|
|
alert('头像上传失败: ' + uploadError.message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
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);
|
2026-05-28 05:54:23 +00:00
|
|
|
|
if (updateError) {
|
|
|
|
|
|
alert('头像更新失败: ' + updateError.message);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.location.reload();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert('头像上传出错,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<p className="font-semibold text-slate-900 text-lg truncate max-w-[200px]">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
|
|
|
|
|
|
<p className="text-sm text-slate-500">{auth.company?.name || '采购商'}</p>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
<span className="inline-flex items-center gap-1 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full mt-1">
|
|
|
|
|
|
{auth.user?.is_master ? '主账号' : '子账号'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 功能菜单 */}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<div className="space-y-2 pt-4 border-t border-slate-200">
|
2026-05-28 05:54:23 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => { setShowAccountCard(false); navigate('/members'); }}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
2026-05-28 05:54:23 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
|
<Users className="w-5 h-5 text-amber-600" />
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<p className="font-medium text-slate-900">账号管理</p>
|
|
|
|
|
|
<p className="text-xs text-slate-500">管理团队成员和账户设置</p>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => { setShowAccountCard(false); setShowUserManual(true); }}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
2026-05-28 05:54:23 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
|
<BookOpen className="w-5 h-5 text-emerald-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<p className="font-medium text-slate-900">用户手册</p>
|
|
|
|
|
|
<p className="text-xs text-slate-500">查看详细使用指南</p>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => { setShowAccountCard(false); setShowFeedback(true); }}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
2026-05-28 05:54:23 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
|
<MessageSquare className="w-5 h-5 text-purple-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<p className="font-medium text-slate-900">用户反馈</p>
|
|
|
|
|
|
<p className="text-xs text-slate-500">提交问题或建议</p>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => { setShowAccountCard(false); setShowAbout(true); }}
|
2026-06-04 13:57:36 +00:00
|
|
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
2026-05-28 05:54:23 +00:00
|
|
|
|
>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
|
<Info className="w-5 h-5 text-slate-600" />
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-06-04 13:57:36 +00:00
|
|
|
|
<p className="font-medium text-slate-900">关于</p>
|
|
|
|
|
|
<p className="text-xs text-slate-500">版本信息与版权</p>
|
2026-05-28 05:54:23 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2026-05-26 05:15:10 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|