diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 99c85889..e89e14c2 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -333,8 +333,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { const savedPlans = []; for (const plan of plans) { - const { sourceId, planQty } = plan; + const { sourceId, planQty, planDate } = plan; if (!sourceId || !planQty || planQty <= 0) continue; + const planDateValue = planDate || null; if (detectedSource === "detail") { // 디테일 소스: detail_id로 저장 @@ -368,9 +369,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { const insertRes = await client.query( `INSERT INTO shipment_plan (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 *`, - [companyCode, sourceId, detail.master_id, planQty, userId] + [companyCode, sourceId, detail.master_id, planQty, planDateValue, userId] ); savedPlans.push(insertRes.rows[0]); @@ -410,9 +411,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { const insertRes = await client.query( `INSERT INTO shipment_plan (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 *`, - [companyCode, masterId, planQty, userId] + [companyCode, masterId, planQty, planDateValue, userId] ); savedPlans.push(insertRes.rows[0]); diff --git a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx index 4df68117..5405b24e 100644 --- a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx @@ -433,9 +433,9 @@ export const V2ButtonConfigPanel: React.FC = ({ loadAll(); }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]); - // 화면 목록 로드 (모달 액션용) + // 화면 목록 로드 (모달/편집/네비게이트 액션용) useEffect(() => { - if (actionType !== "modal" && actionType !== "navigate") return; + if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return; if (screens.length > 0) return; const loadScreens = async () => { @@ -870,7 +870,6 @@ const ActionDetailSection: React.FC<{ switch (actionType) { case "save": case "delete": - case "edit": case "quickInsert": return (
@@ -879,7 +878,6 @@ const ActionDetailSection: React.FC<{ {actionType === "save" && "저장 설정"} {actionType === "delete" && "삭제 설정"} - {actionType === "edit" && "편집 설정"} {actionType === "quickInsert" && "즉시 저장 설정"}
@@ -900,6 +898,147 @@ const ActionDetailSection: React.FC<{ ); + case "edit": + return ( +
+
+ + 편집 설정 +
+ + {/* 대상 화면 선택 */} +
+ + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens + .filter((s) => + !modalSearchTerm || + s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) || + s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) || + String(s.id).includes(modalSearchTerm) + ) + .map((screen) => ( + { + updateActionConfig("targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + className="text-xs" + > + +
+ {screen.name} + {screen.description && ( + {screen.description} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 편집 모드 선택 */} +
+ + +
+ + {/* 모달 모드일 때 추가 설정 */} + {(action.editMode || "modal") === "modal" && ( + <> +
+ + updateActionConfig("editModalTitle", e.target.value)} + placeholder="데이터 수정" + className="h-7 text-xs" + /> +
+
+ + updateActionConfig("editModalDescription", e.target.value)} + placeholder="모달 설명" + className="h-7 text-xs" + /> +
+
+ + +
+ + )} + + {commonMessageSection} +
+ ); + case "modal": return (
diff --git a/frontend/lib/api/shipping.ts b/frontend/lib/api/shipping.ts index 0aa9c8b3..b24394f2 100644 --- a/frontend/lib/api/shipping.ts +++ b/frontend/lib/api/shipping.ts @@ -36,6 +36,7 @@ export interface AggregateResponse { export interface BatchSavePlan { sourceId: string; planQty: number; + planDate?: string; } // ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx index c96d9286..3ef08be5 100644 --- a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx @@ -10,6 +10,8 @@ import { CheckCircle, Factory, Truck, + Plus, + X, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -92,6 +94,8 @@ export const ShippingPlanEditorComponent: React.FC< dueDate: order.dueDate, balanceQty: remainingBalance, 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) => g.details .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) { @@ -179,12 +188,21 @@ export const ShippingPlanEditorComponent: React.FC< return; } - // 잔량 초과 검증 (allowOverPlan = false일 때) + // 같은 sourceId별 합산 → 미출하량 초과 검증 if (!currentConfig.allowOverPlan) { - const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty); - if (overPlan) { - toast.error("출하계획량이 미출하량을 초과합니다."); - return; + 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; + } } } @@ -197,7 +215,7 @@ export const ShippingPlanEditorComponent: React.FC< savingRef.current = true; setSaving(true); 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); if (res.success) { 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( (groupIdx: number, detailIdx: number, value: string) => { setItemGroups((prev) => { const next = [...prev]; const group = { ...next[groupIdx] }; const details = [...group.details]; - const detail = { ...details[detailIdx] }; + const target = details[detailIdx]; + let qty = Number(value) || 0; - detail.planQty = Number(value) || 0; - details[detailIdx] = detail; + // 같은 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 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); + // 출하계획일 변경 + 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; + }); + }, + [] + ); - group.aggregation = { - ...group.aggregation, - totalPlanQty: existingPlanTotal + newPlanTotal, - availableStock: - group.aggregation.currentStock - - (existingPlanTotal + newPlanTotal), + // 분할 행 추가 (같은 수주에 새 행) + 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; }); @@ -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) { return (
@@ -353,6 +471,9 @@ export const ShippingPlanEditorComponent: React.FC< details={group.details} groupIdx={groupIdx} onPlanQtyChange={handlePlanQtyChange} + onPlanDateChange={handlePlanDateChange} + onAddSplit={handleAddSplitRow} + onRemoveSplit={handleRemoveSplitRow} showExisting={showExisting} />
@@ -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<{ details: PlanDetailRow[]; groupIdx: number; - onPlanQtyChange: ( - groupIdx: number, - detailIdx: number, - value: string - ) => void; + 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, showExisting = true }) => { +}> = ({ 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 (
- - + + - {visibleDetails.map((detail, detailIdx) => ( - - - - - - - - - ))} - {visibleDetails.length === 0 && ( + {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 && ( - diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/types.ts b/frontend/lib/registry/components/v2-shipping-plan-editor/types.ts index 533c7b33..5455ed0f 100644 --- a/frontend/lib/registry/components/v2-shipping-plan-editor/types.ts +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/types.ts @@ -56,7 +56,9 @@ export interface PlanDetailRow { dueDate: string; balanceQty: number; planQty: number; + planDate?: string; existingPlanId?: number; + splitKey?: string; } export interface ItemGroup {
+ 구분 @@ -500,65 +662,124 @@ const DetailTable: React.FC<{ 미출하 + 출하계획량 + 출하계획일 + + 분할 +
- {detail.type === "existing" ? ( - - 기존 - - ) : ( - - 신규 - - )} - {detail.orderNo}{detail.partnerName} - {detail.dueDate || "-"} - - {detail.balanceQty.toLocaleString()} - - {detail.type === "existing" ? ( - - {detail.planQty.toLocaleString()} - - ) : ( - 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" - /> - )} -
+ {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 && ( + + )} +
+ 데이터가 없습니다