iloom-flatten/src/pages/purchaser/PlanOverview.tsx
github-actions[bot] b6e2e526d9 sync: Add files via upload
重大版本更新 (A@029ab8fcd9c4f587ce7a2394252868172d8e5d79)
2026-06-04 13:57:36 +00:00

370 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, 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; factoryId?: string; productName?: string; color?: string; colorCode?: 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, factoryId?: string, productName?: string, color?: string, colorCode?: string) => {
setShareModal({ planId, type, factoryName, factoryId, productName, color, colorCode });
}, []);
// 关闭分享弹窗
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, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
onPlanUpdated={refetch}
operatorNames={operatorNames}
/>
);
})}
</div>
)}
{/* 未关联工厂的计划 */}
{unlinkedPlans.length > 0 && (
<UnlinkedPlansSection
plans={filterPlansBySearch(unlinkedPlans)}
activeTab={activeTab}
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
/>
)}
{/* 加载更多 */}
{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}
factoryId={shareModal.factoryId}
factoryName={shareModal.factoryName}
planProductName={shareModal.productName}
planColor={shareModal.color}
planColorCode={shareModal.colorCode}
onDirectPush={() => refetch()}
/>
)}
</div>
);
}
// 未关联计划区域组件
interface UnlinkedPlansSectionProps {
plans: PlanWithSteps[];
activeTab: 'textile' | 'washing';
onShare: (planId: string, type: 'textile' | 'washing', factoryName?: string, factoryId?: string, productName?: string, color?: string, colorCode?: string) => 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, undefined, undefined, plan.product_name, plan.color, plan.color_code)}
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>
);
}