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

824 lines
32 KiB
TypeScript
Raw Normal View History

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