748 lines
32 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client';
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useErrorHandler } from '../../hooks/useErrorHandler';
import type { Company, FactoryType } from '../../types';
import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
interface YarnInput {
id: string;
name: string;
ratio: number;
yarn_type?: 'warp' | 'weft' | 'all';
}
// 产品类型定义(来自产品信息)
interface Product {
id: string;
product_name: string;
weight: number;
warp_weight: number | null;
weft_weight: number | null;
total_weight: number | null;
color: string;
fabric_code: string;
color_code: string;
production_price: number;
image_url: string | null;
yarn_usage_per_meter: number;
}
// 纱线配比类型
interface ProductYarnRatio {
id: string;
product_id: string;
yarn_name: string;
ratio: number;
yarn_type?: 'warp' | 'weft' | 'all';
}
// 纱线类型显示映射
const yarnTypeLabels: Record<string, string> = {
warp: '经线',
weft: '纬线',
all: '全部'
};
export function NewPlan() {
const navigate = useNavigate();
const { auth } = useAuth();
const handleError = useErrorHandler();
// 从产品信息选择的产品
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [productRatios, setProductRatios] = useState<Record<string, ProductYarnRatio[]>>({});
// 搜索和筛选状态
const [searchQuery, setSearchQuery] = useState('');
const [showProductDropdown, setShowProductDropdown] = useState(false);
const [showProductModal, setShowProductModal] = useState(false);
// 表单字段
const [productName, setProductName] = useState('');
const [color, setColor] = useState('');
const [fabricCode, setFabricCode] = useState('');
const [colorCode, setColorCode] = useState('01');
const [remark, setRemark] = useState('');
const [textileFactory, setTextileFactory] = useState('');
const [targetQuantity, setTargetQuantity] = useState('');
const [productionPrice, setProductionPrice] = useState('');
const [yarns, setYarns] = useState<YarnInput[]>([{ id: '1', name: '', ratio: 1 }]);
const [factories, setFactories] = useState<Company[]>([]);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (auth.company) {
fetchFactories();
fetchProducts();
}
}, [auth.company]);
// 从产品信息获取产品数据
const fetchProducts = async () => {
if (!auth.company) return;
setLoading(true);
const { data: prodData } = await supabase
.from('products')
.select('*')
.eq('company_id', auth.company.id)
.order('created_at', { ascending: false });
if (prodData) {
setProducts(prodData as Product[]);
// 获取每个产品的纱线配比
const productIds = prodData.map(p => p.id);
if (productIds.length > 0) {
const { data: ratiosData } = await supabase
.from('product_yarn_ratios')
.select('*')
.in('product_id', productIds);
if (ratiosData) {
const ratiosMap: Record<string, ProductYarnRatio[]> = {};
ratiosData.forEach((r: any) => {
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
ratiosMap[r.product_id].push(r as ProductYarnRatio);
});
setProductRatios(ratiosMap);
}
}
}
setLoading(false);
};
const fetchFactories = async () => {
if (!auth.company) return;
// 查询工厂管理中登记的纺织厂companies表中role=textile的记录
const { data } = await supabase
.from('companies')
.select('*')
.eq('role', 'textile')
.order('created_at', { ascending: false });
setFactories(data || []);
};
// 根据搜索词筛选产品
const filteredProducts = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return products.filter(product =>
product.product_name?.toLowerCase().includes(query) ||
product.fabric_code?.toLowerCase().includes(query) ||
product.color?.toLowerCase().includes(query) ||
product.color_code?.toLowerCase().includes(query)
).slice(0, 10); // 最多显示10条
}, [searchQuery, products]);
// 选择产品后自动填充表单
const handleSelectProduct = (product: Product) => {
setSelectedProduct(product);
setProductName(product.product_name);
setColor(product.color);
setFabricCode(product.fabric_code);
setColorCode(product.color_code);
setProductionPrice(String(product.production_price || ''));
setSearchQuery(`${product.product_name}-${product.weight}g-${product.color}`);
setShowProductDropdown(false);
setShowProductModal(false);
// 自动填充纱线配比
const ratios = productRatios[product.id];
if (ratios && ratios.length > 0) {
setYarns(ratios.map((r, i) => ({
id: i.toString(),
name: r.yarn_name,
ratio: r.ratio,
yarn_type: r.yarn_type || 'all'
})));
} else {
setYarns([{ id: '1', name: '', ratio: 1, yarn_type: 'all' }]);
}
};
// 清除选择的产品
const handleClearProduct = () => {
setSelectedProduct(null);
setProductName('');
setColor('');
setFabricCode('');
setColorCode('01');
setProductionPrice('');
setSearchQuery('');
setYarns([{ id: '1', name: '', ratio: 1 }]);
};
const addYarn = () => {
setYarns([...yarns, { id: Date.now().toString(), name: '', ratio: 1 }]);
};
const removeYarn = (id: string) => {
if (yarns.length > 1) setYarns(yarns.filter(y => y.id !== id));
};
const updateYarn = (id: string, field: 'name' | 'ratio', value: string | number) => {
setYarns(yarns.map(y => y.id === id ? { ...y, [field]: value } : y));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!auth.company) return;
setSubmitting(true);
try {
// 生成计划编号
const timestamp = Date.now().toString().slice(-6);
const planCode = `PLAN-2026-${timestamp}`;
// 创建计划
const { data: plan, error: planError } = await supabase
.from('production_plans')
.insert({
plan_code: planCode,
product_name: productName,
color: color,
fabric_code: fabricCode.toUpperCase(),
color_code: colorCode,
remark: remark,
purchaser_id: auth.company.id,
target_quantity: parseInt(targetQuantity) || 0,
yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量
production_price: parseFloat(productionPrice) || 0,
status: PLAN_STATUS.PENDING,
created_by: auth.user?.id
})
.select()
.maybeSingle();
if (planError) {
handleError(planError, { userMessage: '创建计划失败' });
setSubmitting(false);
return;
}
if (plan) {
// 创建纱线配比
const yarnErrors: string[] = [];
for (const yarn of yarns) {
if (yarn.name) {
const amountPerMeter = selectedProduct?.weight || 0;
const totalAmount = amountPerMeter * yarn.ratio * (parseInt(targetQuantity) || 0);
const { error: yarnError } = await supabase.from('yarn_ratios').insert({
plan_id: plan.id,
yarn_name: yarn.name,
ratio: yarn.ratio,
amount_per_meter: amountPerMeter * yarn.ratio,
total_amount: totalAmount
});
if (yarnError) {
yarnErrors.push(yarnError.message);
console.error('纱线配比创建失败:', yarnError);
}
}
}
// 创建流程节点
const stepErrors: string[] = [];
const steps: ('confirm' | 'yarn_purchase' | 'dyeing' | 'machine_start' | 'fabric_warehouse')[] = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
for (const step of steps) {
const { error: stepError } = await supabase.from('plan_process_steps').insert({
plan_id: plan.id,
step_type: step,
status: STEP_STATUS.PENDING
});
if (stepError) {
stepErrors.push(stepError.message);
console.error('流程节点创建失败:', stepError);
}
}
// 记录初始价格旧价格为0表示新建
const finalPrice = parseFloat(productionPrice) || 0;
if (finalPrice > 0) {
const { error: priceError } = await supabase.from('production_price_history').insert({
plan_id: plan.id,
old_price: 0,
new_price: finalPrice,
change_amount: finalPrice,
operator_id: auth.user?.id,
operator_name: auth.user?.username || '',
change_reason: '创建计划时设置初始价格'
});
if (priceError) {
console.error('价格历史记录失败:', priceError);
}
}
// 注意:工厂关联不再在创建计划时自动进行
// 而是通过分享链接或推送功能,由工厂确认后才建立关联
// 如果有子操作失败,提示用户但允许继续
if (yarnErrors.length > 0 || stepErrors.length > 0) {
console.warn('计划创建完成,但部分数据创建失败:', { yarnErrors, stepErrors });
}
}
alert('计划创建成功!');
navigate('/purchaser/plans');
} catch (err) {
handleError(err, { userMessage: '创建失败,请重试' });
}
setSubmitting(false);
};
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white shadow-sm sticky top-0 z-10">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
<button onClick={() => navigate('/purchaser')} className="p-2 hover:bg-gray-100 rounded-full">
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold"></h1>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* 产品信息选择 - 搜索+筛选形式 */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-base font-semibold mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-500 rounded"></span>
</h2>
{loading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : products.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p></p>
<button
type="button"
onClick={() => navigate('/purchaser/warehouse')}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
>
</button>
</div>
) : (
<div className="relative">
{/* 搜索输入框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowProductDropdown(true);
if (!e.target.value) {
handleClearProduct();
}
}}
onFocus={() => setShowProductDropdown(true)}
placeholder="搜索产品名称、坯布码、颜色、色号..."
className="w-full pl-10 pr-10 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{searchQuery && (
<button
type="button"
onClick={handleClearProduct}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded"
>
<X className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
{/* 搜索结果下拉框 */}
{showProductDropdown && (
<div className="absolute z-20 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredProducts.length > 0 ? (
filteredProducts.map(product => (
<div
key={product.id}
onClick={() => handleSelectProduct(product)}
className="p-3 hover:bg-blue-50 cursor-pointer border-b last:border-b-0"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{product.image_url ? (
<img
src={product.image_url}
alt={product.product_name}
className="w-10 h-10 rounded-lg object-cover border border-gray-200 flex-shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<span className="text-gray-400 text-xs"></span>
</div>
)}
<div>
<p className="font-medium text-gray-900">
{product.product_name}-{product.weight}g-{product.color}
</p>
<p className="text-xs text-gray-500 mt-1">
: {product.fabric_code} | : {product.color_code} | : ¥{product.production_price || 0}/
</p>
<p className="text-xs text-gray-400">
: {product.weight}g/m |
线: {product.warp_weight || '-'}g/m |
线: {product.weft_weight || '-'}g/m
</p>
</div>
</div>
<ChevronDown className="w-4 h-4 text-gray-400" />
</div>
</div>
))
) : searchQuery.trim() ? (
<div className="p-4 text-center text-gray-500">
</div>
) : (
<div className="p-4 text-center text-gray-400">
</div>
)}
</div>
)}
{/* 已选择产品展示 */}
{selectedProduct && (
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{selectedProduct.image_url ? (
<img
src={selectedProduct.image_url}
alt={selectedProduct.product_name}
className="w-16 h-16 rounded-lg object-cover border border-gray-200 flex-shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center flex-shrink-0">
<span className="text-gray-400 text-xs"></span>
</div>
)}
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium text-gray-900">
{selectedProduct.product_name}-{selectedProduct.weight}g-{selectedProduct.color}
</p>
<p className="text-xs text-gray-500 mt-1">
: {selectedProduct.fabric_code} | : {selectedProduct.color_code}
</p>
<p className="text-xs text-gray-500">
: {selectedProduct.weight}g/m |
线: {selectedProduct.warp_weight || '-'}g/m |
线: {selectedProduct.weft_weight || '-'}g/m
</p>
</div>
</div>
<button
type="button"
onClick={handleClearProduct}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* 产品信息 - 自动从原坯布产品获取 */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-base font-semibold mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-500 rounded"></span>
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="text"
value={productName}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="text"
value={color}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="text"
value={fabricCode}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="text"
value={colorCode}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">g/m</label>
<input
type="text"
value={selectedProduct?.weight ? `${selectedProduct.weight}g/m` : ''}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">线g/m</label>
<input
type="text"
value={selectedProduct?.warp_weight ? `${selectedProduct.warp_weight}g/m` : '-'}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">线g/m</label>
<input
type="text"
value={selectedProduct?.weft_weight ? `${selectedProduct.weft_weight}g/m` : '-'}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">g/m</label>
<input
type="text"
value={selectedProduct?.total_weight ? `${selectedProduct.total_weight}g/m` : (selectedProduct?.weight ? `${selectedProduct.weight}g/m` : '-')}
disabled
className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700"
/>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div className="col-span-2">
<label className="block text-sm text-gray-600 mb-1">线/</label>
<div className="w-full px-3 py-2 border rounded-lg bg-gray-100 text-gray-700 min-h-[42px] flex flex-wrap items-center gap-x-4">
{yarns.filter(y => y.name).map((yarn, idx) => (
<span key={yarn.id} className="inline-block whitespace-nowrap">
{yarnTypeLabels[yarn.yarn_type || 'all']}:{yarn.name}&nbsp;&nbsp;&nbsp;:{Number(yarn.ratio).toFixed(0)}%
</span>
))}
{!yarns.some(y => y.name) && <span className="text-gray-400">-</span>}
</div>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<div className="col-span-2">
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="text"
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="如60*4060双股等"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* 生产信息 */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-base font-semibold mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-green-500 rounded"></span>
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<select
value={textileFactory}
onChange={(e) => setTextileFactory(e.target.value)}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value=""></option>
{factories.map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1"></label>
<input
type="number"
value={targetQuantity}
onChange={(e) => setTargetQuantity(e.target.value)}
placeholder="请输入计划产量"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm text-gray-600 mb-1">/</label>
<input
type="number"
value={productionPrice}
onChange={(e) => setProductionPrice(e.target.value)}
placeholder={selectedProduct ? `原坯布价格: ${selectedProduct.production_price || 0} 元/米` : "请输入采购价"}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{selectedProduct && (
<p className="text-xs text-gray-400 mt-1"></p>
)}
</div>
</div>
</div>
<div className="flex gap-4">
<button type="button" onClick={() => navigate('/purchaser')} className="flex-1 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"></button>
<button type="submit" disabled={submitting || !selectedProduct} className="flex-1 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{submitting ? '创建中...' : '创建计划'}
</button>
</div>
</form>
</div>
{/* 产品选择弹窗 */}
<AnimatePresence>
{showProductModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={() => setShowProductModal(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 弹窗头部 */}
<div className="p-4 border-b flex items-center justify-between bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<button
onClick={() => setShowProductModal(false)}
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* 搜索框 */}
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索产品名称、坯布码、颜色、色号..."
className="w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* 产品列表 */}
<div className="overflow-y-auto max-h-[50vh]">
{filteredProducts.length > 0 ? (
<div className="divide-y">
{filteredProducts.map((product) => (
<div
key={product.id}
onClick={() => handleSelectProduct(product)}
className="p-4 hover:bg-blue-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{product.image_url ? (
<img
src={product.image_url}
alt={product.product_name}
className="w-12 h-12 rounded-lg object-cover border border-gray-200 flex-shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<span className="text-gray-400 text-xs"></span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-gray-900">
{product.product_name}-{product.weight}g-{product.color}
</p>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500">
<span>: {product.fabric_code}</span>
<span>: {product.color_code}</span>
<span>: ¥{product.production_price || 0}/</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-gray-400">
<span>: {product.weight}g/m</span>
<span>线: {product.warp_weight || '-'}g/m</span>
<span>线: {product.weft_weight || '-'}g/m</span>
<span>: {product.total_weight || product.weight}g/m</span>
</div>
</div>
</div>
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<ChevronDown className="w-4 h-4 text-blue-600 -rotate-90" />
</div>
</div>
</div>
))}
</div>
) : searchQuery.trim() ? (
<div className="p-8 text-center text-gray-500">
<Search className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p></p>
<p className="text-sm text-gray-400 mt-1"></p>
</div>
) : (
<div className="p-8 text-center text-gray-500">
<p className="text-gray-400"></p>
</div>
)}
</div>
{/* 弹窗底部 */}
<div className="p-4 border-t bg-gray-50 flex justify-between items-center">
<span className="text-sm text-gray-500">
{products.length}
</span>
<button
onClick={() => setShowProductModal(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"
>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}