diff --git a/migrations/20260707_seed_washing_plans.sql b/migrations/20260707_seed_washing_plans.sql
new file mode 100644
index 0000000..9c603c7
--- /dev/null
+++ b/migrations/20260707_seed_washing_plans.sql
@@ -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;
diff --git a/src/components/plans/WashingPlanCard.tsx b/src/components/plans/WashingPlanCard.tsx
new file mode 100644
index 0000000..5f95f2b
--- /dev/null
+++ b/src/components/plans/WashingPlanCard.tsx
@@ -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 (
+
+
setExpanded(prev => !prev)}
+ className="p-3 sm:p-4 cursor-pointer"
+ >
+
+
+
+
+ {plan.product_name || '未知产品'}-{plan.product_color || ''}
+
+
+ {status.label}
+
+
+
+ {plan.fabric_code || ''} | 计划: {plan.planned_meters}米
+
+
{plan.plan_code}
+
+
+
+
+ ¥{plan.washing_price}/米
+
+
+
+
+
+
+
+
+ {/* Progress bar */}
+
+
+ 水洗进度
+
+ {plan.status === 'completed' ? '已完成' : plan.status === 'in_progress' ? '处理中' : '待处理'}
+
+
+
+
+
+
+
+ {expanded && (
+
+
+
+ }
+ label="计划水洗"
+ value={`${plan.planned_meters}米`}
+ />
+ }
+ label="水洗单价"
+ value={`¥${plan.washing_price}/米`}
+ />
+
+
+ {plan.estimated_total_cost != null && (
+
+ )}
+
+
+ {plan.status === 'completed' && (
+
+
实际结果
+
+
+
+ {plan.actual_total_cost != null && (
+
+ )}
+ {plan.washing_date && (
+
+ )}
+
+
+ )}
+
+ {plan.notes && (
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
+function InfoItem({ icon, label, value, highlight }: {
+ icon?: React.ReactNode;
+ label: string;
+ value: string;
+ highlight?: boolean;
+}) {
+ return (
+
+ {icon &&
{icon}}
+
+
{label}
+
+ {value}
+
+
+
+ );
+}
diff --git a/src/hooks/useWashingPlanData.ts b/src/hooks/useWashingPlanData.ts
new file mode 100644
index 0000000..ef1de3c
--- /dev/null
+++ b/src/hooks/useWashingPlanData.ts
@@ -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({
+ 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,
+ };
+}
diff --git a/src/pages/purchaser/PlanOverview.tsx b/src/pages/purchaser/PlanOverview.tsx
index ed6defa..d4b0875 100644
--- a/src/pages/purchaser/PlanOverview.tsx
+++ b/src/pages/purchaser/PlanOverview.tsx
@@ -2,14 +2,17 @@ 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 { useWashingPlanData } from '../../hooks/useWashingPlanData';
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 { WashingPlanCard } from '../../components/plans/WashingPlanCard';
import { ShareModal } from '../../components/plans/ShareModal';
-import { statusMap } from '../../utils/constants';
+import { statusMap, washingStatusMap } from '../../utils/constants';
import { calculateProgress } from '../../utils/helpers';
import type { PlanWithSteps } from '../../types';
+import type { WashingPlanWithProduct } from '../../hooks/useWashingPlanData';
export function PlanOverview() {
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);
- // 使用优化的数据获取 Hook
+ // 使用优化的数据获取 Hook(织布厂)
const {
plans,
factories,
planFactories,
operatorNames,
- loading,
- loadingMore,
- hasMore,
- loadMore,
- refetch
+ loading: textileLoading,
+ loadingMore: textileLoadingMore,
+ hasMore: textileHasMore,
+ loadMore: textileLoadMore,
+ refetch: textileRefetch
} = usePlanData({
companyId: auth.company?.id || null,
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(() => {
if (!statusFilter || statusFilter === 'all') return plans;
return plans.filter(p => p.status === statusFilter);
}, [plans, statusFilter]);
- // 筛选工厂
+ // 根据状态过滤 - 水洗厂
+ const filteredWashingPlansByStatus = useMemo(() => {
+ if (!statusFilter || statusFilter === 'all') return washingPlans;
+ const statusMapping: Record = {
+ producing: 'in_progress',
+ completed: 'completed',
+ };
+ const mappedStatus = statusMapping[statusFilter] || statusFilter;
+ return washingPlans.filter(p => p.status === mappedStatus);
+ }, [washingPlans, statusFilter]);
+
+ // 筛选工厂(仅织布厂 tab 使用)
const filteredFactories = useMemo(() =>
- factories.filter(f =>
- activeTab === 'textile' ? f.role === 'textile' : f.role === 'washing'
- ),
- [factories, activeTab]
+ factories.filter(f => f.role === 'textile'),
+ [factories]
);
- // 获取工厂的计划
+ // 获取工厂的计划(仅织布厂 tab 使用)
const getPlansForFactory = useCallback((factoryId: string) => {
const planIds = planFactories
.filter(pf => pf.factory_id === factoryId)
@@ -82,7 +113,7 @@ export function PlanOverview() {
return filteredPlansByStatus.filter(p => planIds.includes(p.id));
}, [planFactories, filteredPlansByStatus]);
- // 搜索过滤
+ // 搜索过滤 - 织布厂
const filterPlansBySearch = useCallback((plans: PlanWithSteps[]) => {
if (!searchQuery) return plans;
const query = searchQuery.toLowerCase();
@@ -93,14 +124,47 @@ export function PlanOverview() {
);
}, [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 = {};
+ 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(() =>
filteredPlansByStatus.filter(p =>
!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);
}, []);
+ // 总计划数(用于空状态判断)
+ const totalPlans = activeTab === 'textile' ? plans.length : washingPlans.length;
+
if (loading) {
return (
@@ -201,44 +268,117 @@ export function PlanOverview() {
type="text"
value={searchQuery}
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"
/>
- {/* 已关联工厂的计划 */}
- {filteredFactories.length > 0 && (
-
- {filteredFactories.map(factory => {
- const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id));
- if (factoryPlans.length === 0 && searchQuery) return null;
+ {/* ===== 织布厂 Tab 内容 ===== */}
+ {activeTab === 'textile' && (
+ <>
+ {/* 已关联工厂的计划 */}
+ {filteredFactories.length > 0 && (
+
+ {filteredFactories.map(factory => {
+ const factoryPlans = filterPlansBySearch(getPlansForFactory(factory.id));
+ if (factoryPlans.length === 0 && searchQuery) return null;
- return (
-
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}
- />
- );
- })}
-
+ return (
+
toggleGroup(factory.id)}
+ onTogglePlan={togglePlan}
+ onShare={(planId, type, factoryName, factoryId, productName, color, colorCode) => openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
+ onPlanUpdated={textileRefetch}
+ operatorNames={operatorNames}
+ />
+ );
+ })}
+
+ )}
+
+ {/* 未关联工厂的计划 */}
+ {unlinkedPlans.length > 0 && (
+ openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
+ />
+ )}
+ >
)}
- {/* 未关联工厂的计划 */}
- {unlinkedPlans.length > 0 && (
- openShareModal(planId, type, factoryName, factoryId, productName, color, colorCode)}
- />
+ {/* ===== 水洗厂 Tab 内容 ===== */}
+ {activeTab === 'washing' && (
+ <>
+ {/* 按工厂分组的水洗计划 */}
+ {Object.entries(washingPlansByFactory.linked).map(([factoryId, group]) => (
+
+ toggleGroup(factoryId)}
+ className="p-3 sm:p-4 cursor-pointer hover:bg-gray-50 transition-colors flex items-center justify-between"
+ >
+
+
+
+
+
+
{group.factoryName}
+
{group.plans.length} 个计划
+
+
+
+
+ {group.plans.map(plan => (
+
+ ))}
+
+
+ ))}
+
+ {/* 未关联工厂的水洗计划 */}
+ {washingPlansByFactory.unlinked.length > 0 && (
+
+
+
+
+
+
+
+
+ 待关联水洗厂的计划
+
+
+ 这些计划尚未关联水洗厂
+
+
+
+
+
+ {washingPlansByFactory.unlinked.map(plan => (
+
+ ))}
+
+
+ )}
+ >
)}
{/* 加载更多 */}
@@ -255,18 +395,26 @@ export function PlanOverview() {
)}
{/* 空状态 */}
- {plans.length === 0 && (
+ {totalPlans === 0 && (
-
+ {activeTab === 'textile' ? (
+
+ ) : (
+
+ )}
-
暂无计划
-
请先创建计划
+
+ {activeTab === 'textile' ? '暂无纺织计划' : '暂无水洗计划'}
+
+
+ {activeTab === 'textile' ? '请先创建纺织计划' : '请先创建水洗计划'}
+
)}
@@ -284,14 +432,14 @@ export function PlanOverview() {
planProductName={shareModal.productName}
planColor={shareModal.color}
planColorCode={shareModal.colorCode}
- onDirectPush={() => refetch()}
+ onDirectPush={() => textileRefetch()}
/>
)}
);
}
-// 未关联计划区域组件
+// 未关联计划区域组件(仅用于织布厂)
interface UnlinkedPlansSectionProps {
plans: PlanWithSteps[];
activeTab: 'textile' | 'washing';
@@ -300,7 +448,7 @@ interface UnlinkedPlansSectionProps {
function UnlinkedPlansSection({ plans, activeTab, onShare }: UnlinkedPlansSectionProps) {
const { isMobile } = useResponsive();
- const factoryLabel = activeTab === 'textile' ? '织布厂' : '水洗厂';
+ const factoryLabel = '织布厂';
return (
= {
+ [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 = {
[PLAN_STATUS.PENDING]: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },