iloom-flatten/src/pages/purchaser/PlanOverview.tsx

365 lines
14 KiB
TypeScript
Raw Normal View History

import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { usePlanData } from '../../hooks/usePlanData';
import { useResponsive } from '../../hooks/useResponsive';
import { motion } from 'framer-motion';
import { ArrowLeft, Search, Factory, Droplets, Share2 } from 'lucide-react';
import { PlanGroup } from '../../components/plans/PlanGroup';
import { ShareModal } from '../../components/plans/ShareModal';
import { statusMap } from '../../utils/constants';
import { calculateProgress } from '../../utils/helpers';
import type { PlanWithSteps } from '../../types';
export function PlanOverview() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { auth } = useAuth();
const { isMobile } = useResponsive();
// 从 URL 获取过滤参数
const statusFilter = searchParams.get('filter') as 'all' | 'producing' | 'completed' | null;
// Tab 状态
const [activeTab, setActiveTab] = useState<'textile' | 'washing'>('textile');
// 搜索
const [searchQuery, setSearchQuery] = useState('');
// 根据 URL 参数设置页面标题
useEffect(() => {
if (statusFilter) {
const titles: Record<string, string> = {
producing: '进行中计划',
completed: '已完成计划'
};
document.title = titles[statusFilter] || '计划总览';
}
}, [statusFilter]);
// 展开状态
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
// 分享弹窗
const [shareModal, setShareModal] = useState<{ planId: string; type: 'textile' | 'washing'; factoryName?: string } | null>(null);
// 使用优化的数据获取 Hook
const {
plans,
factories,
planFactories,
operatorNames,
loading,
loadingMore,
hasMore,
loadMore,
refetch
} = usePlanData({
companyId: auth.company?.id || null,
pageSize: 20
});
// 根据状态过滤计划 - 必须在 getPlansForFactory 之前声明
const filteredPlansByStatus = useMemo(() => {
if (!statusFilter || statusFilter === 'all') return plans;
return plans.filter(p => p.status === statusFilter);
}, [plans, statusFilter]);
// 筛选工厂
const filteredFactories = useMemo(() =>
factories.filter(f =>
activeTab === 'textile' ? f.role === 'textile' : f.role === 'washing'
),
[factories, activeTab]
);
// 获取工厂的计划
const getPlansForFactory = useCallback((factoryId: string) => {
const planIds = planFactories
.filter(pf => pf.factory_id === factoryId)
.map(pf => pf.plan_id);
return filteredPlansByStatus.filter(p => planIds.includes(p.id));
}, [planFactories, filteredPlansByStatus]);
// 搜索过滤
const filterPlansBySearch = useCallback((plans: PlanWithSteps[]) => {
if (!searchQuery) return plans;
const query = searchQuery.toLowerCase();
return plans.filter(p =>
p.product_name.toLowerCase().includes(query) ||
p.fabric_code.toLowerCase().includes(query) ||
p.plan_code.toLowerCase().includes(query)
);
}, [searchQuery]);
// 未关联的计划
const unlinkedPlans = useMemo(() =>
filteredPlansByStatus.filter(p =>
!planFactories.some(pf =>
pf.plan_id === p.id && pf.factory_type === activeTab
)
),
[filteredPlansByStatus, planFactories, activeTab]
);
// 切换分组展开
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
}, []);
// 切换计划展开
const togglePlan = useCallback((planId: string) => {
setExpandedPlan(prev => prev === planId ? null : planId);
}, []);
// 打开分享弹窗
const openShareModal = useCallback((planId: string, type: 'textile' | 'washing', factoryName?: string) => {
setShareModal({ planId, type, factoryName });
}, []);
// 关闭分享弹窗
const closeShareModal = useCallback(() => {
setShareModal(null);
}, []);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-500 text-sm">...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 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">
<div className="flex items-center gap-3 sm:gap-4">
<button
onClick={() => navigate('/purchaser')}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</button>
<h1 className="text-lg sm:text-xl font-bold text-gray-900">
{statusFilter === 'producing' ? '进行中计划' : statusFilter === 'completed' ? '已完成计划' : '计划总览'}
</h1>
{statusFilter && (
<button
onClick={() => navigate('/purchaser/plans')}
className="text-xs text-blue-600 hover:text-blue-700 underline"
>
</button>
)}
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-4 sm:py-6">
{/* Tab 切换 */}
<div className="flex gap-2 mb-4 sm:mb-6">
<button
onClick={() => setActiveTab('textile')}
className={`flex items-center gap-2 px-4 sm:px-6 py-2.5 sm:py-3 rounded-xl font-medium text-sm transition-all flex-1 sm:flex-none justify-center ${
activeTab === 'textile'
? 'bg-blue-600 text-white shadow-md shadow-blue-500/20'
: 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-200'
}`}
>
<Factory size={isMobile ? 16 : 18} />
<span className="whitespace-nowrap"></span>
</button>
<button
onClick={() => setActiveTab('washing')}
className={`flex items-center gap-2 px-4 sm:px-6 py-2.5 sm:py-3 rounded-xl font-medium text-sm transition-all flex-1 sm:flex-none justify-center ${
activeTab === 'washing'
? 'bg-blue-600 text-white shadow-md shadow-blue-500/20'
: 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-200'
}`}
>
<Droplets size={isMobile ? 16 : 18} />
<span className="whitespace-nowrap"></span>
</button>
</div>
{/* 搜索栏 */}
<div className="mb-4 sm:mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 sm:w-5 h-4 sm:h-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索产品名称、坯布码、计划编号..."
className="w-full pl-9 sm:pl-10 pr-4 py-2.5 sm:py-3 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all text-sm sm:text-base"
/>
</div>
</div>
{/* 已关联工厂的计划 */}
{filteredFactories.length > 0 && (
<div className="space-y-4 mb-6">
{filteredFactories.map(factory => {
const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id));
if (factoryPlans.length === 0 && searchQuery) return null;
return (
<PlanGroup
key={factory.id}
factory={factory}
plans={factoryPlans}
isExpanded={expandedGroups.has(factory.id)}
expandedPlan={expandedPlan}
onToggleGroup={() => toggleGroup(factory.id)}
onTogglePlan={togglePlan}
onShare={(planId) => openShareModal(planId, activeTab)}
onPlanUpdated={refetch}
operatorNames={operatorNames}
/>
);
})}
</div>
)}
{/* 未关联工厂的计划 */}
{unlinkedPlans.length > 0 && (
<UnlinkedPlansSection
plans={filterPlansBySearch(unlinkedPlans)}
activeTab={activeTab}
onShare={openShareModal}
/>
)}
{/* 加载更多 */}
{hasMore && (
<div className="text-center py-4">
<button
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 bg-white border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 disabled:opacity-50 text-sm"
>
{loadingMore ? '加载中...' : '加载更多'}
</button>
</div>
)}
{/* 空状态 */}
{plans.length === 0 && (
<div className="text-center py-12 sm:py-16">
<div className="w-14 h-14 sm:w-16 sm:h-16 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Factory className="w-7 h-7 sm:w-8 sm:h-8 text-gray-400" />
</div>
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-500 mb-4"></p>
<button
onClick={() => navigate('/purchaser/plans/new')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm sm:text-base"
>
</button>
</div>
)}
</div>
{/* 分享弹窗 */}
{shareModal && (
<ShareModal
isOpen={true}
onClose={closeShareModal}
planId={shareModal.planId}
factoryType={shareModal.type}
factoryName={shareModal.factoryName}
/>
)}
</div>
);
}
// 未关联计划区域组件
interface UnlinkedPlansSectionProps {
plans: PlanWithSteps[];
activeTab: 'textile' | 'washing';
onShare: (planId: string, type: 'textile' | 'washing') => void;
}
function UnlinkedPlansSection({ plans, activeTab, onShare }: UnlinkedPlansSectionProps) {
const { isMobile } = useResponsive();
const factoryLabel = activeTab === 'textile' ? '织布厂' : '水洗厂';
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl border border-blue-200 overflow-hidden mb-4 sm:mb-6"
>
<div className="p-3 sm:p-4 bg-white/50 border-b border-blue-100">
<div className="flex items-center gap-2">
<div className="w-7 h-7 sm:w-8 sm:h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Share2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600" />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-blue-900 text-sm sm:text-base">
{factoryLabel}
</h3>
<p className="text-xs sm:text-sm text-blue-600">
"分享链接"{factoryLabel}
</p>
</div>
</div>
</div>
<div className="p-3 sm:p-4 space-y-2 sm:space-y-3">
{plans.map(plan => {
const status = statusMap[plan.status] || statusMap.pending;
const progress = calculateProgress(plan.completed_quantity, plan.target_quantity);
return (
<div key={plan.id} className="bg-white rounded-xl p-3 sm:p-4 border border-blue-100 hover:border-blue-300 transition-colors">
<div className="flex items-start justify-between gap-2 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2 mb-1">
<h4 className="font-medium text-gray-900 text-sm sm:text-base truncate">
{plan.product_name}-{plan.color}
</h4>
<span className={`px-1.5 sm:px-2 py-0.5 text-xs font-medium rounded-full flex-shrink-0 ${status.bgColor} ${status.color} border ${status.borderColor}`}>
{status.label}
</span>
</div>
<p className="text-xs sm:text-sm text-gray-500">{plan.fabric_code}-{plan.color_code}</p>
<p className="text-xs text-gray-400 mt-0.5">{plan.plan_code}</p>
</div>
<button
onClick={() => onShare(plan.id, activeTab)}
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm flex-shrink-0"
>
<Share2 size={isMobile ? 12 : 14} />
</button>
</div>
<div className="mt-2 sm:mt-3">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span></span>
<span className="font-medium">{progress}%</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
);
})}
</div>
</motion.div>
);
}