Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
kmh
2026-03-18 14:49:53 +09:00
13 changed files with 1583 additions and 2 deletions
@@ -119,6 +119,7 @@ import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록
/**
* 컴포넌트 초기화 함수
@@ -604,7 +604,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
toast.dismiss();
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval"];
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval", "event"];
if (!silentActions.includes(actionConfig.type)) {
currentLoadingToastRef.current = toast.loading(
actionConfig.type === "save"
@@ -631,7 +631,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리
if (!success) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"];
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert", "event"];
if (silentErrorActions.includes(actionConfig.type)) {
return;
}
@@ -0,0 +1,576 @@
"use client";
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { ComponentRendererProps } from "@/types/component";
import {
Loader2,
Package,
TrendingUp,
Warehouse,
CheckCircle,
Factory,
Truck,
} 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,
});
}
// 기존 출하계획 아래에 표시
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, balanceQty: d.balanceQty }))
);
if (plans.length === 0) {
toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요.");
return;
}
// 잔량 초과 검증 (allowOverPlan = false일 때)
if (!currentConfig.allowOverPlan) {
const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty);
if (overPlan) {
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 }));
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 handlePlanQtyChange = useCallback(
(groupIdx: number, detailIdx: number, value: string) => {
setItemGroups((prev) => {
const next = [...prev];
const group = { ...next[groupIdx] };
const details = [...group.details];
const detail = { ...details[detailIdx] };
detail.planQty = Number(value) || 0;
details[detailIdx] = detail;
group.details = details;
const newPlanTotal = details
.filter((d) => d.type === "new")
.reduce((sum, d) => sum + d.planQty, 0);
const existingPlanTotal = details
.filter((d) => d.type === "existing")
.reduce((sum, d) => sum + d.planQty, 0);
group.aggregation = {
...group.aggregation,
totalPlanQty: existingPlanTotal + newPlanTotal,
availableStock:
group.aggregation.currentStock -
(existingPlanTotal + newPlanTotal),
};
next[groupIdx] = group;
return next;
});
},
[]
);
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}
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>
);
};
const DetailTable: React.FC<{
details: PlanDetailRow[];
groupIdx: number;
onPlanQtyChange: (
groupIdx: number,
detailIdx: number,
value: string
) => void;
showExisting?: boolean;
}> = ({ details, groupIdx, onPlanQtyChange, showExisting = true }) => {
const visibleDetails = details
.map((d, idx) => ({ ...d, _origIdx: idx }))
.filter((d) => showExisting || d.type === "new");
return (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<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-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>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{visibleDetails.map((detail, detailIdx) => (
<tr
key={`${detail.type}-${detail.sourceId}-${detail.existingPlanId || detailIdx}`}
className="border-b last:border-b-0 hover:bg-muted/30"
>
<td className="px-3 py-2">
{detail.type === "existing" ? (
<Badge variant="secondary" className="text-[10px]">
</Badge>
) : (
<Badge className="bg-primary text-[10px] text-primary-foreground">
</Badge>
)}
</td>
<td className="px-3 py-2 text-xs">{detail.orderNo}</td>
<td className="px-3 py-2 text-xs">{detail.partnerName}</td>
<td className="px-3 py-2 text-xs">
{detail.dueDate || "-"}
</td>
<td className="px-3 py-2 text-right text-xs font-medium">
{detail.balanceQty.toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
{detail.type === "existing" ? (
<span className="text-xs text-muted-foreground">
{detail.planQty.toLocaleString()}
</span>
) : (
<Input
type="number"
min={0}
max={
detail.balanceQty > 0 ? detail.balanceQty : undefined
}
value={detail.planQty || ""}
onChange={(e) =>
onPlanQtyChange(groupIdx, detail._origIdx, e.target.value)
}
className="ml-auto h-7 w-24 text-right text-xs"
placeholder="0"
/>
)}
</td>
</tr>
))}
{visibleDetails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-6 text-center text-xs text-muted-foreground"
>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export const ShippingPlanEditorWrapper: React.FC<
ShippingPlanEditorComponentProps
> = (props) => {
return <ShippingPlanEditorComponent {...props} />;
};
@@ -0,0 +1,166 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
interface ShippingPlanEditorConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export const ShippingPlanEditorConfigPanel: React.FC<
ShippingPlanEditorConfigPanelProps
> = ({ config, onChange }) => {
const handleChange = (key: string, value: any) => {
onChange({ ...config, [key]: value });
};
const handleSummaryCardToggle = (cardKey: string, checked: boolean) => {
onChange({
...config,
visibleSummaryCards: {
...(config.visibleSummaryCards || defaultSummaryCards),
[cardKey]: checked,
},
});
};
const defaultSummaryCards = {
totalBalance: true,
totalPlanQty: true,
currentStock: true,
availableStock: true,
inProductionQty: true,
};
const summaryCards = config.visibleSummaryCards || defaultSummaryCards;
const summaryCardLabels: Record<string, string> = {
totalBalance: "총수주잔량",
totalPlanQty: "총출하계획량",
currentStock: "현재고",
availableStock: "가용재고",
inProductionQty: "생산중수량",
};
return (
<div className="space-y-4 p-4">
{/* 기본 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || "출하계획 등록"}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="출하계획 등록"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* 표시 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showSummaryCards !== false}
onCheckedChange={(checked) =>
handleChange("showSummaryCards", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showExistingPlans !== false}
onCheckedChange={(checked) =>
handleChange("showExistingPlans", checked)
}
/>
</div>
{config.showSummaryCards !== false && (
<>
<Separator />
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="space-y-2">
{Object.entries(summaryCardLabels).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Switch
checked={summaryCards[key] !== false}
onCheckedChange={(checked) =>
handleSummaryCardToggle(key, checked)
}
/>
</div>
))}
</div>
</>
)}
<Separator />
{/* 저장 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.allowOverPlan === true}
onCheckedChange={(checked) =>
handleChange("allowOverPlan", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.autoCloseOnSave !== false}
onCheckedChange={(checked) =>
handleChange("autoCloseOnSave", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.confirmBeforeSave === true}
onCheckedChange={(checked) =>
handleChange("confirmBeforeSave", checked)
}
/>
</div>
{config.confirmBeforeSave && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Textarea
value={config.confirmMessage || "출하계획을 저장하시겠습니까?"}
onChange={(e) => handleChange("confirmMessage", e.target.value)}
placeholder="출하계획을 저장하시겠습니까?"
className="min-h-[60px] text-xs"
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,16 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2ShippingPlanEditorDefinition } from "./index";
import { ShippingPlanEditorComponent } from "./ShippingPlanEditorComponent";
export class ShippingPlanEditorRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ShippingPlanEditorDefinition;
render(): React.ReactElement {
return <ShippingPlanEditorComponent {...this.props} />;
}
}
ShippingPlanEditorRenderer.registerSelf();
@@ -0,0 +1,27 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ShippingPlanEditorWrapper } from "./ShippingPlanEditorComponent";
import { ShippingPlanEditorConfigPanel } from "./ShippingPlanEditorConfigPanel";
export const V2ShippingPlanEditorDefinition = createComponentDefinition({
id: "v2-shipping-plan-editor",
name: "출하계획 동시등록",
nameEng: "Shipping Plan Editor",
description: "수주 선택 후 품목별 그룹핑하여 출하계획을 일괄 등록하는 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: ShippingPlanEditorWrapper,
configPanel: ShippingPlanEditorConfigPanel,
defaultConfig: {
title: "출하계획 등록",
},
defaultSize: { width: 900, height: 600 },
icon: "Truck",
tags: ["출하", "계획", "수주", "일괄등록", "v2"],
version: "1.0.0",
author: "개발팀",
});
export type { ShippingPlanEditorConfig, ItemGroup, PlanDetailRow } from "./types";
@@ -0,0 +1,67 @@
import { ComponentConfig } from "@/types/component";
export interface ShippingPlanEditorConfig extends ComponentConfig {
title?: string;
showSummaryCards?: boolean;
showExistingPlans?: boolean;
allowOverPlan?: boolean;
autoCloseOnSave?: boolean;
confirmBeforeSave?: boolean;
confirmMessage?: string;
visibleSummaryCards?: {
totalBalance?: boolean;
totalPlanQty?: boolean;
currentStock?: boolean;
availableStock?: boolean;
inProductionQty?: boolean;
};
}
// 백엔드에서 정규화해서 내려주는 수주 정보
export interface EnrichedOrder {
sourceId: string;
orderNo: string;
partCode: string;
partName: string;
partnerName: string;
dueDate: string;
orderQty: number;
shipQty: number;
balanceQty: number;
}
export interface ItemAggregation {
totalBalance: number;
totalPlanQty: number;
currentStock: number;
availableStock: number;
inProductionQty: number;
}
export interface ExistingPlan {
id: number;
sourceId: string;
planQty: number;
planDate: string;
shipmentPlanNo: string;
status: string;
}
// 상세 테이블 행 (기존 출하계획 + 신규 입력)
export interface PlanDetailRow {
type: "existing" | "new";
sourceId: string;
orderNo: string;
partnerName: string;
dueDate: string;
balanceQty: number;
planQty: number;
existingPlanId?: number;
}
export interface ItemGroup {
partCode: string;
partName: string;
aggregation: ItemAggregation;
details: PlanDetailRow[];
}