2026-03-18 14:42:47 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
|
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
|
|
|
import {
|
|
|
|
|
Loader2,
|
|
|
|
|
Package,
|
|
|
|
|
TrendingUp,
|
|
|
|
|
Warehouse,
|
|
|
|
|
CheckCircle,
|
|
|
|
|
Factory,
|
|
|
|
|
Truck,
|
2026-03-18 16:04:55 +09:00
|
|
|
Plus,
|
|
|
|
|
X,
|
2026-03-18 14:42:47 +09:00
|
|
|
} 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<ItemGroup[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [source, setSource] = useState<"master" | "detail">("detail");
|
|
|
|
|
const itemGroupsRef = useRef<ItemGroup[]>([]);
|
|
|
|
|
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<string, number>();
|
|
|
|
|
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,
|
2026-03-18 16:04:55 +09:00
|
|
|
planDate: new Date().toISOString().split("T")[0],
|
|
|
|
|
splitKey: `${order.sourceId}_0`,
|
2026-03-18 14:42:47 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 출하계획 아래에 표시
|
|
|
|
|
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)
|
2026-03-18 16:04:55 +09:00
|
|
|
.map((d) => ({
|
|
|
|
|
sourceId: d.sourceId,
|
|
|
|
|
planQty: d.planQty,
|
|
|
|
|
planDate: d.planDate || new Date().toISOString().split("T")[0],
|
|
|
|
|
balanceQty: d.balanceQty,
|
|
|
|
|
}))
|
2026-03-18 14:42:47 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (plans.length === 0) {
|
|
|
|
|
toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// 같은 sourceId별 합산 → 미출하량 초과 검증
|
2026-03-18 14:42:47 +09:00
|
|
|
if (!currentConfig.allowOverPlan) {
|
2026-03-18 16:04:55 +09:00
|
|
|
const sumBySource = new Map<string, { total: number; balance: number }>();
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-03-18 14:42:47 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 저장 전 확인 (confirmBeforeSave = true일 때)
|
|
|
|
|
if (currentConfig.confirmBeforeSave) {
|
|
|
|
|
const msg = currentConfig.confirmMessage || "출하계획을 저장하시겠습니까?";
|
|
|
|
|
if (!window.confirm(msg)) return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
savingRef.current = true;
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
2026-03-18 16:04:55 +09:00
|
|
|
const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty, planDate: p.planDate }));
|
2026-03-18 14:42:47 +09:00
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// 집계 재계산 헬퍼
|
|
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 14:42:47 +09:00
|
|
|
const handlePlanQtyChange = useCallback(
|
|
|
|
|
(groupIdx: number, detailIdx: number, value: string) => {
|
|
|
|
|
setItemGroups((prev) => {
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
const group = { ...next[groupIdx] };
|
|
|
|
|
const details = [...group.details];
|
2026-03-18 16:04:55 +09:00
|
|
|
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);
|
2026-03-18 14:42:47 +09:00
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
details[detailIdx] = { ...details[detailIdx], planQty: qty };
|
2026-03-18 14:42:47 +09:00
|
|
|
group.details = details;
|
2026-03-18 16:04:55 +09:00
|
|
|
next[groupIdx] = recalcAggregation(group);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
2026-03-18 14:42:47 +09:00
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// 출하계획일 변경
|
|
|
|
|
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];
|
2026-03-18 14:42:47 +09:00
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// 같은 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}`,
|
2026-03-18 14:42:47 +09:00
|
|
|
};
|
|
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// 같은 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;
|
2026-03-18 14:42:47 +09:00
|
|
|
next[groupIdx] = group;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// 분할 행 삭제
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-18 14:42:47 +09:00
|
|
|
if (isDesignMode) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full flex-col gap-3 rounded-lg border border-dashed border-gray-300 p-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Truck className="h-5 w-5 text-muted-foreground" />
|
|
|
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
|
|
|
{config.title || "출하계획 등록"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{[
|
|
|
|
|
"총수주잔량",
|
|
|
|
|
"총출하계획량",
|
|
|
|
|
"현재고",
|
|
|
|
|
"가용재고",
|
|
|
|
|
"생산중수량",
|
|
|
|
|
].map((label) => (
|
|
|
|
|
<div
|
|
|
|
|
key={label}
|
|
|
|
|
className="flex flex-1 flex-col items-center rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-lg font-bold text-gray-400">0</span>
|
|
|
|
|
<span className="text-[10px] text-gray-400">{label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
|
|
|
<span className="text-xs text-gray-400">상세 테이블 영역</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loading || saving) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
<div className="flex flex-col items-center gap-3">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
{saving ? "출하계획 저장 중..." : "데이터 로딩 중..."}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (selectedIds.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
선택된 수주가 없습니다
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const showSummary = config.showSummaryCards !== false;
|
|
|
|
|
const showExisting = config.showExistingPlans !== false;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full flex-col gap-4 overflow-auto p-4">
|
|
|
|
|
{itemGroups.map((group, groupIdx) => (
|
|
|
|
|
<div key={group.partCode} className="flex flex-col gap-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Package className="h-4 w-4 text-primary" />
|
|
|
|
|
<span className="text-sm font-semibold">
|
|
|
|
|
{group.partName} ({group.partCode})
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showSummary && (
|
|
|
|
|
<SummaryCards
|
|
|
|
|
aggregation={group.aggregation}
|
|
|
|
|
visibleCards={config.visibleSummaryCards}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<DetailTable
|
|
|
|
|
details={group.details}
|
|
|
|
|
groupIdx={groupIdx}
|
|
|
|
|
onPlanQtyChange={handlePlanQtyChange}
|
2026-03-18 16:04:55 +09:00
|
|
|
onPlanDateChange={handlePlanDateChange}
|
|
|
|
|
onAddSplit={handleAddSplitRow}
|
|
|
|
|
onRemoveSplit={handleRemoveSplitRow}
|
2026-03-18 14:42:47 +09:00
|
|
|
showExisting={showExisting}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{cards.map((card) => {
|
|
|
|
|
const Icon = card.icon;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={card.label}
|
|
|
|
|
className={`flex flex-1 flex-col items-center rounded-lg border ${card.color.border} ${card.color.bg} px-3 py-2 transition-shadow hover:shadow-sm`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Icon className={`h-3.5 w-3.5 ${card.color.text}`} />
|
|
|
|
|
<span className={`text-xl font-bold ${card.color.text}`}>
|
|
|
|
|
{card.value.toLocaleString()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className={`mt-0.5 text-[10px] ${card.color.text}`}>
|
|
|
|
|
{card.label}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 16:04:55 +09:00
|
|
|
// sourceId별 그룹 (셀병합용)
|
|
|
|
|
interface SourceGroup {
|
|
|
|
|
sourceId: string;
|
|
|
|
|
orderNo: string;
|
|
|
|
|
partnerName: string;
|
|
|
|
|
dueDate: string;
|
|
|
|
|
balanceQty: number;
|
|
|
|
|
rows: (PlanDetailRow & { _origIdx: number })[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:42:47 +09:00
|
|
|
const DetailTable: React.FC<{
|
|
|
|
|
details: PlanDetailRow[];
|
|
|
|
|
groupIdx: number;
|
2026-03-18 16:04:55 +09:00
|
|
|
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;
|
2026-03-18 14:42:47 +09:00
|
|
|
showExisting?: boolean;
|
2026-03-18 16:04:55 +09:00
|
|
|
}> = ({ details, groupIdx, onPlanQtyChange, onPlanDateChange, onAddSplit, onRemoveSplit, showExisting = true }) => {
|
2026-03-18 14:42:47 +09:00
|
|
|
const visibleDetails = details
|
|
|
|
|
.map((d, idx) => ({ ...d, _origIdx: idx }))
|
|
|
|
|
.filter((d) => showExisting || d.type === "new");
|
2026-03-18 16:04:55 +09:00
|
|
|
|
|
|
|
|
// sourceId별 그룹핑 (순서 유지)
|
|
|
|
|
const sourceGroups = useMemo(() => {
|
|
|
|
|
const map = new Map<string, SourceGroup>();
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 14:42:47 +09:00
|
|
|
return (
|
|
|
|
|
<div className="overflow-hidden rounded-lg border">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="border-b bg-muted/50">
|
2026-03-18 16:04:55 +09:00
|
|
|
<th className="w-14 px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
2026-03-18 14:42:47 +09:00
|
|
|
구분
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
|
|
|
수주번호
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
|
|
|
거래처
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
|
|
|
납기일
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
|
|
|
|
|
미출하
|
|
|
|
|
</th>
|
2026-03-18 16:04:55 +09:00
|
|
|
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
|
2026-03-18 14:42:47 +09:00
|
|
|
출하계획량
|
|
|
|
|
</th>
|
2026-03-18 16:04:55 +09:00
|
|
|
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
|
|
|
|
|
출하계획일
|
|
|
|
|
</th>
|
|
|
|
|
<th className="w-16 px-2 py-2 text-center text-xs font-medium text-muted-foreground">
|
|
|
|
|
분할
|
|
|
|
|
</th>
|
2026-03-18 14:42:47 +09:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
2026-03-18 16:04:55 +09:00
|
|
|
{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 (
|
|
|
|
|
<tr
|
|
|
|
|
key={row.splitKey || `${row.type}-${row.sourceId}-${row.existingPlanId || row._origIdx}`}
|
|
|
|
|
className={`hover:bg-muted/30 ${rowIdx < totalRows - 1 ? "" : "border-b"}`}
|
|
|
|
|
>
|
|
|
|
|
{/* 구분 - 매 행마다 표시 */}
|
|
|
|
|
<td className={`px-3 py-2 ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
|
|
|
|
{row.type === "existing" ? (
|
|
|
|
|
<Badge variant="secondary" className="text-[10px]">기존</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge className="bg-primary text-[10px] text-primary-foreground">신규</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* 수주번호, 거래처, 납기일, 미출하 - 첫 행만 rowSpan */}
|
|
|
|
|
{isFirst && (
|
|
|
|
|
<>
|
|
|
|
|
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
|
|
|
|
|
{sg.orderNo}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
|
|
|
|
|
{sg.partnerName}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
|
|
|
|
|
{sg.dueDate || "-"}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="border-t px-3 py-2 text-right text-xs font-bold align-middle" rowSpan={totalRows}>
|
|
|
|
|
{sg.balanceQty.toLocaleString()}
|
|
|
|
|
</td>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 출하계획량 */}
|
|
|
|
|
<td className={`px-3 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
|
|
|
|
{row.type === "existing" ? (
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{row.planQty.toLocaleString()}
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={0}
|
|
|
|
|
max={remaining}
|
|
|
|
|
value={row.planQty || ""}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* 출하계획일 */}
|
|
|
|
|
<td className={`px-3 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
|
|
|
|
{row.type === "existing" ? (
|
|
|
|
|
<span className="text-xs text-muted-foreground">-</span>
|
|
|
|
|
) : (
|
|
|
|
|
<Input
|
|
|
|
|
type="date"
|
|
|
|
|
value={row.planDate || ""}
|
|
|
|
|
onChange={(e) => onPlanDateChange(groupIdx, row._origIdx, e.target.value)}
|
|
|
|
|
disabled={isDisabled}
|
|
|
|
|
className="mx-auto h-7 w-32 text-xs disabled:opacity-40"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{/* 분할 버튼 */}
|
|
|
|
|
<td className={`px-2 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
|
|
|
|
{row.type === "new" && isFirst && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onAddSplit(groupIdx, row.sourceId)}
|
|
|
|
|
disabled={sg.balanceQty <= 0}
|
|
|
|
|
className="flex h-6 w-6 items-center justify-center rounded-md text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed mx-auto"
|
|
|
|
|
title="분할 행 추가"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{row.type === "new" && !isFirst && newCount > 1 && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onRemoveSplit(groupIdx, row._origIdx)}
|
|
|
|
|
className="flex h-6 w-6 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 mx-auto"
|
|
|
|
|
title="분할 행 삭제"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
})}
|
|
|
|
|
{sourceGroups.length === 0 && (
|
2026-03-18 14:42:47 +09:00
|
|
|
<tr>
|
2026-03-18 16:04:55 +09:00
|
|
|
<td colSpan={8} className="px-3 py-6 text-center text-xs text-muted-foreground">
|
2026-03-18 14:42:47 +09:00
|
|
|
데이터가 없습니다
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ShippingPlanEditorWrapper: React.FC<
|
|
|
|
|
ShippingPlanEditorComponentProps
|
|
|
|
|
> = (props) => {
|
|
|
|
|
return <ShippingPlanEditorComponent {...props} />;
|
|
|
|
|
};
|