370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|