748 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}