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 && ( +
+

+ 备注: {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' },