824 lines
32 KiB
TypeScript
824 lines
32 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|