feat: enhance shipping plan editor and batch save functionality

- Added planDate support in batch save operations for shipping plans, allowing for more accurate scheduling.
- Updated the ShippingPlanEditorComponent to handle planDate and splitKey for better management of shipping plans.
- Enhanced validation logic to ensure that the total planned quantity does not exceed available stock during the editing process.
- Introduced functionality to add and remove split rows dynamically, improving user experience in managing shipping plans.

These changes aim to provide a more robust and flexible shipping plan management experience, facilitating better tracking and scheduling of shipping operations.
This commit is contained in:
kjs 2026-03-18 16:04:55 +09:00
parent 9decf13068
commit 359bf0e614
5 changed files with 455 additions and 91 deletions

View File

@ -333,8 +333,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
const savedPlans = []; const savedPlans = [];
for (const plan of plans) { for (const plan of plans) {
const { sourceId, planQty } = plan; const { sourceId, planQty, planDate } = plan;
if (!sourceId || !planQty || planQty <= 0) continue; if (!sourceId || !planQty || planQty <= 0) continue;
const planDateValue = planDate || null;
if (detectedSource === "detail") { if (detectedSource === "detail") {
// 디테일 소스: detail_id로 저장 // 디테일 소스: detail_id로 저장
@ -368,9 +369,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
const insertRes = await client.query( const insertRes = await client.query(
`INSERT INTO shipment_plan `INSERT INTO shipment_plan
(company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) (company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
VALUES ($1, $2, $3, $4, CURRENT_DATE, 'READY', $5) VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6)
RETURNING *`, RETURNING *`,
[companyCode, sourceId, detail.master_id, planQty, userId] [companyCode, sourceId, detail.master_id, planQty, planDateValue, userId]
); );
savedPlans.push(insertRes.rows[0]); savedPlans.push(insertRes.rows[0]);
@ -410,9 +411,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
const insertRes = await client.query( const insertRes = await client.query(
`INSERT INTO shipment_plan `INSERT INTO shipment_plan
(company_code, sales_order_id, plan_qty, plan_date, status, created_by) (company_code, sales_order_id, plan_qty, plan_date, status, created_by)
VALUES ($1, $2, $3, CURRENT_DATE, 'READY', $4) VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5)
RETURNING *`, RETURNING *`,
[companyCode, masterId, planQty, userId] [companyCode, masterId, planQty, planDateValue, userId]
); );
savedPlans.push(insertRes.rows[0]); savedPlans.push(insertRes.rows[0]);

View File

@ -433,9 +433,9 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
loadAll(); loadAll();
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]); }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
// 화면 목록 로드 (모달 액션용) // 화면 목록 로드 (모달/편집/네비게이트 액션용)
useEffect(() => { useEffect(() => {
if (actionType !== "modal" && actionType !== "navigate") return; if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return;
if (screens.length > 0) return; if (screens.length > 0) return;
const loadScreens = async () => { const loadScreens = async () => {
@ -870,7 +870,6 @@ const ActionDetailSection: React.FC<{
switch (actionType) { switch (actionType) {
case "save": case "save":
case "delete": case "delete":
case "edit":
case "quickInsert": case "quickInsert":
return ( return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3"> <div className="rounded-lg border bg-muted/30 p-4 space-y-3">
@ -879,7 +878,6 @@ const ActionDetailSection: React.FC<{
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{actionType === "save" && "저장 설정"} {actionType === "save" && "저장 설정"}
{actionType === "delete" && "삭제 설정"} {actionType === "delete" && "삭제 설정"}
{actionType === "edit" && "편집 설정"}
{actionType === "quickInsert" && "즉시 저장 설정"} {actionType === "quickInsert" && "즉시 저장 설정"}
</span> </span>
</div> </div>
@ -900,6 +898,147 @@ const ActionDetailSection: React.FC<{
</div> </div>
); );
case "edit":
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Pencil className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{/* 대상 화면 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-8 w-full justify-between text-xs"
disabled={screensLoading}
>
{screensLoading
? "로딩 중..."
: action.targetScreenId
? screens.find((s) => s.id === action.targetScreenId)?.name || `화면 #${action.targetScreenId}`
: "화면 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="화면 검색..."
value={modalSearchTerm}
onValueChange={setModalSearchTerm}
className="text-xs"
/>
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> .</CommandEmpty>
<CommandGroup>
{screens
.filter((s) =>
!modalSearchTerm ||
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
String(s.id).includes(modalSearchTerm)
)
.map((screen) => (
<CommandItem
key={screen.id}
value={String(screen.id)}
onSelect={() => {
updateActionConfig("targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
action.targetScreenId === screen.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && (
<span className="text-[10px] text-muted-foreground">{screen.description}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 편집 모드 선택 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={action.editMode || "modal"}
onValueChange={(value) => updateActionConfig("editMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal" className="text-xs"> </SelectItem>
<SelectItem value="navigate" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 모드일 때 추가 설정 */}
{(action.editMode || "modal") === "modal" && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
value={action.editModalTitle || ""}
onChange={(e) => updateActionConfig("editModalTitle", e.target.value)}
placeholder="데이터 수정"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={action.editModalDescription || ""}
onChange={(e) => updateActionConfig("editModalDescription", e.target.value)}
placeholder="모달 설명"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={action.modalSize || "lg"}
onValueChange={(value) => updateActionConfig("modalSize", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm" className="text-xs"> (sm)</SelectItem>
<SelectItem value="md" className="text-xs"> (md)</SelectItem>
<SelectItem value="lg" className="text-xs"> (lg)</SelectItem>
<SelectItem value="xl" className="text-xs"> (xl)</SelectItem>
<SelectItem value="full" className="text-xs"> (full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{commonMessageSection}
</div>
);
case "modal": case "modal":
return ( return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3"> <div className="rounded-lg border bg-muted/30 p-4 space-y-3">

View File

@ -36,6 +36,7 @@ export interface AggregateResponse {
export interface BatchSavePlan { export interface BatchSavePlan {
sourceId: string; sourceId: string;
planQty: number; planQty: number;
planDate?: string;
} }
// ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN // ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN

View File

@ -10,6 +10,8 @@ import {
CheckCircle, CheckCircle,
Factory, Factory,
Truck, Truck,
Plus,
X,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -92,6 +94,8 @@ export const ShippingPlanEditorComponent: React.FC<
dueDate: order.dueDate, dueDate: order.dueDate,
balanceQty: remainingBalance, balanceQty: remainingBalance,
planQty: 0, planQty: 0,
planDate: new Date().toISOString().split("T")[0],
splitKey: `${order.sourceId}_0`,
}); });
} }
@ -171,7 +175,12 @@ export const ShippingPlanEditorComponent: React.FC<
const plans = currentGroups.flatMap((g) => const plans = currentGroups.flatMap((g) =>
g.details g.details
.filter((d) => d.type === "new" && d.planQty > 0) .filter((d) => d.type === "new" && d.planQty > 0)
.map((d) => ({ sourceId: d.sourceId, planQty: d.planQty, balanceQty: d.balanceQty })) .map((d) => ({
sourceId: d.sourceId,
planQty: d.planQty,
planDate: d.planDate || new Date().toISOString().split("T")[0],
balanceQty: d.balanceQty,
}))
); );
if (plans.length === 0) { if (plans.length === 0) {
@ -179,12 +188,21 @@ export const ShippingPlanEditorComponent: React.FC<
return; return;
} }
// 잔량 초과 검증 (allowOverPlan = false일 때) // 같은 sourceId별 합산 → 미출하량 초과 검증
if (!currentConfig.allowOverPlan) { if (!currentConfig.allowOverPlan) {
const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty); const sumBySource = new Map<string, { total: number; balance: number }>();
if (overPlan) { for (const p of plans) {
toast.error("출하계획량이 미출하량을 초과합니다."); const prev = sumBySource.get(p.sourceId) || { total: 0, balance: p.balanceQty };
return; 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;
}
} }
} }
@ -197,7 +215,7 @@ export const ShippingPlanEditorComponent: React.FC<
savingRef.current = true; savingRef.current = true;
setSaving(true); setSaving(true);
try { try {
const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty })); const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty, planDate: p.planDate }));
const res = await batchSaveShippingPlans(savePlans, currentSource); const res = await batchSaveShippingPlans(savePlans, currentSource);
if (res.success) { if (res.success) {
toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`); toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`);
@ -238,33 +256,106 @@ export const ShippingPlanEditorComponent: React.FC<
}; };
}, []); }, []);
// 집계 재계산 헬퍼
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( const handlePlanQtyChange = useCallback(
(groupIdx: number, detailIdx: number, value: string) => { (groupIdx: number, detailIdx: number, value: string) => {
setItemGroups((prev) => { setItemGroups((prev) => {
const next = [...prev]; const next = [...prev];
const group = { ...next[groupIdx] }; const group = { ...next[groupIdx] };
const details = [...group.details]; const details = [...group.details];
const detail = { ...details[detailIdx] }; const target = details[detailIdx];
let qty = Number(value) || 0;
detail.planQty = Number(value) || 0; // 같은 sourceId의 다른 신규 행 합산
details[detailIdx] = detail; 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; group.details = details;
next[groupIdx] = recalcAggregation(group);
return next;
});
},
[]
);
const newPlanTotal = details // 출하계획일 변경
.filter((d) => d.type === "new") const handlePlanDateChange = useCallback(
.reduce((sum, d) => sum + d.planQty, 0); (groupIdx: number, detailIdx: number, value: string) => {
const existingPlanTotal = details setItemGroups((prev) => {
.filter((d) => d.type === "existing") const next = [...prev];
.reduce((sum, d) => sum + d.planQty, 0); const group = { ...next[groupIdx] };
const details = [...group.details];
details[detailIdx] = { ...details[detailIdx], planDate: value };
group.details = details;
next[groupIdx] = group;
return next;
});
},
[]
);
group.aggregation = { // 분할 행 추가 (같은 수주에 새 행)
...group.aggregation, const handleAddSplitRow = useCallback(
totalPlanQty: existingPlanTotal + newPlanTotal, (groupIdx: number, sourceId: string) => {
availableStock: setItemGroups((prev) => {
group.aggregation.currentStock - const next = [...prev];
(existingPlanTotal + newPlanTotal), 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; next[groupIdx] = group;
return next; return next;
}); });
@ -272,6 +363,33 @@ export const ShippingPlanEditorComponent: React.FC<
[] []
); );
// 분할 행 삭제
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) { if (isDesignMode) {
return ( 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 h-full w-full flex-col gap-3 rounded-lg border border-dashed border-gray-300 p-4">
@ -353,6 +471,9 @@ export const ShippingPlanEditorComponent: React.FC<
details={group.details} details={group.details}
groupIdx={groupIdx} groupIdx={groupIdx}
onPlanQtyChange={handlePlanQtyChange} onPlanQtyChange={handlePlanQtyChange}
onPlanDateChange={handlePlanDateChange}
onAddSplit={handleAddSplitRow}
onRemoveSplit={handleRemoveSplitRow}
showExisting={showExisting} showExisting={showExisting}
/> />
</div> </div>
@ -467,25 +588,66 @@ const SummaryCards: React.FC<{
); );
}; };
// sourceId별 그룹 (셀병합용)
interface SourceGroup {
sourceId: string;
orderNo: string;
partnerName: string;
dueDate: string;
balanceQty: number;
rows: (PlanDetailRow & { _origIdx: number })[];
}
const DetailTable: React.FC<{ const DetailTable: React.FC<{
details: PlanDetailRow[]; details: PlanDetailRow[];
groupIdx: number; groupIdx: number;
onPlanQtyChange: ( onPlanQtyChange: (groupIdx: number, detailIdx: number, value: string) => void;
groupIdx: number, onPlanDateChange: (groupIdx: number, detailIdx: number, value: string) => void;
detailIdx: number, onAddSplit: (groupIdx: number, sourceId: string) => void;
value: string onRemoveSplit: (groupIdx: number, detailIdx: number) => void;
) => void;
showExisting?: boolean; showExisting?: boolean;
}> = ({ details, groupIdx, onPlanQtyChange, showExisting = true }) => { }> = ({ details, groupIdx, onPlanQtyChange, onPlanDateChange, onAddSplit, onRemoveSplit, showExisting = true }) => {
const visibleDetails = details const visibleDetails = details
.map((d, idx) => ({ ...d, _origIdx: idx })) .map((d, idx) => ({ ...d, _origIdx: idx }))
.filter((d) => showExisting || d.type === "new"); .filter((d) => showExisting || d.type === "new");
// 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);
};
return ( return (
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"> <th className="w-14 px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th> </th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"> <th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
@ -500,65 +662,124 @@ const DetailTable: React.FC<{
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"> <th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
</th> </th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"> <th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
</th> </th>
<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>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleDetails.map((detail, detailIdx) => ( {sourceGroups.map((sg) => {
<tr const totalRows = sg.rows.length;
key={`${detail.type}-${detail.sourceId}-${detail.existingPlanId || detailIdx}`} const newCount = sg.rows.filter((r) => r.type === "new").length;
className="border-b last:border-b-0 hover:bg-muted/30" return sg.rows.map((row, rowIdx) => {
> const isFirst = rowIdx === 0;
<td className="px-3 py-2"> const remaining = row.type === "new" ? getRemaining(row.sourceId, row._origIdx) : 0;
{detail.type === "existing" ? ( const isDisabled = row.type === "new" && sg.balanceQty <= 0;
<Badge variant="secondary" className="text-[10px]">
return (
</Badge> <tr
) : ( key={row.splitKey || `${row.type}-${row.sourceId}-${row.existingPlanId || row._origIdx}`}
<Badge className="bg-primary text-[10px] text-primary-foreground"> className={`hover:bg-muted/30 ${rowIdx < totalRows - 1 ? "" : "border-b"}`}
>
</Badge> {/* 구분 - 매 행마다 표시 */}
)} <td className={`px-3 py-2 ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
</td> {row.type === "existing" ? (
<td className="px-3 py-2 text-xs">{detail.orderNo}</td> <Badge variant="secondary" className="text-[10px]"></Badge>
<td className="px-3 py-2 text-xs">{detail.partnerName}</td> ) : (
<td className="px-3 py-2 text-xs"> <Badge className="bg-primary text-[10px] text-primary-foreground"></Badge>
{detail.dueDate || "-"} )}
</td> </td>
<td className="px-3 py-2 text-right text-xs font-medium">
{detail.balanceQty.toLocaleString()} {/* 수주번호, 거래처, 납기일, 미출하 - 첫 행만 rowSpan */}
</td> {isFirst && (
<td className="px-3 py-2 text-right"> <>
{detail.type === "existing" ? ( <td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
<span className="text-xs text-muted-foreground"> {sg.orderNo}
{detail.planQty.toLocaleString()} </td>
</span> <td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
) : ( {sg.partnerName}
<Input </td>
type="number" <td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
min={0} {sg.dueDate || "-"}
max={ </td>
detail.balanceQty > 0 ? detail.balanceQty : undefined <td className="border-t px-3 py-2 text-right text-xs font-bold align-middle" rowSpan={totalRows}>
} {sg.balanceQty.toLocaleString()}
value={detail.planQty || ""} </td>
onChange={(e) => </>
onPlanQtyChange(groupIdx, detail._origIdx, e.target.value) )}
}
className="ml-auto h-7 w-24 text-right text-xs" {/* 출하계획량 */}
placeholder="0" <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">
</td> {row.planQty.toLocaleString()}
</tr> </span>
))} ) : (
{visibleDetails.length === 0 && ( <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 && (
<tr> <tr>
<td <td colSpan={8} className="px-3 py-6 text-center text-xs text-muted-foreground">
colSpan={6}
className="px-3 py-6 text-center text-xs text-muted-foreground"
>
</td> </td>
</tr> </tr>

View File

@ -56,7 +56,9 @@ export interface PlanDetailRow {
dueDate: string; dueDate: string;
balanceQty: number; balanceQty: number;
planQty: number; planQty: number;
planDate?: string;
existingPlanId?: number; existingPlanId?: number;
splitKey?: string;
} }
export interface ItemGroup { export interface ItemGroup {