"use client"; import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { Loader2, Package, TrendingUp, Warehouse, CheckCircle, Factory, Truck, Plus, X, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { ShippingPlanEditorConfig, ItemGroup, PlanDetailRow, ItemAggregation, } from "./types"; import { getShippingPlanAggregate, batchSaveShippingPlans } from "@/lib/api/shipping"; export interface ShippingPlanEditorComponentProps extends ComponentRendererProps {} export const ShippingPlanEditorComponent: React.FC< ShippingPlanEditorComponentProps > = ({ component, isDesignMode = false, groupedData, formData, onFormDataChange, onClose, ...props }) => { const config = (component?.componentConfig || {}) as ShippingPlanEditorConfig; const [itemGroups, setItemGroups] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [source, setSource] = useState<"master" | "detail">("detail"); const itemGroupsRef = useRef([]); const sourceRef = useRef<"master" | "detail">("detail"); // groupedData에서 선택된 행 추출 (마스터든 디테일이든 그대로) const selectedRows = useMemo(() => { if (!groupedData) return []; if (Array.isArray(groupedData)) return groupedData; if (groupedData.selectedRows) return groupedData.selectedRows; if (groupedData.data) return groupedData.data; return []; }, [groupedData]); // 선택된 행의 ID 목록 추출 (문자열) const selectedIds = useMemo(() => { return selectedRows .map((row: any) => String(row.id)) .filter((id: string) => id && id !== "undefined" && id !== "null"); }, [selectedRows]); const loadData = useCallback(async () => { if (selectedIds.length === 0 || isDesignMode) return; setLoading(true); try { // ID만 보내면 백엔드에서 소스 감지 + JOIN + 정규화 const res = await getShippingPlanAggregate(selectedIds); if (!res.success) { toast.error("집계 데이터 조회 실패"); return; } setSource(res.source); const aggregateData = res.data || {}; const groups: ItemGroup[] = Object.entries(aggregateData).map( ([partCode, data]) => { const details: PlanDetailRow[] = []; // 수주별로 기존 계획 합산량 계산 const existingPlansBySource = new Map(); for (const plan of data.existingPlans || []) { const prev = existingPlansBySource.get(plan.sourceId) || 0; existingPlansBySource.set(plan.sourceId, prev + plan.planQty); } // 신규 행 먼저: 모든 수주에 대해 항상 추가 (분할출하 대응) for (const order of data.orders || []) { const alreadyPlanned = existingPlansBySource.get(order.sourceId) || 0; const remainingBalance = Math.max(0, order.balanceQty - alreadyPlanned); details.push({ type: "new", sourceId: order.sourceId, orderNo: order.orderNo, partnerName: order.partnerName, dueDate: order.dueDate, balanceQty: remainingBalance, planQty: 0, planDate: new Date().toISOString().split("T")[0], splitKey: `${order.sourceId}_0`, }); } // 기존 출하계획 아래에 표시 for (const plan of data.existingPlans || []) { const matchOrder = data.orders?.find( (o) => o.sourceId === plan.sourceId ); details.push({ type: "existing", sourceId: plan.sourceId, orderNo: matchOrder?.orderNo || "-", partnerName: matchOrder?.partnerName || "-", dueDate: matchOrder?.dueDate || "-", balanceQty: matchOrder?.balanceQty || 0, planQty: plan.planQty, existingPlanId: plan.id, }); } // partName: orders에서 가져오기 const partName = data.orders?.[0]?.partName || partCode; return { partCode, partName, aggregation: { totalBalance: data.totalBalance, totalPlanQty: data.totalPlanQty, currentStock: data.currentStock, availableStock: data.availableStock, inProductionQty: data.inProductionQty, }, details, }; } ); setItemGroups(groups); } catch (err) { console.error("[v2-shipping-plan-editor] 데이터 로드 실패:", err); toast.error("데이터를 불러오는데 실패했습니다"); } finally { setLoading(false); } }, [selectedIds, isDesignMode]); useEffect(() => { loadData(); }, [loadData]); // ref 동기화 (이벤트 핸들러에서 최신 state 접근용) useEffect(() => { itemGroupsRef.current = itemGroups; }, [itemGroups]); useEffect(() => { sourceRef.current = source; }, [source]); // 저장 로직 (ref 기반으로 최신 state 접근, 재구독 방지) const savingRef = useRef(false); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const configRef = useRef(config); configRef.current = config; const handleSave = useCallback(async () => { if (savingRef.current) return; const currentGroups = itemGroupsRef.current; const currentSource = sourceRef.current; const currentConfig = configRef.current; const plans = currentGroups.flatMap((g) => g.details .filter((d) => d.type === "new" && d.planQty > 0) .map((d) => ({ sourceId: d.sourceId, planQty: d.planQty, planDate: d.planDate || new Date().toISOString().split("T")[0], balanceQty: d.balanceQty, })) ); if (plans.length === 0) { toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요."); return; } // 같은 sourceId별 합산 → 미출하량 초과 검증 if (!currentConfig.allowOverPlan) { const sumBySource = new Map(); for (const p of plans) { const prev = sumBySource.get(p.sourceId) || { total: 0, balance: p.balanceQty }; sumBySource.set(p.sourceId, { total: prev.total + p.planQty, balance: prev.balance, }); } for (const [, val] of sumBySource) { if (val.balance > 0 && val.total > val.balance) { toast.error("분할 출하계획의 합산이 미출하량을 초과합니다."); return; } } } // 저장 전 확인 (confirmBeforeSave = true일 때) if (currentConfig.confirmBeforeSave) { const msg = currentConfig.confirmMessage || "출하계획을 저장하시겠습니까?"; if (!window.confirm(msg)) return; } savingRef.current = true; setSaving(true); try { const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty, planDate: p.planDate })); const res = await batchSaveShippingPlans(savePlans, currentSource); if (res.success) { toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`); if (currentConfig.autoCloseOnSave !== false && onCloseRef.current) { onCloseRef.current(); } } else { toast.error(res.error || "출하계획 저장에 실패했습니다."); } } catch (err) { console.error("[v2-shipping-plan-editor] 저장 실패:", err); toast.error("출하계획 저장 중 오류가 발생했습니다."); } finally { savingRef.current = false; setSaving(false); } }, []); // V2 이벤트 버스 구독 (마운트 1회만, ref로 최신 핸들러 참조) const handleSaveRef = useRef(handleSave); handleSaveRef.current = handleSave; useEffect(() => { let unsubscribe: (() => void) | null = null; let mounted = true; (async () => { const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core"); if (!mounted) return; unsubscribe = v2EventBus.subscribe(V2_EVENTS.SHIPPING_PLAN_SAVE, () => { handleSaveRef.current(); }); })(); return () => { mounted = false; if (unsubscribe) unsubscribe(); }; }, []); // 집계 재계산 헬퍼 const recalcAggregation = (group: ItemGroup): ItemGroup => { const newPlanTotal = group.details .filter((d) => d.type === "new") .reduce((sum, d) => sum + d.planQty, 0); const existingPlanTotal = group.details .filter((d) => d.type === "existing") .reduce((sum, d) => sum + d.planQty, 0); return { ...group, aggregation: { ...group.aggregation, totalPlanQty: existingPlanTotal + newPlanTotal, availableStock: group.aggregation.currentStock - (existingPlanTotal + newPlanTotal), }, }; }; const handlePlanQtyChange = useCallback( (groupIdx: number, detailIdx: number, value: string) => { setItemGroups((prev) => { const next = [...prev]; const group = { ...next[groupIdx] }; const details = [...group.details]; const target = details[detailIdx]; let qty = Number(value) || 0; // 같은 sourceId의 다른 신규 행 합산 const otherSum = details .filter((d, i) => d.type === "new" && d.sourceId === target.sourceId && i !== detailIdx) .reduce((sum, d) => sum + d.planQty, 0); // 잔여 가능량 = 미출하량 - 다른 분할 행 합산 const maxAllowed = Math.max(0, target.balanceQty - otherSum); qty = Math.min(qty, maxAllowed); qty = Math.max(0, qty); details[detailIdx] = { ...details[detailIdx], planQty: qty }; group.details = details; next[groupIdx] = recalcAggregation(group); return next; }); }, [] ); // 출하계획일 변경 const handlePlanDateChange = useCallback( (groupIdx: number, detailIdx: number, value: string) => { setItemGroups((prev) => { const next = [...prev]; const group = { ...next[groupIdx] }; const details = [...group.details]; details[detailIdx] = { ...details[detailIdx], planDate: value }; group.details = details; next[groupIdx] = group; return next; }); }, [] ); // 분할 행 추가 (같은 수주에 새 행) const handleAddSplitRow = useCallback( (groupIdx: number, sourceId: string) => { setItemGroups((prev) => { const next = [...prev]; const group = { ...next[groupIdx] }; const details = [...group.details]; // 같은 sourceId의 신규 행 중 마지막 찾기 const existingNewRows = details.filter( (d) => d.type === "new" && d.sourceId === sourceId ); const baseRow = existingNewRows[0]; if (!baseRow) return prev; const splitCount = existingNewRows.length; const newRow: PlanDetailRow = { type: "new", sourceId, orderNo: baseRow.orderNo, partnerName: baseRow.partnerName, dueDate: baseRow.dueDate, balanceQty: baseRow.balanceQty, planQty: 0, planDate: new Date().toISOString().split("T")[0], splitKey: `${sourceId}_${splitCount}`, }; // 같은 sourceId 신규 행 바로 뒤에 삽입 const lastNewIdx = details.reduce( (last, d, i) => d.type === "new" && d.sourceId === sourceId ? i : last, -1 ); details.splice(lastNewIdx + 1, 0, newRow); group.details = details; next[groupIdx] = group; return next; }); }, [] ); // 분할 행 삭제 const handleRemoveSplitRow = useCallback( (groupIdx: number, detailIdx: number) => { setItemGroups((prev) => { const next = [...prev]; const group = { ...next[groupIdx] }; const details = [...group.details]; const target = details[detailIdx]; // 같은 sourceId의 신규 행이 1개뿐이면 삭제 안 함 (최소 1행 유지) const sameSourceNewCount = details.filter( (d) => d.type === "new" && d.sourceId === target.sourceId ).length; if (sameSourceNewCount <= 1) { toast.warning("최소 1개의 출하계획 행이 필요합니다."); return prev; } details.splice(detailIdx, 1); group.details = details; next[groupIdx] = recalcAggregation(group); return next; }); }, [] ); if (isDesignMode) { return (
{config.title || "출하계획 등록"}
{[ "총수주잔량", "총출하계획량", "현재고", "가용재고", "생산중수량", ].map((label) => (
0 {label}
))}
상세 테이블 영역
); } if (loading || saving) { return (
{saving ? "출하계획 저장 중..." : "데이터 로딩 중..."}
); } if (selectedIds.length === 0) { return (
선택된 수주가 없습니다
); } const showSummary = config.showSummaryCards !== false; const showExisting = config.showExistingPlans !== false; return (
{itemGroups.map((group, groupIdx) => (
{group.partName} ({group.partCode})
{showSummary && ( )}
))}
); }; interface VisibleCards { totalBalance?: boolean; totalPlanQty?: boolean; currentStock?: boolean; availableStock?: boolean; inProductionQty?: boolean; } const SummaryCards: React.FC<{ aggregation: ItemAggregation; visibleCards?: VisibleCards; }> = ({ aggregation, visibleCards }) => { const allCards = [ { key: "totalBalance" as const, label: "총수주잔량", value: aggregation.totalBalance, icon: TrendingUp, color: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200", }, }, { key: "totalPlanQty" as const, label: "총출하계획량", value: aggregation.totalPlanQty, icon: Truck, color: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200", }, }, { key: "currentStock" as const, label: "현재고", value: aggregation.currentStock, icon: Warehouse, color: { bg: "bg-emerald-50", text: "text-emerald-600", border: "border-emerald-200", }, }, { key: "availableStock" as const, label: "가용재고", value: aggregation.availableStock, icon: CheckCircle, color: { bg: aggregation.availableStock < 0 ? "bg-red-50" : "bg-amber-50", text: aggregation.availableStock < 0 ? "text-red-600" : "text-amber-600", border: aggregation.availableStock < 0 ? "border-red-200" : "border-amber-200", }, }, { key: "inProductionQty" as const, label: "생산중수량", value: aggregation.inProductionQty, icon: Factory, color: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200", }, }, ]; const cards = allCards.filter( (c) => !visibleCards || visibleCards[c.key] !== false ); return (
{cards.map((card) => { const Icon = card.icon; return (
{card.value.toLocaleString()}
{card.label}
); })}
); }; // sourceId별 그룹 (셀병합용) interface SourceGroup { sourceId: string; orderNo: string; partnerName: string; dueDate: string; balanceQty: number; rows: (PlanDetailRow & { _origIdx: number })[]; } const DetailTable: React.FC<{ details: PlanDetailRow[]; groupIdx: number; onPlanQtyChange: (groupIdx: number, detailIdx: number, value: string) => void; onPlanDateChange: (groupIdx: number, detailIdx: number, value: string) => void; onAddSplit: (groupIdx: number, sourceId: string) => void; onRemoveSplit: (groupIdx: number, detailIdx: number) => void; showExisting?: boolean; }> = ({ details, groupIdx, onPlanQtyChange, onPlanDateChange, onAddSplit, onRemoveSplit, showExisting = true }) => { const visibleDetails = details .map((d, idx) => ({ ...d, _origIdx: idx })) .filter((d) => showExisting || d.type === "new"); // sourceId별 그룹핑 (순서 유지) const sourceGroups = useMemo(() => { const map = new Map(); const order: string[] = []; for (const d of visibleDetails) { if (!map.has(d.sourceId)) { map.set(d.sourceId, { sourceId: d.sourceId, orderNo: d.orderNo, partnerName: d.partnerName, dueDate: d.dueDate, balanceQty: d.balanceQty, rows: [], }); order.push(d.sourceId); } map.get(d.sourceId)!.rows.push(d); } return order.map((id) => map.get(id)!); }, [visibleDetails]); // 같은 sourceId 신규 행들의 planQty 합산 (잔여량 계산용) const getRemaining = (sourceId: string, excludeOrigIdx: number) => { const group = sourceGroups.find((g) => g.sourceId === sourceId); if (!group) return 0; const otherSum = group.rows .filter((r) => r.type === "new" && r._origIdx !== excludeOrigIdx) .reduce((sum, r) => sum + r.planQty, 0); return Math.max(0, group.balanceQty - otherSum); }; return (
{sourceGroups.map((sg) => { const totalRows = sg.rows.length; const newCount = sg.rows.filter((r) => r.type === "new").length; return sg.rows.map((row, rowIdx) => { const isFirst = rowIdx === 0; const remaining = row.type === "new" ? getRemaining(row.sourceId, row._origIdx) : 0; const isDisabled = row.type === "new" && sg.balanceQty <= 0; return ( {/* 구분 - 매 행마다 표시 */} {/* 수주번호, 거래처, 납기일, 미출하 - 첫 행만 rowSpan */} {isFirst && ( <> )} {/* 출하계획량 */} {/* 출하계획일 */} {/* 분할 버튼 */} ); }); })} {sourceGroups.length === 0 && ( )}
구분 수주번호 거래처 납기일 미출하 출하계획량 출하계획일 분할
{row.type === "existing" ? ( 기존 ) : ( 신규 )} {sg.orderNo} {sg.partnerName} {sg.dueDate || "-"} {sg.balanceQty.toLocaleString()} {row.type === "existing" ? ( {row.planQty.toLocaleString()} ) : ( onPlanQtyChange(groupIdx, row._origIdx, e.target.value)} disabled={isDisabled} className="mx-auto h-7 w-24 text-center text-xs disabled:opacity-40" placeholder="0" /> )} {row.type === "existing" ? ( - ) : ( onPlanDateChange(groupIdx, row._origIdx, e.target.value)} disabled={isDisabled} className="mx-auto h-7 w-32 text-xs disabled:opacity-40" /> )} {row.type === "new" && isFirst && ( )} {row.type === "new" && !isFirst && newCount > 1 && ( )}
데이터가 없습니다
); }; export const ShippingPlanEditorWrapper: React.FC< ShippingPlanEditorComponentProps > = (props) => { return ; };