2026-05-26 05:15:10 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
// 分享弹窗
|
2026-05-27 03:17:46 +00:00
|
|
|
|
const [shareModal, setShareModal] = useState<{ planId: string; type: 'textile' | 'washing'; factoryName?: string } | null>(null);
|
2026-05-26 05:15:10 +00:00
|
|
|
|
|
|
|
|
|
|
// 使用优化的数据获取 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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 打开分享弹窗
|
2026-05-27 03:17:46 +00:00
|
|
|
|
const openShareModal = useCallback((planId: string, type: 'textile' | 'washing', factoryName?: string) => {
|
|
|
|
|
|
setShareModal({ planId, type, factoryName });
|
2026-05-26 05:15:10 +00:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭分享弹窗
|
|
|
|
|
|
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}
|
2026-05-27 03:17:46 +00:00
|
|
|
|
factoryName={shareModal.factoryName}
|
2026-05-26 05:15:10 +00:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|