273 lines
15 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 { FileText, Plus, Warehouse, Factory, ArrowLeft, Users, TrendingUp, Package, Clock, Building2, CheckCircle2, DollarSign, Droplets } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive';
import { OnboardingGuide } from '../../components/OnboardingGuide';
import { NotificationCenter } from '../../components/NotificationCenter';
import type { ProductionPlan } 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 },
producing: { label: '生产中', color: 'text-blue-600', bgColor: 'bg-blue-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: '/purchaser/plans', gradient: 'from-blue-500 to-cyan-500', description: '查看所有计划' },
{ id: 'new', icon: Plus, label: '新建纺织计划', path: '/purchaser/plans/new', gradient: 'from-emerald-500 to-green-500', description: '创建新纺织计划' },
{ id: 'washing', icon: Droplets, label: '新建水洗', path: '/purchaser/washing-plans/new', gradient: 'from-cyan-500 to-blue-500', description: '创建水洗计划' },
{ id: 'warehouse', icon: Warehouse, label: '原坯布仓库', path: '/purchaser/warehouse', gradient: 'from-amber-500 to-orange-500', description: '管理原坯布库存' },
{ id: 'finished', icon: Package, label: '水洗仓库', path: '/purchaser/finished-warehouse', gradient: 'from-emerald-500 to-teal-500', description: '管理水洗厂内坯布库存' },
{ id: 'factories', icon: Factory, label: '工厂管理', path: '/purchaser/factories', gradient: 'from-purple-500 to-violet-500', description: '管理合作工厂' },
{ id: 'accounts-payable', icon: DollarSign, label: '应付账款', path: '/purchaser/accounts-payable', gradient: 'from-rose-500 to-pink-500', description: '查看应付账款' }
];
if (isMaster) {
actions.push({ id: 'members', icon: Users, label: '账号管理', path: '/members', gradient: 'from-gray-500 to-slate-500', 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 PurchaserDashboard() {
const navigate = useNavigate();
const { auth, logout } = useAuth();
const { isMobile } = useResponsive();
const [plans, setPlans] = useState<ProductionPlan[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!auth.company) return;
fetchPlans();
}, [auth.company]);
const fetchPlans = async () => {
const { data } = await supabase
.from('production_plans')
.select('*')
.eq('purchaser_id', auth.company!.id)
.order('created_at', { ascending: false });
setPlans(data || []);
setLoading(false);
};
const stats = {
all: plans.length,
producing: plans.filter(p => p.status === 'producing').length,
completed: plans.filter(p => p.status === 'completed').length
};
const recentPlans = plans.slice(0, 5);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/30">
{/* 新用户引导动画 */}
<OnboardingGuide role="purchaser" onNavigate={(path) => navigate(path)} />
{/* 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">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20">
<Building2 className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div>
<div>
<h1 className="text-sm sm:text-lg font-bold text-gray-900 flex items-center gap-2">
{auth.company?.name || '采购商'}
<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"></span>
</h1>
<p className="text-xs text-gray-500 hidden sm:block">{(auth.user as any)?.display_name || auth.user?.username || '用户'}</p>
</div>
</div>
<div className="flex items-center gap-2">
<NotificationCenter />
<button
onClick={async () => { await logout(); navigate('/login'); }}
className="px-3 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
退
</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-blue-500 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('/purchaser/plans')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-blue-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-blue-50 rounded-lg flex items-center justify-center">
<FileText className="w-3 h-3 sm:w-4 sm:h-4 text-blue-600" />
</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('/purchaser/plans?filter=producing')}
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-gray-100 cursor-pointer hover:border-emerald-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">
<TrendingUp 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.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('/purchaser/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-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-gray-500"></p>
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-amber-50 rounded-lg flex items-center justify-center">
<Package className="w-3 h-3 sm:w-4 sm:h-4 text-amber-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-4 sm:p-6">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-3 sm:mb-4"></h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4">
{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-3 sm:p-4 rounded-xl border border-gray-100 hover:border-transparent hover:shadow-lg transition-all bg-gray-50/50 hover:bg-white"
>
<div className={`w-10 h-10 sm:w-12 sm:h-12 rounded-xl flex items-center justify-center mb-2 sm:mb-3 bg-gradient-to-br ${action.gradient} shadow-md group-hover:shadow-lg transition-shadow`}>
<action.icon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
</div>
<span className="text-xs sm:text-sm font-medium text-gray-700 group-hover:text-gray-900">{action.label}</span>
<span className="text-xs text-gray-400 mt-0.5 sm:mt-1 hidden sm:block">{action.description}</span>
</motion.button>
))}
</div>
</motion.div>
{/* 最近计划 - 移动端优化 */}
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-gray-100 p-4 sm: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"></h2>
<button
onClick={() => navigate('/purchaser/plans')}
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
>
<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-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('/purchaser/plans/new')}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
</button>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
{recentPlans.map((plan, i) => {
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}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
onClick={() => navigate(`/purchaser/plans?id=${plan.id}`)}
className="group flex items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border border-gray-100 hover:border-blue-200 hover:bg-blue-50/30 cursor-pointer transition-all"
>
<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 mb-0.5 sm:mb-1">
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate">{plan.product_name}-{plan.color}</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.fabric_code}-{plan.color_code}</p>
</div>
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-gray-900">{plan.target_quantity}</p>
<p className="text-xs text-gray-400"></p>
</div>
<div className="w-16 sm:w-24">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span className="hidden sm:inline"></span>
<span>{progress}%</span>
</div>
<div className="h-1 sm:h-1.5 bg-gray-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, delay: i * 0.1 }}
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 rounded-full"
/>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</motion.div>
</motion.div>
)}
</div>
</div>
);
}