feat(plans): separate textile and washing plan data in plan overview

WHY: The purchaser plan overview page showed the same production_plans
data under both textile and washing tabs. Washing plans are a separate
entity (washing_plans table) with different fields like planned_meters,
washing_price, and shrinkage_rate, but were never displayed.

HOW:
- Add useWashingPlanData hook to fetch washing plans via existing API
- Add WashingPlanCard component with washing-specific fields display
- Update PlanOverview to use separate data sources per tab
- Add washingStatusMap for washing plan status UI styling
- Add seed SQL with 3 demo washing plans (pending/in_progress/completed)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chever John 2026-07-07 18:56:04 +08:00
parent d085054437
commit 6b835b933c
No known key found for this signature in database
GPG Key ID: 2894D52ED1211DF0
5 changed files with 543 additions and 57 deletions

View File

@ -0,0 +1,41 @@
-- ============================================
-- 水洗计划示例数据 - Seed washing plans and related data
-- ============================================
-- 先创建示例产品(水洗计划需要引用 product_id
INSERT INTO public.products (id, company_id, product_name, weight, color, fabric_code, color_code, production_price, product_type, yarn_usage_per_meter, created_at) VALUES
('44444444-4444-4444-4444-444444444401', '11111111-1111-1111-1111-111111111101', '水洗棉花糖', 180, '白色', 'SX', '01', 10, 'textile', 100, '2026-01-10T00:00:00Z'),
('44444444-4444-4444-4444-444444444402', '11111111-1111-1111-1111-111111111101', '纯棉汗布', 200, '黑色', 'CM', '02', 12, 'textile', 120, '2026-01-10T00:00:00Z'),
('44444444-4444-4444-4444-444444444403', '11111111-1111-1111-1111-111111111101', '磨毛布', 160, '灰色', 'MM', '03', 8, 'textile', 80, '2026-01-10T00:00:00Z')
ON CONFLICT (id) DO NOTHING;
-- 创建示例水洗计划
INSERT INTO public.washing_plans (id, plan_code, product_id, company_id, washing_factory_id, planned_meters, washing_price, estimated_shrinkage_rate, estimated_washed_meters, estimated_total_cost, status, notes, created_by, created_at) VALUES
('55555555-5555-5555-5555-555555555501', 'WS20260201-A1B2', '44444444-4444-4444-4444-444444444401', '11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111103', 3000, 2.5, 5, 2850, 7500, 'pending', '第一批水洗', 'a0000001-0000-0000-0000-000000000001', '2026-02-01T00:00:00Z'),
('55555555-5555-5555-5555-555555555502', 'WS20260210-C3D4', '44444444-4444-4444-4444-444444444402', '11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111103', 5000, 3.0, 6, 4700, 15000, 'in_progress', '加急处理', 'a0000001-0000-0000-0000-000000000001', '2026-02-10T00:00:00Z'),
('55555555-5555-5555-5555-555555555503', 'WS20260115-E5F6', '44444444-4444-4444-4444-444444444403', '11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111103', 2000, 2.0, 4, 1920, 4000, 'completed', '常规水洗', 'a0000001-0000-0000-0000-000000000001', '2026-01-15T00:00:00Z')
ON CONFLICT (id) DO NOTHING;
-- 更新已完成计划的实际数据
UPDATE public.washing_plans
SET actual_washed_meters = 1880,
actual_shrinkage_rate = 6,
actual_total_cost = 4000,
washing_date = '2026-01-20'
WHERE id = '55555555-5555-5555-5555-555555555503';
-- 创建水洗流程步骤
INSERT INTO public.washing_process_steps (id, washing_plan_id, company_id, step_type, status, notes, operator_id, created_at) VALUES
-- 待处理计划的步骤
('66666666-6666-6666-6666-666666666601', '55555555-5555-5555-5555-555555555501', '11111111-1111-1111-1111-111111111103', 'confirm', 'pending', NULL, NULL, '2026-02-01T00:00:00Z'),
('66666666-6666-6666-6666-666666666602', '55555555-5555-5555-5555-555555555501', '11111111-1111-1111-1111-111111111103', 'start_washing', 'pending', NULL, NULL, '2026-02-01T00:00:00Z'),
('66666666-6666-6666-6666-666666666603', '55555555-5555-5555-5555-555555555501', '11111111-1111-1111-1111-111111111103', 'complete', 'pending', NULL, NULL, '2026-02-01T00:00:00Z'),
-- 处理中计划的步骤
('66666666-6666-6666-6666-666666666604', '55555555-5555-5555-5555-555555555502', '11111111-1111-1111-1111-111111111103', 'confirm', 'completed', '已确认', 'a0000003-0000-0000-0000-000000000003', '2026-02-10T09:00:00Z'),
('66666666-6666-6666-6666-666666666605', '55555555-5555-5555-5555-555555555502', '11111111-1111-1111-1111-111111111103', 'start_washing', 'completed', '已开始水洗', 'a0000003-0000-0000-0000-000000000003', '2026-02-11T08:00:00Z'),
('66666666-6666-6666-6666-666666666606', '55555555-5555-5555-5555-555555555502', '11111111-1111-1111-1111-111111111103', 'complete', 'pending', NULL, NULL, '2026-02-10T00:00:00Z'),
-- 已完成计划的步骤
('66666666-6666-6666-6666-666666666607', '55555555-5555-5555-5555-555555555503', '11111111-1111-1111-1111-111111111103', 'confirm', 'completed', '已确认', 'a0000003-0000-0000-0000-000000000003', '2026-01-15T10:00:00Z'),
('66666666-6666-6666-6666-666666666608', '55555555-5555-5555-5555-555555555503', '11111111-1111-1111-1111-111111111103', 'start_washing', 'completed', '已开始水洗', 'a0000003-0000-0000-0000-000000000003', '2026-01-16T08:00:00Z'),
('66666666-6666-6666-6666-666666666609', '55555555-5555-5555-5555-555555555503', '11111111-1111-1111-1111-111111111103', 'complete', 'completed', '水洗完成', 'a0000003-0000-0000-0000-000000000003', '2026-01-20T14:00:00Z')
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, Droplets, DollarSign } from 'lucide-react';
import { washingStatusMap } from '../../utils/constants';
import type { WashingPlanWithProduct } from '../../hooks/useWashingPlanData';
interface WashingPlanCardProps {
plan: WashingPlanWithProduct;
}
export function WashingPlanCard({ plan }: WashingPlanCardProps) {
const [expanded, setExpanded] = useState(false);
const { isMobile } = useResponsive();
const status = washingStatusMap[plan.status] || washingStatusMap.pending;
const progress = plan.status === 'completed' ? 100
: plan.status === 'in_progress' ? 50
: 0;
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-gray-300 transition-colors">
<div
onClick={() => setExpanded(prev => !prev)}
className="p-3 sm:p-4 cursor-pointer"
>
<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.product_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.planned_meters}
</p>
<p className="text-xs text-gray-400 mt-0.5">{plan.plan_code}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="text-right text-xs sm:text-sm">
<p className="text-gray-600 font-medium">
¥{plan.washing_price}/
</p>
</div>
<motion.div
animate={{ rotate: expanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
</div>
</div>
{/* Progress bar */}
<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">
{plan.status === 'completed' ? '已完成' : plan.status === 'in_progress' ? '处理中' : '待处理'}
</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
plan.status === 'completed' ? 'bg-emerald-500' :
plan.status === 'in_progress' ? 'bg-purple-500' :
'bg-gray-300'
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-gray-100"
>
<div className="p-3 sm:p-4 bg-gray-50 space-y-3">
<div className="grid grid-cols-2 gap-3">
<InfoItem
icon={<Droplets className="w-3.5 h-3.5 text-blue-500" />}
label="计划水洗"
value={`${plan.planned_meters}`}
/>
<InfoItem
icon={<DollarSign className="w-3.5 h-3.5 text-green-500" />}
label="水洗单价"
value={`¥${plan.washing_price}/米`}
/>
<InfoItem
label="预计缩水率"
value={plan.estimated_shrinkage_rate ? `${plan.estimated_shrinkage_rate}%` : '-'}
/>
<InfoItem
label="预计洗后"
value={plan.estimated_washed_meters ? `${plan.estimated_washed_meters}` : '-'}
/>
{plan.estimated_total_cost != null && (
<InfoItem
label="预计总费用"
value={`¥${plan.estimated_total_cost.toFixed(2)}`}
/>
)}
</div>
{plan.status === 'completed' && (
<div className="pt-3 border-t border-gray-200">
<p className="text-xs font-medium text-gray-600 mb-2"></p>
<div className="grid grid-cols-2 gap-3">
<InfoItem
label="实际洗后"
value={plan.actual_washed_meters ? `${plan.actual_washed_meters}` : '-'}
highlight
/>
<InfoItem
label="实际缩水率"
value={plan.actual_shrinkage_rate ? `${plan.actual_shrinkage_rate}%` : '-'}
highlight
/>
{plan.actual_total_cost != null && (
<InfoItem
label="实际总费用"
value={`¥${plan.actual_total_cost.toFixed(2)}`}
highlight
/>
)}
{plan.washing_date && (
<InfoItem
label="水洗日期"
value={new Date(plan.washing_date).toLocaleDateString('zh-CN')}
/>
)}
</div>
</div>
)}
{plan.notes && (
<div className="pt-2 border-t border-gray-200">
<p className="text-xs text-gray-500">
<span className="font-medium">:</span> {plan.notes}
</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function InfoItem({ icon, label, value, highlight }: {
icon?: React.ReactNode;
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div className="flex items-start gap-1.5">
{icon && <span className="mt-0.5">{icon}</span>}
<div>
<p className="text-xs text-gray-500">{label}</p>
<p className={`text-sm font-medium ${highlight ? 'text-emerald-700' : 'text-gray-900'}`}>
{value}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { purchaserApi } from '../api';
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
import type { WashingPlan, Company } from '../types';
export interface WashingPlanWithProduct extends WashingPlan {
product_name?: string;
product_color?: string;
fabric_code?: string;
washing_factory_name?: string;
}
interface UseWashingPlanDataOptions {
companyId: string | null;
pageSize?: number;
}
interface WashingPlanDataState {
plans: WashingPlanWithProduct[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
currentPage: number;
error: Error | null;
}
export function useWashingPlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UseWashingPlanDataOptions) {
const [state, setState] = useState<WashingPlanDataState>({
plans: [],
loading: true,
loadingMore: false,
hasMore: true,
currentPage: 1,
error: null,
});
const cacheRef = useRef<{
data: WashingPlanDataState | null;
timestamp: number;
}>({ data: null, timestamp: 0 });
const CACHE_DURATION = CACHE_CONFIG.planDataDuration;
const fetchData = useCallback(async (page: number = 1, append: boolean = false) => {
if (!companyId) {
setState(prev => ({ ...prev, loading: false }));
return;
}
const isFirstPage = page === 1;
if (isFirstPage) {
const now = Date.now();
if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) {
setState(cacheRef.current.data);
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
} else {
setState(prev => ({ ...prev, loadingMore: true }));
}
try {
const res = await purchaserApi.listWashingPlans({ page, page_size: pageSize });
const rawData = res.data;
const washingPlans: WashingPlanWithProduct[] = Array.isArray(rawData) ? rawData : [];
setState(prev => {
const newState: WashingPlanDataState = {
plans: append ? [...prev.plans, ...washingPlans] : washingPlans,
loading: false,
loadingMore: false,
hasMore: res.meta?.has_more ?? washingPlans.length === pageSize,
currentPage: page,
error: null,
};
if (isFirstPage) {
cacheRef.current = { data: newState, timestamp: Date.now() };
}
return newState;
});
} catch (err) {
setState(prev => ({
...prev,
loading: false,
loadingMore: false,
error: err instanceof Error ? err : new Error('Unknown error'),
}));
}
}, [companyId, pageSize]);
const loadMore = useCallback(() => {
if (state.loadingMore || !state.hasMore) return;
fetchData(state.currentPage + 1, true);
}, [fetchData, state.currentPage, state.hasMore, state.loadingMore]);
const refetch = useCallback(() => {
cacheRef.current.timestamp = 0;
fetchData(1, false);
}, [fetchData]);
useEffect(() => {
fetchData(1, false);
}, [fetchData]);
return {
...state,
loadMore,
refetch,
};
}

View File

@ -2,14 +2,17 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { usePlanData } from '../../hooks/usePlanData'; import { usePlanData } from '../../hooks/usePlanData';
import { useWashingPlanData } from '../../hooks/useWashingPlanData';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeft, Search, Factory, Droplets, Share2 } from 'lucide-react'; import { ArrowLeft, Search, Factory, Droplets, Share2 } from 'lucide-react';
import { PlanGroup } from '../../components/plans/PlanGroup'; import { PlanGroup } from '../../components/plans/PlanGroup';
import { WashingPlanCard } from '../../components/plans/WashingPlanCard';
import { ShareModal } from '../../components/plans/ShareModal'; import { ShareModal } from '../../components/plans/ShareModal';
import { statusMap } from '../../utils/constants'; import { statusMap, washingStatusMap } from '../../utils/constants';
import { calculateProgress } from '../../utils/helpers'; import { calculateProgress } from '../../utils/helpers';
import type { PlanWithSteps } from '../../types'; import type { PlanWithSteps } from '../../types';
import type { WashingPlanWithProduct } from '../../hooks/useWashingPlanData';
export function PlanOverview() { export function PlanOverview() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -44,37 +47,65 @@ export function PlanOverview() {
// 分享弹窗 // 分享弹窗
const [shareModal, setShareModal] = useState<{ planId: string; type: 'textile' | 'washing'; factoryName?: string; factoryId?: string; productName?: string; color?: string; colorCode?: 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 // 使用优化的数据获取 Hook(织布厂)
const { const {
plans, plans,
factories, factories,
planFactories, planFactories,
operatorNames, operatorNames,
loading, loading: textileLoading,
loadingMore, loadingMore: textileLoadingMore,
hasMore, hasMore: textileHasMore,
loadMore, loadMore: textileLoadMore,
refetch refetch: textileRefetch
} = usePlanData({ } = usePlanData({
companyId: auth.company?.id || null, companyId: auth.company?.id || null,
pageSize: 20 pageSize: 20
}); });
// 根据状态过滤计划 - 必须在 getPlansForFactory 之前声明 // 水洗厂数据获取 Hook
const {
plans: washingPlans,
loading: washingLoading,
loadingMore: washingLoadingMore,
hasMore: washingHasMore,
loadMore: washingLoadMore,
refetch: washingRefetch
} = useWashingPlanData({
companyId: auth.company?.id || null,
pageSize: 20
});
// 当前 tab 的加载状态
const loading = activeTab === 'textile' ? textileLoading : washingLoading;
const loadingMore = activeTab === 'textile' ? textileLoadingMore : washingLoadingMore;
const hasMore = activeTab === 'textile' ? textileHasMore : washingHasMore;
const loadMore = activeTab === 'textile' ? textileLoadMore : washingLoadMore;
// 根据状态过滤计划 - 织布厂
const filteredPlansByStatus = useMemo(() => { const filteredPlansByStatus = useMemo(() => {
if (!statusFilter || statusFilter === 'all') return plans; if (!statusFilter || statusFilter === 'all') return plans;
return plans.filter(p => p.status === statusFilter); return plans.filter(p => p.status === statusFilter);
}, [plans, statusFilter]); }, [plans, statusFilter]);
// 筛选工厂 // 根据状态过滤 - 水洗厂
const filteredWashingPlansByStatus = useMemo(() => {
if (!statusFilter || statusFilter === 'all') return washingPlans;
const statusMapping: Record<string, string> = {
producing: 'in_progress',
completed: 'completed',
};
const mappedStatus = statusMapping[statusFilter] || statusFilter;
return washingPlans.filter(p => p.status === mappedStatus);
}, [washingPlans, statusFilter]);
// 筛选工厂(仅织布厂 tab 使用)
const filteredFactories = useMemo(() => const filteredFactories = useMemo(() =>
factories.filter(f => factories.filter(f => f.role === 'textile'),
activeTab === 'textile' ? f.role === 'textile' : f.role === 'washing' [factories]
),
[factories, activeTab]
); );
// 获取工厂的计划 // 获取工厂的计划(仅织布厂 tab 使用)
const getPlansForFactory = useCallback((factoryId: string) => { const getPlansForFactory = useCallback((factoryId: string) => {
const planIds = planFactories const planIds = planFactories
.filter(pf => pf.factory_id === factoryId) .filter(pf => pf.factory_id === factoryId)
@ -82,7 +113,7 @@ export function PlanOverview() {
return filteredPlansByStatus.filter(p => planIds.includes(p.id)); return filteredPlansByStatus.filter(p => planIds.includes(p.id));
}, [planFactories, filteredPlansByStatus]); }, [planFactories, filteredPlansByStatus]);
// 搜索过滤 // 搜索过滤 - 织布厂
const filterPlansBySearch = useCallback((plans: PlanWithSteps[]) => { const filterPlansBySearch = useCallback((plans: PlanWithSteps[]) => {
if (!searchQuery) return plans; if (!searchQuery) return plans;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
@ -93,14 +124,47 @@ export function PlanOverview() {
); );
}, [searchQuery]); }, [searchQuery]);
// 未关联的计划 // 搜索过滤 - 水洗厂
const filteredWashingPlans = useMemo(() => {
if (!searchQuery) return filteredWashingPlansByStatus;
const query = searchQuery.toLowerCase();
return filteredWashingPlansByStatus.filter(p =>
(p.product_name || '').toLowerCase().includes(query) ||
(p.fabric_code || '').toLowerCase().includes(query) ||
p.plan_code.toLowerCase().includes(query)
);
}, [filteredWashingPlansByStatus, searchQuery]);
// 水洗计划按工厂分组
const washingPlansByFactory = useMemo(() => {
const linked: Record<string, { factoryName: string; plans: WashingPlanWithProduct[] }> = {};
const unlinked: WashingPlanWithProduct[] = [];
filteredWashingPlans.forEach(plan => {
if (plan.washing_factory_id) {
if (!linked[plan.washing_factory_id]) {
linked[plan.washing_factory_id] = {
factoryName: plan.washing_factory_name || '水洗厂',
plans: [],
};
}
linked[plan.washing_factory_id].plans.push(plan);
} else {
unlinked.push(plan);
}
});
return { linked, unlinked };
}, [filteredWashingPlans]);
// 未关联的计划(仅织布厂 tab
const unlinkedPlans = useMemo(() => const unlinkedPlans = useMemo(() =>
filteredPlansByStatus.filter(p => filteredPlansByStatus.filter(p =>
!planFactories.some(pf => !planFactories.some(pf =>
pf.plan_id === p.id && pf.factory_type === activeTab pf.plan_id === p.id && pf.factory_type === 'textile'
) )
), ),
[filteredPlansByStatus, planFactories, activeTab] [filteredPlansByStatus, planFactories]
); );
// 切换分组展开 // 切换分组展开
@ -128,6 +192,9 @@ export function PlanOverview() {
setShareModal(null); setShareModal(null);
}, []); }, []);
// 总计划数(用于空状态判断)
const totalPlans = activeTab === 'textile' ? plans.length : washingPlans.length;
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
@ -201,44 +268,117 @@ export function PlanOverview() {
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索产品名称、坯布码、计划编号..." placeholder={activeTab === 'textile'
? "搜索产品名称、坯布码、计划编号..."
: "搜索产品名称、坯布码、计划编号..."
}
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" 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>
</div> </div>
{/* 已关联工厂的计划 */} {/* ===== 织布厂 Tab 内容 ===== */}
{filteredFactories.length > 0 && ( {activeTab === 'textile' && (
<div className="space-y-4 mb-6"> <>
{filteredFactories.map(factory => { {/* 已关联工厂的计划 */}
const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id)); {filteredFactories.length > 0 && (
if (factoryPlans.length === 0 && searchQuery) return null; <div className="space-y-4 mb-6">
{filteredFactories.map(factory => {
const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id));
if (factoryPlans.length === 0 && searchQuery) return null;
return ( return (
<PlanGroup <PlanGroup
key={factory.id} key={factory.id}
factory={factory} factory={factory}
plans={factoryPlans} plans={factoryPlans}
isExpanded={expandedGroups.has(factory.id)} isExpanded={expandedGroups.has(factory.id)}
expandedPlan={expandedPlan} expandedPlan={expandedPlan}
onToggleGroup={() => toggleGroup(factory.id)} onToggleGroup={() => toggleGroup(factory.id)}
onTogglePlan={togglePlan} onTogglePlan={togglePlan}
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)} onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
onPlanUpdated={refetch} onPlanUpdated={textileRefetch}
operatorNames={operatorNames} operatorNames={operatorNames}
/> />
); );
})} })}
</div> </div>
)}
{/* 未关联工厂的计划 */}
{unlinkedPlans.length > 0 && (
<UnlinkedPlansSection
plans={filterPlansBySearch(unlinkedPlans)}
activeTab="textile"
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
/>
)}
</>
)} )}
{/* 未关联工厂的计划 */} {/* ===== 水洗厂 Tab 内容 ===== */}
{unlinkedPlans.length > 0 && ( {activeTab === 'washing' && (
<UnlinkedPlansSection <>
plans={filterPlansBySearch(unlinkedPlans)} {/* 按工厂分组的水洗计划 */}
activeTab={activeTab} {Object.entries(washingPlansByFactory.linked).map(([factoryId, group]) => (
onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)} <motion.div
/> key={factoryId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-gray-200 overflow-hidden mb-4"
>
<div
onClick={() => toggleGroup(factoryId)}
className="p-3 sm:p-4 cursor-pointer hover:bg-gray-50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-purple-100 rounded-xl flex items-center justify-center">
<Droplets className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">{group.factoryName}</h3>
<p className="text-xs text-gray-500">{group.plans.length} </p>
</div>
</div>
</div>
<div className="border-t border-gray-100 p-3 sm:p-4 space-y-3">
{group.plans.map(plan => (
<WashingPlanCard key={plan.id} plan={plan} />
))}
</div>
</motion.div>
))}
{/* 未关联工厂的水洗计划 */}
{washingPlansByFactory.unlinked.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-to-br from-purple-50 to-violet-50 rounded-2xl border border-purple-200 overflow-hidden mb-4 sm:mb-6"
>
<div className="p-3 sm:p-4 bg-white/50 border-b border-purple-100">
<div className="flex items-center gap-2">
<div className="w-7 h-7 sm:w-8 sm:h-8 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Droplets className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-purple-600" />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-purple-900 text-sm sm:text-base">
</h3>
<p className="text-xs sm:text-sm text-purple-600">
</p>
</div>
</div>
</div>
<div className="p-3 sm:p-4 space-y-3">
{washingPlansByFactory.unlinked.map(plan => (
<WashingPlanCard key={plan.id} plan={plan} />
))}
</div>
</motion.div>
)}
</>
)} )}
{/* 加载更多 */} {/* 加载更多 */}
@ -255,18 +395,26 @@ export function PlanOverview() {
)} )}
{/* 空状态 */} {/* 空状态 */}
{plans.length === 0 && ( {totalPlans === 0 && (
<div className="text-center py-12 sm:py-16"> <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"> <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" /> {activeTab === 'textile' ? (
<Factory className="w-7 h-7 sm:w-8 sm:h-8 text-gray-400" />
) : (
<Droplets className="w-7 h-7 sm:w-8 sm:h-8 text-gray-400" />
)}
</div> </div>
<h3 className="text-base sm:text-lg font-medium text-gray-900 mb-2"></h3> <h3 className="text-base sm:text-lg font-medium text-gray-900 mb-2">
<p className="text-sm text-gray-500 mb-4"></p> {activeTab === 'textile' ? '暂无纺织计划' : '暂无水洗计划'}
</h3>
<p className="text-sm text-gray-500 mb-4">
{activeTab === 'textile' ? '请先创建纺织计划' : '请先创建水洗计划'}
</p>
<button <button
onClick={() => navigate('/purchaser/plans/new')} onClick={() => navigate(activeTab === 'textile' ? '/purchaser/plans/new' : '/purchaser/washing-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" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm sm:text-base"
> >
{activeTab === 'textile' ? '创建纺织计划' : '创建水洗计划'}
</button> </button>
</div> </div>
)} )}
@ -284,14 +432,14 @@ export function PlanOverview() {
planProductName={shareModal.productName} planProductName={shareModal.productName}
planColor={shareModal.color} planColor={shareModal.color}
planColorCode={shareModal.colorCode} planColorCode={shareModal.colorCode}
onDirectPush={() => refetch()} onDirectPush={() => textileRefetch()}
/> />
)} )}
</div> </div>
); );
} }
// 未关联计划区域组件 // 未关联计划区域组件(仅用于织布厂)
interface UnlinkedPlansSectionProps { interface UnlinkedPlansSectionProps {
plans: PlanWithSteps[]; plans: PlanWithSteps[];
activeTab: 'textile' | 'washing'; activeTab: 'textile' | 'washing';
@ -300,7 +448,7 @@ interface UnlinkedPlansSectionProps {
function UnlinkedPlansSection({ plans, activeTab, onShare }: UnlinkedPlansSectionProps) { function UnlinkedPlansSection({ plans, activeTab, onShare }: UnlinkedPlansSectionProps) {
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const factoryLabel = activeTab === 'textile' ? '织布厂' : '水洗厂'; const factoryLabel = '织布厂';
return ( return (
<motion.div <motion.div

View File

@ -78,6 +78,13 @@ export const OUTBOUND_TYPE = {
// ==================== UI 状态映射 ==================== // ==================== UI 状态映射 ====================
// 水洗计划状态映射
export const washingStatusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
[WASHING_PLAN_STATUS.PENDING]: { label: '待处理', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },
[WASHING_PLAN_STATUS.IN_PROGRESS]: { label: '处理中', color: 'text-purple-700', bgColor: 'bg-purple-50', borderColor: 'border-purple-200' },
[WASHING_PLAN_STATUS.COMPLETED]: { label: '已完成', color: 'text-emerald-700', bgColor: 'bg-emerald-50', borderColor: 'border-emerald-200' },
};
// 状态映射配置 // 状态映射配置
export const statusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = { export const statusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
[PLAN_STATUS.PENDING]: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' }, [PLAN_STATUS.PENDING]: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },