iloom-flatten/src/pages/textile/PlanOverview.tsx

824 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 } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react';
import { ProcessFlow } from '../../components/ProcessFlow';
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
import { getSupabaseUrl } from '../../supabase/client';
import type { ProductionPlan, PlanProcessStep, Company, Profile } from '../../types';
const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: '待确定', color: 'bg-yellow-100 text-yellow-700' },
producing: { label: '生产中', color: 'bg-blue-100 text-blue-700' },
completed: { label: '已完成', color: 'bg-green-100 text-green-700' }
};
interface PlanGroup {
purchaserId: string;
purchaserName: string;
plans: (ProductionPlan & { processSteps: PlanProcessStep[]; inventoryRecords: { time: string; quantity: number; rolls: number | null }[] })[];
}
export function TextilePlanOverview() {
const navigate = useNavigate();
const { auth } = useAuth();
const [groups, setGroups] = useState<PlanGroup[]>([]);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [confirming, setConfirming] = useState<string | null>(null);
const [rejecting, setRejecting] = useState<string | null>(null);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectStepId, setRejectStepId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [importLink, setImportLink] = useState('');
const [operatorNames, setOperatorNames] = useState<Record<string, string>>({});
useEffect(() => {
if (!auth.company) return;
fetchPlans();
// 订阅入库记录变化,实现实时更新
const subscription = supabase
.channel('plan_overview_inventory_changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'inventory_records'
},
() => {
// 有入库记录变化时刷新数据
fetchPlans();
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [auth.company]);
const fetchPlans = async () => {
// 获取关联到当前纺织厂的计划
const { data: planFactories } = await supabase
.from('plan_factories')
.select('plan_id')
.eq('factory_id', auth.company!.id)
.eq('factory_type', 'textile');
if (!planFactories || planFactories.length === 0) {
setGroups([]);
setLoading(false);
return;
}
const planIds = planFactories.map(pf => pf.plan_id);
// 获取计划详情
const { data: planData } = await supabase
.from('production_plans')
.select('*')
.in('id', planIds)
.order('created_at', { ascending: false });
if (!planData || planData.length === 0) {
setGroups([]);
setLoading(false);
return;
}
// 获取所有流程步骤
const { data: stepsData } = await supabase
.from('plan_process_steps')
.select('*')
.in('plan_id', planIds);
// 获取操作人信息
const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[];
if (operatorIds.length > 0) {
const { data: profilesData } = await supabase
.from('profiles')
.select('id, username')
.in('id', operatorIds);
const names: Record<string, string> = {};
(profilesData || []).forEach(p => {
names[p.id] = p.username;
});
setOperatorNames(names);
}
// 获取采购商公司信息
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
const { data: companiesData } = await supabase
.from('companies')
.select('*')
.in('id', purchaserIds);
const companyMap: Record<string, string> = {};
(companiesData || []).forEach(c => { companyMap[c.id] = c.name; });
// 获取所有计划的入库记录
const { data: inventoryData } = await supabase
.from('inventory_records')
.select('plan_id, quantity, rolls, created_at')
.in('plan_id', planIds)
.order('created_at', { ascending: false });
// 按采购商分组
const grouped: Record<string, PlanGroup> = {};
planData.forEach(plan => {
const pid = plan.purchaser_id;
if (!grouped[pid]) {
grouped[pid] = {
purchaserId: pid,
purchaserName: companyMap[pid] || '未知布行',
plans: []
};
}
const processSteps = (stepsData || [])
.filter(s => s.plan_id === plan.id)
.sort((a, b) => {
const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
});
const inventoryRecords = (inventoryData || [])
.filter(r => r.plan_id === plan.id)
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
grouped[pid].plans.push({ ...plan, processSteps, inventoryRecords });
});
const groupList = Object.values(grouped);
setGroups(groupList);
// 默认展开第一个组
if (groupList.length > 0) {
setExpandedGroups(new Set([groupList[0].purchaserId]));
}
setLoading(false);
};
const toggleGroup = (id: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// 从 hash 路由链接中提取查询参数
const extractParamsFromLink = (link: string) => {
const hashMatch = link.match(/#\/import\?(.+)$/);
if (hashMatch) {
const queryString = hashMatch[1];
const params = new URLSearchParams(queryString);
return {
token: params.get('token'),
plan: params.get('plan'),
type: params.get('type')
};
}
try {
const url = new URL(link, window.location.origin);
return {
token: url.searchParams.get('token'),
plan: url.searchParams.get('plan'),
type: url.searchParams.get('type')
};
} catch {
return { token: null, plan: null, type: null };
}
};
const handleImportFromLink = () => {
if (!importLink.trim()) return;
const { token, plan, type } = extractParamsFromLink(importLink);
if (token) {
navigate(`/import?token=${token}`);
} else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`);
} else {
alert('无效的链接格式,请确保链接包含 token 或 plan 参数');
}
};
const handlePasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
if (text.includes('/import?') || text.includes('#/import?')) {
setImportLink(text);
const { token, plan, type } = extractParamsFromLink(text);
if (token) {
navigate(`/import?token=${token}`);
} else if (plan && type) {
navigate(`/import?plan=${plan}&type=${type}`);
} else {
alert('链接格式不正确,请检查链接是否完整');
}
} else {
alert('剪贴板中没有检测到有效的导入链接');
}
} catch (err) {
console.error('读取剪贴板失败:', err);
alert('无法读取剪贴板,请手动粘贴链接');
}
};
// 打开拒绝弹窗
const handleOpenRejectModal = (stepId: string) => {
setRejectStepId(stepId);
setRejectReason('');
setShowRejectModal(true);
};
// 关闭拒绝弹窗
const handleCloseRejectModal = () => {
setShowRejectModal(false);
setRejectStepId(null);
setRejectReason('');
};
// 处理拒绝计划
const handleRejectStep = async () => {
if (!rejectStepId || !rejectReason.trim()) {
alert('请输入拒绝原因');
return;
}
setRejecting(rejectStepId);
const now = new Date().toISOString();
try {
// 获取步骤信息
const { data: step } = await supabase
.from('plan_process_steps')
.select('plan_id')
.eq('id', rejectStepId)
.maybeSingle();
if (!step?.plan_id) {
setRejecting(null);
handleCloseRejectModal();
return;
}
// 更新步骤状态为拒绝(使用 notes 字段记录拒绝原因)
const { error } = await supabase
.from('plan_process_steps')
.update({
status: 'pending' as const,
notes: `拒绝原因: ${rejectReason.trim()}`,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', rejectStepId);
if (error) {
console.error('拒绝失败:', error);
alert('拒绝失败,请重试');
setRejecting(null);
return;
}
// 发送拒绝通知给采购商
await notifyPlanUpdated(step.plan_id, 'rejected', auth.company!.id, auth.user!.id);
// 刷新数据
await fetchPlans();
handleCloseRejectModal();
} catch (err) {
console.error('拒绝失败:', err);
alert('拒绝失败,请重试');
}
setRejecting(null);
};
const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => {
setConfirming(stepId);
const now = new Date().toISOString();
// 获取步骤信息
const { data: step } = await supabase
.from('plan_process_steps')
.select('step_type, plan_id')
.eq('id', stepId)
.maybeSingle();
const planId = step?.plan_id;
if (!planId) {
setConfirming(null);
return;
}
// 如果是坯布入库,使用 Edge Function 进行事务处理
if (step?.step_type === 'fabric_warehouse' && quantity && quantity > 0) {
// 获取纺织厂的仓库和工厂信息
const { data: warehouse } = await supabase
.from('warehouses')
.select('id')
.eq('company_id', auth.company!.id)
.eq('type', 'fabric')
.maybeSingle();
// 获取工厂信息(地址或名称)
const { data: factoryInfo } = await supabase
.from('companies')
.select('name, address')
.eq('id', auth.company!.id)
.maybeSingle();
// 仓库位置:优先使用地址,没有则使用工厂名称
const warehouseLocation = factoryInfo?.address || factoryInfo?.name || '';
// 获取计划信息包括采购商ID和采购价
const { data: plan } = await supabase
.from('production_plans')
.select('completed_quantity, target_quantity, purchaser_id, production_price')
.eq('id', planId)
.maybeSingle();
if (plan) {
const newCompletedQuantity = plan.completed_quantity + quantity;
const progressPercent = (newCompletedQuantity / plan.target_quantity) * 100;
const pricePerMeter = plan.production_price || 0;
const amount = quantity * pricePerMeter;
// 使用 Edge Function 进行入库事务处理
const session = (await supabase.auth.getSession()).data.session;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (session) {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
const response = await fetch(`${getSupabaseUrl()}/functions/v1/fabric-warehouse-inbound`, {
method: 'POST',
headers,
body: JSON.stringify({
planId: planId,
quantity: quantity,
rolls: rolls || 0,
warehouseId: warehouse?.id || null,
warehouseLocation: warehouseLocation,
operatorId: auth.user?.id,
pricePerMeter: pricePerMeter,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || '入库操作失败');
}
const inventoryRecord = result.data?.inventoryRecord;
// 创建或更新应付账款记录
if (inventoryRecord) {
// 检查是否已存在应付账款记录
const { data: existingAP } = await supabase
.from('accounts_payable')
.select('*')
.eq('plan_id', planId)
.eq('textile_factory_id', auth.company!.id)
.maybeSingle();
if (existingAP) {
// 更新应付账款
await supabase
.from('accounts_payable')
.update({
total_quantity: existingAP.total_quantity + quantity,
total_amount: existingAP.total_amount + amount,
unpaid_quantity: existingAP.unpaid_quantity + quantity,
unpaid_amount: existingAP.unpaid_amount + amount,
updated_at: now
})
.eq('id', existingAP.id);
// 创建应付账款明细
await supabase
.from('accounts_payable_items')
.insert({
accounts_payable_id: existingAP.id,
inventory_record_id: inventoryRecord.id,
quantity: quantity,
rolls: rolls || 0,
price_per_meter: pricePerMeter,
amount: amount,
status: 'unpaid',
created_at: now
});
} else {
// 创建新的应付账款
const { data: newAP } = await supabase
.from('accounts_payable')
.insert({
plan_id: planId,
purchaser_id: plan.purchaser_id,
textile_factory_id: auth.company!.id,
total_quantity: quantity,
total_amount: amount,
unpaid_quantity: quantity,
unpaid_amount: amount,
price_per_meter: pricePerMeter,
status: 'unpaid',
created_at: now,
updated_at: now
})
.select()
.single();
if (newAP) {
// 创建应付账款明细
await supabase
.from('accounts_payable_items')
.insert({
accounts_payable_id: newAP.id,
inventory_record_id: inventoryRecord.id,
quantity: quantity,
rolls: rolls || 0,
price_per_meter: pricePerMeter,
amount: amount,
status: 'unpaid',
created_at: now
});
}
}
}
// 如果进度达到97%或以上,标记步骤为完成,否则标记为进行中
if (progressPercent >= 97) {
await supabase
.from('plan_process_steps')
.update({
status: 'completed' as const,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
} else {
// 进度未达到97%标记步骤为进行中active
await supabase
.from('plan_process_steps')
.update({
status: 'active' as const,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
}
}
} else {
// 其他步骤直接更新状态
const { error } = await supabase
.from('plan_process_steps')
.update({
status: 'completed' as const,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
if (error) {
console.error('确认失败:', error);
alert('确认失败,请重试');
setConfirming(null);
return;
}
}
// 更新计划状态
if (step?.step_type === 'confirm') {
await supabase
.from('production_plans')
.update({ status: 'producing' as const })
.eq('id', planId);
// 发送计划开始生产通知
await notifyPlanUpdated(planId, 'producing', auth.company!.id, auth.user!.id);
}
// 检查是否所有步骤都已完成
const { data: allSteps } = await supabase
.from('plan_process_steps')
.select('status')
.eq('plan_id', planId);
const allCompleted = allSteps?.every(s => s.status === 'completed');
if (allCompleted) {
await supabase
.from('production_plans')
.update({ status: 'completed' as const })
.eq('id', planId);
// 发送计划完成通知
await notifyPlanUpdated(planId, 'completed', auth.company!.id, auth.user!.id);
}
// 发送节点完成通知
const stepNames: Record<string, string> = {
confirm: '确认计划',
yarn_purchase: '采纱',
dyeing: '染纱',
machine_start: '上机',
fabric_warehouse: '坯布生产'
};
const stepName = stepNames[step?.step_type || ''] || '生产节点';
await notifyStepCompleted(
planId,
stepName,
auth.company!.id,
auth.user!.id,
step?.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined
);
// 刷新数据
await fetchPlans();
setConfirming(null);
};
// 将步骤转换为 ProcessFlow 需要的格式
const formatProcessNodes = (steps: PlanProcessStep[]) => {
return steps.map(step => ({
id: step.id,
name: step.step_type,
status: step.status,
timestamp: step.timestamp,
stepType: step.step_type,
operatorName: step.operator_id ? operatorNames[step.operator_id] : undefined
}));
};
return (
<div className="min-h-screen bg-gray-50 p-3 sm:p-4 md:p-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-2 sm:gap-3 md:gap-4 mb-4 sm:mb-5 md:mb-6">
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => navigate('/textile')}
className="p-2 hover:bg-gray-200 rounded-full transition-colors will-change-transform"
>
<ArrowLeft className="w-5 h-5 sm:w-6 sm:h-6 text-gray-600" />
</motion.button>
<h1 className="text-lg sm:text-xl md:text-2xl font-bold text-gray-800"></h1>
</div>
{/* 导入链接区域 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4 mb-4 sm:mb-5 md:mb-6"
>
<div className="flex items-center justify-between mb-2 sm:mb-3">
<h2 className="font-semibold text-gray-800 flex items-center gap-2 text-sm sm:text-base">
<Link2 className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</h2>
<motion.button
whileTap={{ scale: 0.97 }}
onClick={handlePasteFromClipboard}
className="flex items-center gap-1 px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors will-change-transform"
>
<Clipboard className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</motion.button>
</div>
<div className="flex gap-2">
<input
type="text"
value={importLink}
onChange={(e) => setImportLink(e.target.value)}
placeholder="粘贴采购商分享的链接..."
className="flex-1 px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-xs sm:text-sm"
/>
<motion.button
whileTap={{ scale: 0.97 }}
onClick={handleImportFromLink}
disabled={!importLink.trim()}
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm font-medium will-change-transform"
>
</motion.button>
</div>
</motion.div>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 sm:py-16">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-6 h-6 sm:w-8 sm:h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3"
/>
<p className="text-gray-400 text-sm">...</p>
</div>
) : groups.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-12 sm:py-16 text-gray-400"
>
<p className="mb-2 text-sm sm:text-base"></p>
<p className="text-xs sm:text-sm text-gray-400">使</p>
</motion.div>
) : (
<div className="space-y-3 sm:space-y-4">
{groups.map((group, groupIndex) => (
<motion.div
key={group.purchaserId}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: groupIndex * 0.08, duration: 0.35 }}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"
>
<motion.div
onClick={() => toggleGroup(group.purchaserId)}
whileTap={{ scale: 0.995 }}
className="flex items-center justify-between p-3 sm:p-4 cursor-pointer hover:bg-gray-50 transition-colors will-change-transform"
>
<div className="flex items-center gap-2 sm:gap-3">
<motion.button
animate={{ rotate: expandedGroups.has(group.purchaserId) ? 90 : 0 }}
transition={{ duration: 0.2 }}
className="p-1 rounded hover:bg-gray-200 will-change-transform"
>
<ChevronRight size={18} className="sm:w-5 sm:h-5" />
</motion.button>
<div>
<h3 className="font-semibold text-gray-800 text-sm sm:text-base">{group.purchaserName}</h3>
<span className="text-xs sm:text-sm text-gray-500"> ({group.plans.length})</span>
</div>
</div>
</motion.div>
<AnimatePresence>
{expandedGroups.has(group.purchaserId) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
className="border-t border-gray-100"
>
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4">
{group.plans.map((plan, planIndex) => {
const progress = plan.target_quantity > 0
? Math.round((plan.completed_quantity / plan.target_quantity) * 100)
: 0;
return (
<motion.div
key={plan.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: planIndex * 0.05, duration: 0.3 }}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<motion.div
onClick={() => setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
whileTap={{ scale: 0.995 }}
className="p-3 sm:p-4 cursor-pointer hover:bg-gray-50 transition-colors will-change-transform"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1.5 sm:mb-2">
<span className={`px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs rounded-full ${statusMap[plan.status]?.color || 'bg-gray-100'}`}>
{statusMap[plan.status]?.label || plan.status}
</span>
</div>
<p className="text-xs sm:text-sm text-gray-600 truncate">{plan.fabric_code}</p>
<p className="text-[10px] sm:text-xs text-gray-400">{plan.plan_code}</p>
</div>
<div className="text-right text-xs sm:text-sm text-gray-600 flex-shrink-0">
<p>: {plan.target_quantity}</p>
<p>: {plan.completed_quantity}</p>
</div>
</div>
<div className="mt-2 sm:mt-3">
<div className="flex justify-between text-[10px] sm:text-xs text-gray-500 mb-1">
<span></span>
<span>{progress}%</span>
</div>
<div className="h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.46, 0.45, 0.94] }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full will-change-transform"
/>
</div>
</div>
</motion.div>
<AnimatePresence>
{expandedPlan === plan.id && plan.processSteps.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="border-t border-gray-100 p-3 sm:p-4 bg-gray-50"
>
<h5 className="font-medium mb-3 sm:mb-4 flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-blue-600" />
</h5>
<ProcessFlow
nodes={formatProcessNodes(plan.processSteps)}
onConfirm={(stepId, quantity, rolls) => handleConfirmStep(stepId, quantity, rolls)}
onReject={handleOpenRejectModal}
confirmLoading={confirming}
showConfirmButton={true}
planTargetQuantity={plan.target_quantity}
planCompletedQuantity={plan.completed_quantity}
isPlanLocked={plan.status === 'completed'}
lastInventoryTime={plan.inventoryRecords[0]?.time}
inventoryRecords={plan.inventoryRecords}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
)}
</div>
{/* 拒绝计划弹窗 */}
<AnimatePresence>
{showRejectModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 20 }}
transition={{ duration: 0.2, ease: [0.25, 0.46, 0.45, 0.94] }}
className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl"
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<X className="w-6 h-6 text-red-600" />
</div>
<h3 className="text-lg font-semibold text-gray-800"></h3>
</div>
<p className="text-sm text-gray-600 mb-4">
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请输入拒绝原因,如:产能不足、价格不合适等..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 text-sm resize-none"
rows={4}
/>
</div>
<div className="flex gap-3">
<motion.button
whileTap={{ scale: 0.98 }}
onClick={handleCloseRejectModal}
className="flex-1 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 text-sm font-medium transition-colors"
>
</motion.button>
<motion.button
whileTap={{ scale: 0.98 }}
onClick={handleRejectStep}
disabled={!rejectReason.trim() || rejecting === rejectStepId}
className="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{rejecting === rejectStepId ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<X className="w-4 h-4" />
)}
</motion.button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}