From a2c532c7c7676cfacc8a1c7de1fcbbcb9ef68100 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 12 Mar 2026 18:26:47 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20+=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=95=A1=EC=85=98=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?+=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20UI=20=EB=8F=99=EC=8B=9C=20=EC=A0=91=EC=88=98=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80(preCondition=20WHERE=20+=20409?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC),=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EC=8B=9C=EC=97=90=EB=A7=8C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=99=9C=EC=84=B1=ED=99=94(owner-match=20showCondi?= =?UTF-8?q?tion),=20=EB=B3=B8=EC=9D=B8=20=EC=B9=B4=EB=93=9C=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=95=EB=A0=AC(ownerSortColumn)=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=98=EA=B3=A0=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EC=97=90=EC=84=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20UI=203=EC=A2=85?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.=20[=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C]=20-=20popActionRoutes:=20TaskBody=EC=97=90?= =?UTF-8?q?=20preCondition=20=EC=B6=94=EA=B0=80,=20data-update=20WHERE=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=82=BD=EC=9E=85,=20=20=20rowCount=3D0?= =?UTF-8?q?=20=EC=8B=9C=20409=20Conflict=20=EB=B0=98=ED=99=98=20(isPreCond?= =?UTF-8?q?itionFail)=20[=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20-?= =?UTF-8?q?=20=EB=9F=B0=ED=83=80=EC=9E=84]=20-=20types.ts:=20ActionPreCond?= =?UTF-8?q?ition=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4,=20owner-?= =?UTF-8?q?match=20=ED=83=80=EC=9E=85,=20ownerSortColumn=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20-=20cell-renderers:=20evaluateShowCondition?= =?UTF-8?q?=EC=97=90=20owner-match=20=EB=B6=84=EA=B8=B0=20+=20currentUserI?= =?UTF-8?q?d=20prop=20-=20PopCardListV2Component:=20useAuth=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99,=20preCondition=20=EC=A0=84=EB=8B=AC/409=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC,=20=20=20ownerSortColumn=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=A0=95=EB=A0=AC,=20currentUserId=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EC=A0=84=EB=8B=AC=20[=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20-=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=84=A4=EC=A0=95=20UI]=20-=20PopCardListV2Config:?= =?UTF-8?q?=20showCondition=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=97=90=20"=EC=86=8C=EC=9C=A0=EC=9E=90=20=EC=9D=BC=EC=B9=98"?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20+=20=EC=BB=AC=EB=9F=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D,=20=20=20ImmediateActionEditor=EC=97=90=20"=EC=82=AC?= =?UTF-8?q?=EC=A0=84=20=EC=A1=B0=EA=B1=B4(=EC=A4=91=EB=B3=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80)"=20=ED=86=A0=EA=B8=80=20+=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC/=EA=B8=B0=EB=8C=80=EA=B0=92/=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A9=94=EC=8B=9C=EC=A7=80,=20=20=20TabActions?= =?UTF-8?q?=EC=97=90=20"=EC=86=8C=EC=9C=A0=EC=9E=90=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC"=20=EC=BB=AC=EB=9F=BC=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 59 +++- .../PopCardListV2Component.tsx | 168 ++++++++++- .../pop-card-list-v2/PopCardListV2Config.tsx | 272 ++++++++++++++++-- .../pop-card-list-v2/cell-renderers.tsx | 10 +- frontend/lib/registry/pop-components/types.ts | 31 +- 5 files changed, 494 insertions(+), 46 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index d25c6bdc..669cc960 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -104,6 +104,11 @@ interface TaskBody { manualItemField?: string; manualPkColumn?: string; cartScreenId?: string; + preCondition?: { + column: string; + expectedValue: string; + failMessage?: string; + }; } function resolveStatusValue( @@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [resolved, companyCode, lookupValues[i]], + let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const condParams: unknown[] = [resolved, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + condWhere += ` AND "${task.preCondition.column}" = $4`; + condParams.push(task.preCondition.expectedValue); + } + const condResult = await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`, + condParams, ); + if (task.preCondition && condResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } else if (opType === "db-conditional") { - // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (task.preCondition) { + logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", { + taskId: task.id, preCondition: task.preCondition, + }); + } if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; @@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [value, companyCode, lookupValues[i]], + let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const queryParams: unknown[] = [value, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) { + throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + } + whereSql += ` AND "${task.preCondition.column}" = $4`; + queryParams.push(task.preCondition.expectedValue); + } + const updateResult = await client.query( + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`, + queryParams, ); + if (task.preCondition && updateResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } @@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp }); } catch (error: any) { await client.query("ROLLBACK"); + + if (error.isPreConditionFail) { + logger.warn("[pop/execute-action] preCondition 실패", { message: error.message }); + return res.status(409).json({ + success: false, + message: error.message, + errorCode: "PRE_CONDITION_FAIL", + }); + } + logger.error("[pop/execute-action] 오류:", error); return res.status(500).json({ success: false, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 55829efb..3a52a36e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -34,6 +34,7 @@ import type { TimelineDataSource, ActionButtonUpdate, ActionButtonClickAction, + QuantityInputConfig, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, @@ -47,6 +48,7 @@ import { screenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; +import { useAuth } from "@/hooks/useAuth"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; @@ -56,6 +58,32 @@ const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopVie type RowData = Record; +function calculateMaxQty( + row: RowData, + processId: string | number | undefined, + cfg?: QuantityInputConfig, +): number { + if (!cfg) return 999999; + const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999; + if (!cfg.currentColumn) return maxVal; + + const processFlow = row.__processFlow__ as Array<{ + isCurrent: boolean; + processId?: string | number; + rawData?: Record; + }> | undefined; + + const currentProcess = processId + ? processFlow?.find((p) => String(p.processId) === String(processId)) + : processFlow?.find((p) => p.isCurrent); + + if (currentProcess?.rawData) { + const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0; + return Math.max(0, maxVal - currentVal); + } + return maxVal; +} + // cart_items 행 파싱 (pop-card-list에서 그대로 차용) function parseCartRow(dbRow: Record): Record { let rowData: Record = {}; @@ -113,6 +141,7 @@ export function PopCardListV2Component({ }: PopCardListV2ComponentProps) { const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); + const { userId: currentUserId } = useAuth(); const isCartListMode = config?.cartListMode?.enabled === true; const [inheritedConfig, setInheritedConfig] = useState | null>(null); @@ -469,7 +498,7 @@ export function PopCardListV2Component({ type: "data-update" as const, targetTable: btnConfig.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -619,11 +648,28 @@ export function PopCardListV2Component({ const scrollAreaRef = useRef(null); + const ownerSortColumn = config?.ownerSortColumn; + const displayCards = useMemo(() => { - if (!isExpanded) return filteredRows.slice(0, visibleCardCount); + let source = filteredRows; + + if (ownerSortColumn && currentUserId) { + const mine: RowData[] = []; + const others: RowData[] = []; + for (const row of source) { + if (String(row[ownerSortColumn] ?? "") === currentUserId) { + mine.push(row); + } else { + others.push(row); + } + } + source = [...mine, ...others]; + } + + if (!isExpanded) return source.slice(0, visibleCardCount); const start = (currentPage - 1) * expandedCardsPerPage; - return filteredRows.slice(start, start + expandedCardsPerPage); - }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + return source.slice(start, start + expandedCardsPerPage); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]); const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; const needsPagination = isExpanded && totalPages > 1; @@ -756,10 +802,17 @@ export function PopCardListV2Component({ if (firstPending) { firstPending.isCurrent = true; } } - return fetchedRows.map((row) => ({ - ...row, - __processFlow__: processMap.get(String(row.id)) || [], - })); + return fetchedRows.map((row) => { + const steps = processMap.get(String(row.id)) || []; + const current = steps.find((s) => s.isCurrent); + const processFields: Record = {}; + if (current?.rawData) { + for (const [key, val] of Object.entries(current.rawData)) { + processFields[`__process_${key}`] = val; + } + } + return { ...row, __processFlow__: steps, ...processFields }; + }); }, []); const fetchData = useCallback(async () => { @@ -1041,6 +1094,7 @@ export function PopCardListV2Component({ onToggleRowSelect={() => toggleRowSelection(row)} onEnterSelectMode={enterSelectMode} onOpenPopModal={openPopModal} + currentUserId={currentUserId} /> ))} @@ -1148,6 +1202,8 @@ interface CardV2Props { onToggleRowSelect?: () => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; onOpenPopModal?: (screenId: string, row: RowData) => void; + currentUserId?: string; + isLockedByOther?: boolean; } function CardV2({ @@ -1155,7 +1211,7 @@ function CardV2({ parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, - onOpenPopModal, + onOpenPopModal, currentUserId, isLockedByOther, }: CardV2Props) { const inputField = config?.inputField; const cartAction = config?.cartAction; @@ -1167,6 +1223,72 @@ function CardV2({ const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); + const [qtyModalState, setQtyModalState] = useState<{ + open: boolean; + row: RowData; + processId?: string | number; + action: ActionButtonClickAction; + } | null>(null); + + const handleQtyConfirm = useCallback(async (value: number) => { + if (!qtyModalState) return; + const { row: actionRow, processId: qtyProcessId, action } = qtyModalState; + setQtyModalState(null); + if (!action.targetTable || !action.updates) return; + + const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk; + if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; } + + const lookupValue = action.joinConfig + ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) + : rowId; + const lookupColumn = action.joinConfig?.targetColumn || "id"; + + const tasks = action.updates.map((u, idx) => ({ + id: `qty-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", + valueSource: "fixed" as const, + fixedValue: u.valueType === "userInput" ? String(value) : + u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: lookupColumn, + manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), + })); + + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow; + + try { + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [targetRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + } + } catch (err: unknown) { + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } + } + }, [qtyModalState, onRefresh]); + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); @@ -1365,7 +1487,11 @@ function CardV2({ } for (const action of actionsToRun) { - if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.type === "quantity-input" && action.targetTable && action.updates) { + if (action.confirmMessage && !window.confirm(action.confirmMessage)) return; + setQtyModalState({ open: true, row: actionRow, processId, action }); + return; + } else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { if (action.confirmMessage) { if (!window.confirm(action.confirmMessage)) return; } @@ -1381,7 +1507,7 @@ function CardV2({ type: "data-update" as const, targetTable: action.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -1391,6 +1517,7 @@ function CardV2({ lookupMode: "manual" as const, manualItemField: lookupColumn, manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), })); const targetRow = action.joinConfig ? { ...actionRow, [lookupColumn]: lookupValue } @@ -1408,7 +1535,12 @@ function CardV2({ return; } } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } return; } } else if (action.type === "modal-open" && action.modalScreenId) { @@ -1418,6 +1550,7 @@ function CardV2({ }, packageEntries, inputUnit: inputField?.unit, + currentUserId, })} ))} @@ -1437,6 +1570,17 @@ function CardV2({ /> )} + {qtyModalState?.open && ( + { if (!open) setQtyModalState(null); }} + unit={qtyModalState.action.quantityInput?.unit || "EA"} + maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} + showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false} + onConfirm={(value) => handleQtyConfirm(value)} + /> + )} + ); } diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 79d8a31e..04ba0622 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -65,6 +65,33 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +// ===== 컬럼 옵션 그룹 ===== + +interface ColumnOptionGroup { + groupLabel: string; + options: { value: string; label: string }[]; +} + +function renderColumnOptionGroups(groups: ColumnOptionGroup[]) { + if (groups.length <= 1) { + return groups.flatMap((g) => + g.options.map((o) => ( + {o.label} + )) + ); + } + return groups + .filter((g) => g.options.length > 0) + .map((g) => ( + + {g.groupLabel} + {g.options.map((o) => ( + {o.label} + ))} + + )); +} + // ===== Props ===== interface ConfigPanelProps { @@ -271,6 +298,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) )} @@ -759,10 +787,36 @@ function TabCardDesign({ sourceTable: j.targetTable, })) ); - const allColumnOptions = [ - ...availableColumns.map((c) => ({ value: c.name, label: c.name })), - ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const columnOptionGroups: ColumnOptionGroup[] = [ + { + groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`, + options: availableColumns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(joinedColumns.length > 0 + ? [{ + groupLabel: "조인", + options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + }] + : []), + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), ]; + const allColumnOptions = columnOptionGroups.flatMap((g) => g.options); const [selectedCellId, setSelectedCellId] = useState(null); const [mergeMode, setMergeMode] = useState(false); @@ -1273,6 +1327,7 @@ function TabCardDesign({ cell={selectedCell} allCells={grid.cells} allColumnOptions={allColumnOptions} + columnOptionGroups={columnOptionGroups} columns={columns} selectedColumns={selectedColumns} tables={tables} @@ -1291,6 +1346,7 @@ function CellDetailEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, columns, selectedColumns, tables, @@ -1301,6 +1357,7 @@ function CellDetailEditor({ cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; @@ -1348,9 +1405,7 @@ function CellDetailEditor({ 미지정 - {allColumnOptions.map((o) => ( - {o.label} - ))} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -1417,9 +1472,9 @@ function CellDetailEditor({ {/* 타입별 상세 설정 */} {cell.type === "status-badge" && } {cell.type === "timeline" && } - {cell.type === "action-buttons" && } - {cell.type === "footer-status" && } - {cell.type === "field" && } + {cell.type === "action-buttons" && } + {cell.type === "footer-status" && } + {cell.type === "field" && } {cell.type === "number-input" && (
숫자 입력 설정 @@ -1429,7 +1484,7 @@ function CellDetailEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -1809,12 +1864,14 @@ function ActionButtonsEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, availableTableOptions, onUpdate, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; availableTableOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { @@ -1975,7 +2032,7 @@ function ActionButtonsEditor({ const isSectionOpen = (key: string) => expandedSections[key] !== false; - const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" }; + const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" }; const getCondSummary = (btn: ActionButtonDef) => { const c = btn.showCondition; @@ -1985,6 +2042,7 @@ function ActionButtonsEditor({ return opt ? opt.label : (c.value || "미설정"); } if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`; + if (c.type === "owner-match") return `소유자(${c.column || "?"})`; return "항상"; }; @@ -2081,8 +2139,21 @@ function ActionButtonsEditor({ 항상 타임라인 카드 컬럼 + 소유자 일치 + {condType === "owner-match" && ( + + )} {condType === "timeline-status" && ( 즉시 실행 + 수량 입력 선택 후 실행 모달 열기 @@ -2191,6 +2261,50 @@ function ActionButtonsEditor({ /> )} + {aType === "quantity-input" && ( +
+ addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> +
+ 수량 모달 설정 +
+ 최대값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })} + placeholder="예: qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 현재값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })} + placeholder="예: input_qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 단위 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })} + placeholder="예: EA" + className="h-6 w-20 text-[10px]" + /> +
+
+
+ )} + {aType === "select-mode" && (
@@ -2455,6 +2569,70 @@ function ImmediateActionEditor({ className="h-6 flex-1 text-[10px]" />
+ + {/* 사전 조건 (중복 방지) */} +
+
+ 사전 조건 (중복 방지) + { + if (checked) { + onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } }); + } else { + onUpdateAction({ preCondition: undefined }); + } + }} + className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5" + /> +
+ {action.preCondition && ( +
+
+ 검증 컬럼 + +
+
+ 기대값 + onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })} + placeholder="예: waiting" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 실패 메시지 + onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })} + placeholder="이미 다른 사용자가 처리했습니다" + className="h-6 flex-1 text-[10px]" + /> +
+

+ 실행 시 해당 컬럼의 현재 DB 값이 기대값과 일치할 때만 처리됩니다 +

+
+ )} +
+
변경할 컬럼{tableName ? ` (${tableName})` : ""} @@ -2491,11 +2669,22 @@ function ImmediateActionEditor({ 직접입력 + 사용자 입력 현재 사용자 현재 시간 컬럼 참조 + {u.valueType === "userInput" && ( + + )} {(u.valueType === "static" || u.valueType === "columnRef") && ( ) => void; }) { const footerStatusMap = cell.footerStatusMap || []; @@ -2644,7 +2835,7 @@ function FooterStatusEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -2680,10 +2871,12 @@ function FooterStatusEditor({ function FieldConfigEditor({ cell, allColumnOptions, + columnOptionGroups, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; onUpdate: (partial: Partial) => void; }) { const valueType = cell.valueType || "column"; @@ -2706,7 +2899,7 @@ function FieldConfigEditor({ onUpdate({ formulaRight: v })}> - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -2741,16 +2934,61 @@ function FieldConfigEditor({ function TabActions({ cfg, onUpdate, + columns, }: { cfg: PopCardListV2Config; onUpdate: (partial: Partial) => void; + columns: ColumnInfo[]; }) { const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [ + { + groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`, + options: columns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), + ], [columns, processColumns, processTableName, cfg.dataSource?.tableName]); + return (
+ {/* 소유자 우선 정렬 */} +
+ +
+ +
+

+ 선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다 +

+
+ {/* 카드 선택 시 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index f1863b13..180dc219 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -70,6 +70,7 @@ export interface CellRendererProps { onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; packageEntries?: PackageEntry[]; inputUnit?: string; + currentUserId?: string; } // ===== 메인 디스패치 ===== @@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== -function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { +function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" { const cond = btn.showCondition; if (!cond || cond.type === "always") return "visible"; @@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | matched = subStatus !== undefined && String(subStatus) === cond.value; } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else if (cond.type === "owner-match" && cond.column) { + const ownerValue = String(row[cond.column] ?? ""); + matched = !!currentUserId && ownerValue === currentUserId; } else { return "visible"; } @@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; } -function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); const currentProcessId = currentProcess?.processId; @@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode } if (cell.actionButtons && cell.actionButtons.length > 0) { const evaluated = cell.actionButtons.map((btn) => ({ btn, - state: evaluateShowCondition(btn, row), + state: evaluateShowCondition(btn, row, currentUserId), })); const activeBtn = evaluated.find((e) => e.state === "visible"); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 3b7ff73e..3680578e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 { export interface ActionButtonUpdate { column: string; value?: string; - valueType: "static" | "currentUser" | "currentTime" | "columnRef"; + valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput"; + operationType?: "assign" | "add" | "subtract"; } // 액션 버튼 클릭 시 동작 모드 @@ -881,34 +882,49 @@ export interface SelectModeConfig { export interface SelectModeButtonConfig { label: string; variant: ButtonVariant; - clickMode: "status-change" | "modal-open" | "cancel-select"; + clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; modalScreenId?: string; + quantityInput?: QuantityInputConfig; } // ===== 버튼 중심 구조 (신규) ===== export interface ActionButtonShowCondition { - type: "timeline-status" | "column-value" | "always"; + type: "timeline-status" | "column-value" | "always" | "owner-match"; value?: string; column?: string; unmatchBehavior?: "hidden" | "disabled"; } export interface ActionButtonClickAction { - type: "immediate" | "select-mode" | "modal-open"; + type: "immediate" | "select-mode" | "modal-open" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; selectModeButtons?: SelectModeButtonConfig[]; modalScreenId?: string; - // 외부 테이블 조인 설정 (DB 직접 선택 시) joinConfig?: { - sourceColumn: string; // 메인 테이블의 FK 컬럼 - targetColumn: string; // 외부 테이블의 매칭 컬럼 + sourceColumn: string; + targetColumn: string; }; + quantityInput?: QuantityInputConfig; + preCondition?: ActionPreCondition; +} + +export interface QuantityInputConfig { + maxColumn?: string; + currentColumn?: string; + unit?: string; + enablePackage?: boolean; +} + +export interface ActionPreCondition { + column: string; + expectedValue: string; + failMessage?: string; } export interface ActionButtonDef { @@ -976,6 +992,7 @@ export interface PopCardListV2Config { cartAction?: CardCartActionConfig; cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; + ownerSortColumn?: string; } /** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ From c067c373906bfbc4da9079e9e703dbe0f5f38ba8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 14:19:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=202=20-=20?= =?UTF-8?q?=EC=83=9D=EC=82=B0=20=EA=B3=B5=EC=A0=95=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20API=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9E=91=EC=97=85=EC=A7=80=EC=8B=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EA=B3=B5=EC=A0=95+=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B3=BC=20=EA=B3=B5=EC=A0=95=EB=B3=84=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=A0=9C=EC=96=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=B0=B1=EC=97=94=EB=93=9C=20API=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=ED=95=9C=EB=8B=A4.=20[=EC=8B=A0=EA=B7=9C]=20?= =?UTF-8?q?popProductionController.ts=20-=20createWorkProcesses:=20POST=20?= =?UTF-8?q?/api/pop/production/create-work-processes=20=20=20-=20item=5Fro?= =?UTF-8?q?uting=5Fdetail=20+=20process=5Fmng=20JOIN=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=20=20-=20work=5Forder=5Fprocess=20INSERT=20(=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EB=B3=84)=20=20=20-=20process=5Fwork=5Fresult=20INSERT=20SELEC?= =?UTF-8?q?T=20(=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=8A=A4=EB=83=85=EC=83=B7?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC)=20=20=20-=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(409=20Conflict)=20?= =?UTF-8?q?=20=20-=201=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20-=20controlTimer:=20POST=20/api/pop/production/time?= =?UTF-8?q?r=20=20=20-=20start:=20started=5Fat=20=EC=84=A4=EC=A0=95=20+=20?= =?UTF-8?q?status=20waiting->in=5Fprogress=20(=EB=A9=B1=EB=93=B1)=20=20=20?= =?UTF-8?q?-=20pause:=20paused=5Fat=20=EC=84=A4=EC=A0=95=20=20=20-=20resum?= =?UTF-8?q?e:=20total=5Fpaused=5Ftime=20=EB=88=84=EC=A0=81=20+=20paused=5F?= =?UTF-8?q?at=20=EC=B4=88=EA=B8=B0=ED=99=94=20[=EC=8B=A0=EA=B7=9C]=20popPr?= =?UTF-8?q?oductionRoutes.ts=20-=20authenticateToken=20=EB=AF=B8=EB=93=A4?= =?UTF-8?q?=EC=9B=A8=EC=96=B4=20=EC=A0=84=EC=97=AD=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?-=202=EA=B0=9C=20POST=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20[=EC=88=98=EC=A0=95]=20app.ts?= =?UTF-8?q?=20-=20popProductionRoutes=20import=20+=20/api/pop/production?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../controllers/popProductionController.ts | 291 ++++++++++++++++++ .../src/routes/popProductionRoutes.ts | 15 + 3 files changed, 308 insertions(+) create mode 100644 backend-node/src/controllers/popProductionController.ts create mode 100644 backend-node/src/routes/popProductionRoutes.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f45a88cd..6b86a333 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -124,6 +124,7 @@ import entitySearchRoutes, { import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 +import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -259,6 +260,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 +app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts new file mode 100644 index 00000000..d575b07a --- /dev/null +++ b/backend-node/src/controllers/popProductionController.ts @@ -0,0 +1,291 @@ +import { Response } from "express"; +import { getPool } from "../database/db"; +import logger from "../utils/logger"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; + +/** + * D-BE1: 작업지시 공정 일괄 생성 + * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. + */ +export const createWorkProcesses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_instruction_id, item_code, routing_version_id, plan_qty } = + req.body; + + if (!work_instruction_id || !routing_version_id) { + return res.status(400).json({ + success: false, + message: + "work_instruction_id와 routing_version_id는 필수입니다.", + }); + } + + logger.info("[pop/production] create-work-processes 요청", { + companyCode, + userId, + work_instruction_id, + item_code, + routing_version_id, + plan_qty, + }); + + await client.query("BEGIN"); + + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [work_instruction_id, companyCode] + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 공정이 생성된 작업지시입니다.", + }); + } + + // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, + COALESCE(pm.process_name, rd.process_code) as process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON pm.process_code = rd.process_code + AND pm.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, + [routing_version_id, companyCode] + ); + + if (routingDetails.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "라우팅 버전에 등록된 공정이 없습니다.", + }); + } + + const processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }> = []; + let totalChecklists = 0; + + for (const rd of routingDetails.rows) { + // 2. work_order_process INSERT + const wopResult = await client.query( + `INSERT INTO work_order_process ( + company_code, wo_id, seq_no, process_code, process_name, + is_required, is_fixed_order, standard_time, plan_qty, + status, routing_detail_id, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + companyCode, + work_instruction_id, + rd.seq_no, + rd.process_code, + rd.process_name, + rd.is_required, + rd.is_fixed_order, + rd.standard_time, + plan_qty || null, + "waiting", + rd.id, + userId, + ] + ); + const wopId = wopResult.rows[0].id; + + // 3. process_work_result INSERT (스냅샷 복사) + // process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사 + const snapshotResult = await client.query( + `INSERT INTO process_work_result ( + company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + pwi.company_code, $1, + pwi.id, pwd.id, + pwi.work_phase, pwi.title, pwi.sort_order::text, + pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, + pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, + pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + 'pending', $2 + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + ORDER BY pwi.sort_order, pwd.sort_order`, + [wopId, userId, rd.id, companyCode] + ); + + const checklistCount = snapshotResult.rowCount ?? 0; + totalChecklists += checklistCount; + + processes.push({ + id: wopId, + seq_no: rd.seq_no, + process_name: rd.process_name, + checklist_count: checklistCount, + }); + + logger.info("[pop/production] 공정 생성 완료", { + wopId, + processName: rd.process_name, + checklistCount, + }); + } + + await client.query("COMMIT"); + + logger.info("[pop/production] create-work-processes 완료", { + companyCode, + work_instruction_id, + total_processes: processes.length, + total_checklists: totalChecklists, + }); + + return res.json({ + success: true, + data: { + processes, + total_processes: processes.length, + total_checklists: totalChecklists, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("[pop/production] create-work-processes 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "공정 생성 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } +}; + +/** + * D-BE2: 타이머 API (시작/일시정지/재시작) + */ +export const controlTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_order_process_id, action } = req.body; + + if (!work_order_process_id || !action) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] timer 요청", { + companyCode, + userId, + work_order_process_id, + action, + }); + + let result; + + switch (action) { + case "start": + // 최초 1회만 설정, 이미 있으면 무시 + result = await pool.query( + `UPDATE work_order_process + SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, + status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, started_at, status`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE work_order_process + SET paused_at = NOW()::text, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NULL + RETURNING id, paused_at`, + [work_order_process_id, companyCode] + ); + break; + + case "resume": + // 일시정지 시간 누적 후 paused_at 초기화 + result = await pool.query( + `UPDATE work_order_process + SET total_paused_time = ( + COALESCE(total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int + )::text, + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL + RETURNING id, total_paused_time`, + [work_order_process_id, companyCode] + ); + break; + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] timer 완료", { + action, + work_order_process_id, + result: result.rows[0], + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts new file mode 100644 index 00000000..f20d470d --- /dev/null +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createWorkProcesses, + controlTimer, +} from "../controllers/popProductionController"; + +const router = Router(); + +router.use(authenticateToken); + +router.post("/create-work-processes", createWorkProcesses); +router.post("/timer", controlTimer); + +export default router; From c4d7b165382609d11c81456d43f1ecad41a2b1c8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 14:23:26 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20LOCK-OWNER=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20UI=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=B6=84=20=EB=B0=98=EC=98=81=20a2c532c7=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20CardV2=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20UI=EB=A5=BC=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20-=20locked=20=EA=B3=84=EC=82=B0:=20ownerSortColumn?= =?UTF-8?q?=20=EA=B0=92=EC=9D=B4=20=EC=A1=B4=EC=9E=AC=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=99=80=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=8B=9C=20true=20-=20isLockedBy?= =?UTF-8?q?Other=20prop=EC=9D=84=20CardV2=EC=97=90=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?-=20=EC=9E=A0=EA=B8=88=20=EC=B9=B4=EB=93=9C:=20opacity-50,=20cu?= =?UTF-8?q?rsor-not-allowed,=20onClick/onKeyDown=20=EC=B0=A8=EB=8B=A8,=20t?= =?UTF-8?q?abIndex=3D-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopCardListV2Component.tsx | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 3a52a36e..141c3ffc 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -1067,36 +1067,42 @@ export function PopCardListV2Component({ className={`min-h-0 flex-1 grid ${scrollClassName}`} style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }} > - {displayCards.map((row, index) => ( - { - const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; - if (!cartId) return; - setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); - }} - onDeleteItem={handleDeleteItem} - onUpdateQuantity={handleUpdateQuantity} - onRefresh={fetchData} - selectMode={selectMode} - isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} - isSelectable={isRowSelectable(row)} - onToggleRowSelect={() => toggleRowSelection(row)} - onEnterSelectMode={enterSelectMode} - onOpenPopModal={openPopModal} - currentUserId={currentUserId} - /> - ))} + {displayCards.map((row, index) => { + const locked = !!ownerSortColumn + && !!String(row[ownerSortColumn] ?? "") + && String(row[ownerSortColumn] ?? "") !== (currentUserId ?? ""); + return ( + { + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} + onRefresh={fetchData} + selectMode={selectMode} + isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} + isSelectable={isRowSelectable(row)} + onToggleRowSelect={() => toggleRowSelection(row)} + onEnterSelectMode={enterSelectMode} + onOpenPopModal={openPopModal} + currentUserId={currentUserId} + isLockedByOther={locked} + /> + ); + })}
{/* 선택 모드 하단 액션 바 */} @@ -1394,16 +1400,24 @@ function CardV2({ return (
{ + if (isLockedByOther) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); }} role="button" - tabIndex={0} + tabIndex={isLockedByOther ? -1 : 0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { + if (isLockedByOther) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); } From 842ac27d60c3ec11827a25e398021a04482e47a5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 16:03:24 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20V6=20=EC=A0=95=EC=82=AC=EA=B0=81?= =?UTF-8?q?=ED=98=95=20=EB=B8=94=EB=A1=9D=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=8B=A4=ED=97=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=A0=EC=A0=95=20=EC=B9=B8=20=EC=88=98(4/6/8/12?= =?UTF-8?q?)=20=EA=B8=B0=EB=B0=98=EC=9D=98=20V5=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=2024px=20=EC=A0=95=EC=82=AC=EA=B0=81?= =?UTF-8?q?=ED=98=95=20=EB=B8=94=EB=A1=9D=20=EA=B8=B0=EB=B0=98=EC=9D=98=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=B9=B8=20=EC=88=98=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20=EB=B7=B0=ED=8F=AC=ED=8A=B8=20=EB=84=88=EB=B9=84?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=B8=94=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0=EB=90=98?= =?UTF-8?q?=EB=A9=B0(375px=3D13=EC=B9=B8,=201024px=3D38=EC=B9=B8),=20?= =?UTF-8?q?=EC=9E=91=EC=9D=80=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20=ED=96=89=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20(CSS=20Flexbox=20wrap=20=EC=9B=90=EB=A6=AC?= =?UTF-8?q?)=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=9E=AC=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=90=9C=EB=8B=A4.=20[=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4]=20-=20pop-layout.ts:=20BLOCK=5FSIZE=3D24,=20BLOCK=5F?= =?UTF-8?q?GAP=3D2,=20BLOCK=5FPADDING=3D8,=20=20=20getBlockColumns()=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=B9=B8=20=EC=88=98=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?,=20GRID=5FBREAKPOINTS=20V6=20=EA=B0=92=20-=20gridUtils.ts:=20?= =?UTF-8?q?=ED=96=89=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0(=EB=B0=A9=EC=8B=9D=20F)=20-=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=96=89=20=EB=AC=B6=EC=9D=8C=20=EC=B2=98=EB=A6=AC,=20=20=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=202x2=EC=B9=B8=20=ED=84=B0=EC=B9=98=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5,=20=EB=A9=94=EC=9D=B8=20=EC=BB=A8=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=A0=84=EC=B2=B4=20=EB=84=88=EB=B9=84=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20-=20PopRenderer.tsx:=20repeat(N,=201fr)=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EB=A0=8C=EB=8D=94=EB=A7=81,=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=20=EC=B9=B8=20=EC=88=98=20-=20PopCanvas.tsx:=20=EB=B7=B0?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=ED=94=84=EB=A6=AC=EC=85=8B=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EC=B9=B8=20=EC=88=98,=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=20=EB=B3=80=ED=99=98=20[V5=E2=86=92V6=20?= =?UTF-8?q?=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=B3=80=ED=99=98]=20-=20convert?= =?UTF-8?q?V5LayoutToV6:=20DB=20=EB=AF=B8=EC=88=98=EC=A0=95,=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=20=2012=EC=B9=B8=20=EC=A2=8C=ED=91=9C=20=E2=86=92?= =?UTF-8?q?=2038=EC=B9=B8=20=EB=B8=94=EB=A1=9D=20=EB=B3=80=ED=99=98,=20V5?= =?UTF-8?q?=20overrides=20=EC=A0=9C=EA=B1=B0=20-=20PopDesigner/page.tsx:?= =?UTF-8?q?=20=EB=A1=9C=EB=93=9C=20=EC=A7=80=EC=A0=90=EC=97=90=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=ED=95=A8=EC=88=98=20=EC=82=BD=EC=9E=85=20[?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0]=20-=20ComponentEditorP?= =?UTF-8?q?anel:=20=EB=86=92=EC=9D=B4=20=ED=91=9C=EC=8B=9C/=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EB=9D=BC=EB=B2=A8=20V6=20=EC=88=98=EC=B9=98=20-=20?= =?UTF-8?q?PopCardListConfig:=20=EC=B9=B4=EB=93=9C=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?threshold=20V6=20=EA=B8=B0=EC=A4=80=20-=20PopDesigner:=20handle?= =?UTF-8?q?HideComponent=20=EA=B8=B0=EB=B3=B8=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=ED=95=B4=EC=A0=9C=20[=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EC=A6=88]=20-=20=EC=86=8C=ED=98=95(2x2):?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98,=20=ED=94=84=EB=A1=9C=ED=95=84,?= =?UTF-8?q?=20=EC=8A=A4=EC=BA=90=EB=84=88=20-=20=EC=A4=91=ED=98=95(8x4):?= =?UTF-8?q?=20=EA=B2=80=EC=83=89,=20=EB=B2=84=ED=8A=BC,=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20-=20=EB=8C=80=ED=98=95(19x6~10):=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C,=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C,=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20DB=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4,=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4,?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=200=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(pop)/pop/screens/[screenId]/page.tsx | 15 +- .../components/pop/designer/PopCanvas.tsx | 52 +-- .../components/pop/designer/PopDesigner.tsx | 12 +- .../designer/panels/ComponentEditorPanel.tsx | 12 +- .../pop/designer/renderers/PopRenderer.tsx | 47 ++- .../pop/designer/types/pop-layout.ts | 148 +++---- .../pop/designer/utils/gridUtils.ts | 377 ++++++++++-------- .../pop-card-list/PopCardListConfig.tsx | 4 +- 8 files changed, 375 insertions(+), 292 deletions(-) diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 7fe11270..bf2878a5 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -23,8 +23,11 @@ import { createEmptyPopLayoutV5, GAP_PRESETS, GRID_BREAKPOINTS, + BLOCK_GAP, + BLOCK_PADDING, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; +import { convertV5LayoutToV6 } from "@/components/pop/designer/utils/gridUtils"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; @@ -117,8 +120,8 @@ function PopScreenViewPage() { const popLayout = await screenApi.getLayoutPop(screenId); if (popLayout && isV5Layout(popLayout)) { - // v5 레이아웃 로드 - setLayout(popLayout); + const v6Layout = convertV5LayoutToV6(popLayout); + setLayout(v6Layout); const componentCount = Object.keys(popLayout.components).length; console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { @@ -318,12 +321,8 @@ function PopScreenViewPage() { style={{ maxWidth: 1366 }} > {(() => { - // Gap 프리셋 계산 - const currentGapPreset = layout.settings.gapPreset || "medium"; - const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; - const breakpoint = GRID_BREAKPOINTS[currentModeKey]; - const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); - const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; return ( (null); const canvasRef = useRef(null); - // 현재 뷰포트 해상도 + // V6: 뷰포트에서 동적 블록 칸 수 계산 const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; - const breakpoint = GRID_BREAKPOINTS[currentMode]; + const dynamicColumns = getBlockColumns(customWidth); + const breakpoint = { + ...GRID_BREAKPOINTS[currentMode], + columns: dynamicColumns, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `${dynamicColumns}칸 블록`, + }; - // Gap 프리셋 적용 + // V6: 블록 간격 고정 (프리셋 무관) const currentGapPreset = layout.settings.gapPreset || "medium"; - const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; - const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); - const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); + const adjustedGap = BLOCK_GAP; + const adjustedPadding = BLOCK_PADDING; // 숨김 컴포넌트 ID 목록 (activeLayout 기반) const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; @@ -805,7 +807,7 @@ export default function PopCanvas({ {/* 하단 정보 */}
- {breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px) + V6 블록 그리드 - {dynamicColumns}칸 (블록: {BLOCK_SIZE}px, 간격: {BLOCK_GAP}px)
Space + 드래그: 패닝 | Ctrl + 휠: 줌 diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 36241817..de131032 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -33,7 +33,7 @@ import { PopModalDefinition, PopDataConnection, } from "./types/pop-layout"; -import { getAllEffectivePositions } from "./utils/gridUtils"; +import { getAllEffectivePositions, convertV5LayoutToV6 } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { PopDesignerContext } from "./PopDesignerContext"; @@ -151,13 +151,12 @@ export default function PopDesigner({ const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { - // v5 레이아웃 로드 - // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } - setLayout(loadedLayout); - setHistory([loadedLayout]); + const v6Layout = convertV5LayoutToV6(loadedLayout); + setLayout(v6Layout); + setHistory([v6Layout]); setHistoryIndex(0); // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) @@ -605,9 +604,6 @@ export default function PopDesigner({ // ======================================== const handleHideComponent = useCallback((componentId: string) => { - // 12칸 모드에서는 숨기기 불가 - if (currentMode === "tablet_landscape") return; - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; // 이미 숨겨져 있으면 무시 diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 32ff5e06..ec22426d 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -7,6 +7,8 @@ import { PopGridPosition, GridMode, GRID_BREAKPOINTS, + BLOCK_SIZE, + getBlockColumns, } from "../types/pop-layout"; import { Settings, @@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate

- 높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px + 높이: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px

@@ -470,10 +472,10 @@ interface VisibilityFormProps { function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { const modes: Array<{ key: GridMode; label: string }> = [ - { key: "tablet_landscape", label: "태블릿 가로 (12칸)" }, - { key: "tablet_portrait", label: "태블릿 세로 (8칸)" }, - { key: "mobile_landscape", label: "모바일 가로 (6칸)" }, - { key: "mobile_portrait", label: "모바일 세로 (4칸)" }, + { key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` }, + { key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` }, + { key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` }, + { key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` }, ]; const handleVisibilityChange = (mode: GridMode, visible: boolean) => { diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index a9c7db6e..373bed9b 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -13,6 +13,10 @@ import { GridBreakpoint, detectGridMode, PopComponentType, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; import { convertAndResolvePositions, @@ -107,18 +111,27 @@ export default function PopRenderer({ }: PopRendererProps) { const { gridConfig, components, overrides } = layout; - // 현재 모드 (자동 감지 또는 지정) + // V6: 뷰포트 너비에서 블록 칸 수 동적 계산 const mode = currentMode || detectGridMode(viewportWidth); - const breakpoint = GRID_BREAKPOINTS[mode]; + const columns = getBlockColumns(viewportWidth); - // Gap/Padding: 오버라이드 우선, 없으면 기본값 사용 - const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap; - const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding; + // V6: 블록 간격 고정 + const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP; + const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING; + + // 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용) + const breakpoint: GridBreakpoint = { + columns, + rowHeight: BLOCK_SIZE, + gap: finalGap, + padding: finalPadding, + label: `${columns}칸 블록`, + }; // 숨김 컴포넌트 ID 목록 const hiddenIds = overrides?.[mode]?.hidden || []; - // 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외) + // 동적 행 수 계산 const dynamicRowCount = useMemo(() => { const visibleComps = Object.values(components).filter( comp => !hiddenIds.includes(comp.id) @@ -131,19 +144,17 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // CSS Grid 스타일 - // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) - // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) + // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE const rowTemplate = isDesignMode - ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` - : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; + ? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)` + : `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`; const autoRowHeight = isDesignMode - ? `${breakpoint.rowHeight}px` - : `minmax(${breakpoint.rowHeight}px, auto)`; + ? `${BLOCK_SIZE}px` + : `minmax(${BLOCK_SIZE}px, auto)`; const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", - gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, + gridTemplateColumns: `repeat(${columns}, 1fr)`, gridTemplateRows: rowTemplate, gridAutoRows: autoRowHeight, gap: `${finalGap}px`, @@ -151,15 +162,15 @@ export default function PopRenderer({ minHeight: "100%", backgroundColor: "#ffffff", position: "relative", - }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); + }), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); - // 그리드 가이드 셀 생성 (동적 행 수) + // 그리드 가이드 셀 생성 const gridCells = useMemo(() => { if (!isDesignMode || !showGridGuide) return []; const cells = []; for (let row = 1; row <= dynamicRowCount; row++) { - for (let col = 1; col <= breakpoint.columns; col++) { + for (let col = 1; col <= columns; col++) { cells.push({ id: `cell-${col}-${row}`, col, @@ -168,7 +179,7 @@ export default function PopRenderer({ } } return cells; - }, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); + }, [isDesignMode, showGridGuide, columns, dynamicRowCount]); // visibility 체크 const isVisible = (comp: PopComponentDefinitionV5): boolean => { diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 9fb9a847..44e4c1c4 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -99,24 +99,39 @@ export interface PopLayoutMetadata { } // ======================================== -// v5 그리드 기반 레이아웃 +// v6 정사각형 블록 그리드 시스템 // ======================================== -// 핵심: CSS Grid로 정확한 위치 지정 -// - 열/행 좌표로 배치 (col, row) -// - 칸 단위 크기 (colSpan, rowSpan) -// - Material Design 브레이크포인트 기반 +// 핵심: 균일한 정사각형 블록 (24px x 24px) +// - 열/행 좌표로 배치 (col, row) - 블록 단위 +// - 뷰포트 너비에 따라 칸 수 동적 계산 +// - 단일 좌표계 (모드별 변환 불필요) /** - * 그리드 모드 (4가지) + * V6 블록 상수 + */ +export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형) +export const BLOCK_GAP = 2; // 블록 간격 (px) +export const BLOCK_PADDING = 8; // 캔버스 패딩 (px) + +/** + * 뷰포트 너비에서 블록 칸 수 계산 + */ +export function getBlockColumns(viewportWidth: number): number { + const available = viewportWidth - BLOCK_PADDING * 2; + return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP))); +} + +/** + * 그리드 모드 (하위 호환용 - V6에서는 뷰포트 프리셋 라벨로만 사용) */ export type GridMode = - | "mobile_portrait" // 4칸 - | "mobile_landscape" // 6칸 - | "tablet_portrait" // 8칸 - | "tablet_landscape"; // 12칸 (기본) + | "mobile_portrait" + | "mobile_landscape" + | "tablet_portrait" + | "tablet_landscape"; /** - * 그리드 브레이크포인트 설정 + * 그리드 브레이크포인트 설정 (하위 호환용) */ export interface GridBreakpoint { minWidth?: number; @@ -129,50 +144,43 @@ export interface GridBreakpoint { } /** - * 브레이크포인트 상수 - * 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반 + * V6 브레이크포인트 (블록 기반 동적 칸 수) + * columns는 각 뷰포트 너비에서의 블록 수 */ export const GRID_BREAKPOINTS: Record = { - // 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra) mobile_portrait: { maxWidth: 479, - columns: 4, - rowHeight: 40, - gap: 8, - padding: 12, - label: "모바일 세로 (4칸)", + columns: getBlockColumns(375), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 세로 (${getBlockColumns(375)}칸)`, }, - - // 스마트폰 가로 + 소형 태블릿 mobile_landscape: { minWidth: 480, maxWidth: 767, - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - label: "모바일 가로 (6칸)", + columns: getBlockColumns(600), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `모바일 가로 (${getBlockColumns(600)}칸)`, }, - - // 태블릿 세로 (iPad Mini ~ iPad Pro) tablet_portrait: { minWidth: 768, maxWidth: 1023, - columns: 8, - rowHeight: 48, - gap: 12, - padding: 16, - label: "태블릿 세로 (8칸)", + columns: getBlockColumns(820), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 세로 (${getBlockColumns(820)}칸)`, }, - - // 태블릿 가로 + 데스크톱 (기본) tablet_landscape: { minWidth: 1024, - columns: 12, - rowHeight: 48, - gap: 16, - padding: 24, - label: "태블릿 가로 (12칸)", + columns: getBlockColumns(1024), + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + label: `태블릿 가로 (${getBlockColumns(1024)}칸)`, }, } as const; @@ -183,7 +191,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; /** * 뷰포트 너비로 모드 감지 - * GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용 */ export function detectGridMode(viewportWidth: number): GridMode { if (viewportWidth < 480) return "mobile_portrait"; @@ -225,17 +232,17 @@ export interface PopLayoutDataV5 { } /** - * 그리드 설정 + * 그리드 설정 (V6: 블록 단위) */ export interface PopGridConfig { - // 행 높이 (px) - 1행의 기본 높이 - rowHeight: number; // 기본 48px + // 행 높이 = 블록 크기 (px) + rowHeight: number; // V6 기본 24px (= BLOCK_SIZE) // 간격 (px) - gap: number; // 기본 8px + gap: number; // V6 기본 2px (= BLOCK_GAP) // 패딩 (px) - padding: number; // 기본 16px + padding: number; // V6 기본 8px (= BLOCK_PADDING) } /** @@ -274,7 +281,7 @@ export interface PopComponentDefinitionV5 { } /** - * Gap 프리셋 타입 + * Gap 프리셋 타입 (V6: 단일 간격이므로 medium만 유효, 하위 호환용 유지) */ export type GapPreset = "narrow" | "medium" | "wide"; @@ -287,12 +294,12 @@ export interface GapPresetConfig { } /** - * Gap 프리셋 상수 + * Gap 프리셋 상수 (V6: 모두 동일 - 블록 간격 고정) */ export const GAP_PRESETS: Record = { - narrow: { multiplier: 0.5, label: "좁게" }, - medium: { multiplier: 1.0, label: "보통" }, - wide: { multiplier: 1.5, label: "넓게" }, + narrow: { multiplier: 1.0, label: "기본" }, + medium: { multiplier: 1.0, label: "기본" }, + wide: { multiplier: 1.0, label: "기본" }, }; /** @@ -330,9 +337,9 @@ export interface PopModeOverrideV5 { export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ version: "pop-5.0", gridConfig: { - rowHeight: 48, - gap: 8, - padding: 16, + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, }, components: {}, dataFlow: { connections: [] }, @@ -351,22 +358,27 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { }; /** - * 컴포넌트 타입별 기본 크기 (칸 단위) + * 컴포넌트 타입별 기본 크기 (블록 단위, V6) + * + * 소형 (2x2) : 최소 단위. 아이콘, 프로필, 스캐너 등 단일 요소 + * 중형 (8x4) : 검색, 버튼, 텍스트 등 한 줄 입력/표시 + * 대형 (8x6) : 샘플, 상태바, 필드 등 여러 줄 컨텐츠 + * 초대형 (19x8~) : 카드, 리스트, 대시보드 등 메인 영역 */ export const DEFAULT_COMPONENT_GRID_SIZE: Record = { - "pop-sample": { colSpan: 2, rowSpan: 1 }, - "pop-text": { colSpan: 3, rowSpan: 1 }, - "pop-icon": { colSpan: 1, rowSpan: 2 }, - "pop-dashboard": { colSpan: 6, rowSpan: 3 }, - "pop-card-list": { colSpan: 4, rowSpan: 3 }, - "pop-card-list-v2": { colSpan: 4, rowSpan: 3 }, - "pop-button": { colSpan: 2, rowSpan: 1 }, - "pop-string-list": { colSpan: 4, rowSpan: 3 }, - "pop-search": { colSpan: 2, rowSpan: 1 }, - "pop-status-bar": { colSpan: 6, rowSpan: 1 }, - "pop-field": { colSpan: 6, rowSpan: 2 }, - "pop-scanner": { colSpan: 1, rowSpan: 1 }, - "pop-profile": { colSpan: 1, rowSpan: 1 }, + "pop-sample": { colSpan: 8, rowSpan: 6 }, + "pop-text": { colSpan: 8, rowSpan: 4 }, + "pop-icon": { colSpan: 2, rowSpan: 2 }, + "pop-dashboard": { colSpan: 19, rowSpan: 10 }, + "pop-card-list": { colSpan: 19, rowSpan: 10 }, + "pop-card-list-v2": { colSpan: 19, rowSpan: 10 }, + "pop-button": { colSpan: 8, rowSpan: 4 }, + "pop-string-list": { colSpan: 19, rowSpan: 10 }, + "pop-search": { colSpan: 8, rowSpan: 4 }, + "pop-status-bar": { colSpan: 19, rowSpan: 4 }, + "pop-field": { colSpan: 19, rowSpan: 6 }, + "pop-scanner": { colSpan: 2, rowSpan: 2 }, + "pop-profile": { colSpan: 2, rowSpan: 2 }, }; /** diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index 308ce730..e5078f64 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -6,196 +6,148 @@ import { GapPreset, GAP_PRESETS, PopLayoutDataV5, - PopComponentDefinitionV5, + PopComponentDefinitionV5, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, } from "../types/pop-layout"; // ======================================== -// Gap/Padding 조정 +// Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환) // ======================================== -/** - * Gap 프리셋에 따라 breakpoint의 gap/padding 조정 - * - * @param base 기본 breakpoint 설정 - * @param preset Gap 프리셋 ("narrow" | "medium" | "wide") - * @returns 조정된 breakpoint (gap, padding 계산됨) - */ export function getAdjustedBreakpoint( base: GridBreakpoint, preset: GapPreset ): GridBreakpoint { - const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0; - - return { - ...base, - gap: Math.round(base.gap * multiplier), - padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px - }; + return { ...base }; } // ======================================== -// 그리드 위치 변환 +// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요) // ======================================== /** - * 12칸 기준 위치를 다른 모드로 변환 + * V6: 단일 좌표계이므로 변환 없이 원본 반환 + * @deprecated V6에서는 좌표 변환이 불필요합니다 */ export function convertPositionToMode( position: PopGridPosition, targetMode: GridMode ): PopGridPosition { - const sourceColumns = 12; - const targetColumns = GRID_BREAKPOINTS[targetMode].columns; - - // 같은 칸 수면 그대로 반환 - if (sourceColumns === targetColumns) { - return position; - } - - const ratio = targetColumns / sourceColumns; - - // 열 위치 변환 - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol > targetColumns) { - newCol = 1; - } - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - - return { - col: newCol, - row: position.row, - colSpan: Math.max(1, newColSpan), - rowSpan: position.rowSpan, - }; + return position; } /** - * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 - * - * v5.1 자동 줄바꿈: - * - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치 - * - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨 + * V6 행 그룹 리플로우 (방식 F) + * + * 원리: CSS Flexbox wrap과 동일. + * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 + * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) + * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) + * 4. 설계 너비의 50% 이상 → 전체 너비 확장 + * 5. 리플로우 후 겹침 해결 (resolveOverlaps) */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, targetMode: GridMode ): Array<{ id: string; position: PopGridPosition }> { - // 엣지 케이스: 빈 배열 - if (components.length === 0) { - return []; - } + if (components.length === 0) return []; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns; - // 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) - const converted = components.map(comp => ({ - id: comp.id, - position: convertPositionToMode(comp.position, targetMode), - originalCol: comp.position.col, // 원본 col 보존 - })); + if (targetColumns >= designColumns) { + return components.map(c => ({ id: c.id, position: { ...c.position } })); + } - // 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 - const normalComponents = converted.filter(c => c.originalCol <= targetColumns); - const overflowComponents = converted.filter(c => c.originalCol > targetColumns); + const ratio = targetColumns / designColumns; + const MIN_COL_SPAN = 2; + const MIN_ROW_SPAN = 2; - // 3단계: 정상 컴포넌트의 최대 row 계산 - const maxRow = normalComponents.length > 0 - ? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1)) - : 0; - - // 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치 - let currentRow = maxRow + 1; - const wrappedComponents = overflowComponents.map(comp => { - const wrappedPosition: PopGridPosition = { - col: 1, // 왼쪽 끝부터 시작 - row: currentRow, - colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한 - rowSpan: comp.position.rowSpan, - }; - currentRow += comp.position.rowSpan; // 다음 행으로 이동 - - return { - id: comp.id, - position: wrappedPosition, - }; + // 1. 원본 row 기준 그룹핑 + const rowGroups: Record> = {}; + components.forEach(comp => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(comp); }); - // 5단계: 정상 + 줄바꿈 컴포넌트 병합 - const adjusted = [ - ...normalComponents.map(c => ({ id: c.id, position: c.position })), - ...wrappedComponents, - ]; + const placed: Array<{ id: string; position: PopGridPosition }> = []; + let outputRow = 1; - // 6단계: 겹침 해결 (아래로 밀기) - return resolveOverlaps(adjusted, targetColumns); + // 2. 각 행 그룹을 순서대로 처리 + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + + for (const rowKey of sortedRows) { + const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col); + let currentCol = 1; + let maxRowSpanInLine = 0; + + for (const comp of group) { + const pos = comp.position; + const isMainContent = pos.colSpan >= designColumns * 0.5; + + let scaledSpan = isMainContent + ? targetColumns + : Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio)); + scaledSpan = Math.min(scaledSpan, targetColumns); + + const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); + + // 현재 줄에 안 들어가면 줄바꿈 + if (currentCol + scaledSpan - 1 > targetColumns) { + outputRow += Math.max(1, maxRowSpanInLine); + currentCol = 1; + maxRowSpanInLine = 0; + } + + placed.push({ + id: comp.id, + position: { + col: currentCol, + row: outputRow, + colSpan: scaledSpan, + rowSpan: scaledRowSpan, + }, + }); + + maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan); + currentCol += scaledSpan; + } + + outputRow += Math.max(1, maxRowSpanInLine); + } + + // 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리) + return resolveOverlaps(placed, targetColumns); } // ======================================== -// 검토 필요 판별 +// 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음) // ======================================== /** - * 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인 - * - * v5.1 검토 필요 기준: - * - 12칸 모드(기본 모드)가 아님 - * - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함) - * - * @param currentMode 현재 그리드 모드 - * @param hasOverride 해당 모드에서 오버라이드 존재 여부 - * @returns true = 검토 필요, false = 검토 완료 또는 불필요 + * V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음 + * 항상 false 반환 */ export function needsReview( currentMode: GridMode, hasOverride: boolean ): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; - - // 12칸 모드는 기본 모드이므로 검토 불필요 - if (targetColumns === 12) { - return false; - } - - // 오버라이드가 있으면 이미 편집함 → 검토 완료 - if (hasOverride) { - return false; - } - - // 오버라이드 없으면 → 검토 필요 - return true; + return false; } /** - * @deprecated v5.1부터 needsReview() 사용 권장 - * - * 기존 isOutOfBounds는 "화면 밖" 개념이었으나, - * v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다. - * 대신 needsReview()로 "검토 필요" 여부를 판별하세요. + * @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음 */ export function isOutOfBounds( originalPosition: PopGridPosition, currentMode: GridMode, overridePosition?: PopGridPosition | null ): boolean { - const targetColumns = GRID_BREAKPOINTS[currentMode].columns; - - // 12칸 모드면 초과 불가 - if (targetColumns === 12) { - return false; - } - - // 오버라이드가 있으면 오버라이드 위치로 판단 - if (overridePosition) { - return overridePosition.col > targetColumns; - } - - // 오버라이드 없으면 원본 col로 판단 - return originalPosition.col > targetColumns; + return false; } // ======================================== @@ -269,12 +221,8 @@ export function resolveOverlaps( // ======================================== /** - * 마우스 좌표 → 그리드 좌표 변환 - * - * CSS Grid 계산 방식: - * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) - * - 각 칸 너비 = 사용 가능 너비 / columns - * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) + * V6: 마우스 좌표 → 블록 그리드 좌표 변환 + * 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함 */ export function mouseToGridPosition( mouseX: number, @@ -285,28 +233,19 @@ export function mouseToGridPosition( gap: number, padding: number ): { col: number; row: number } { - // 캔버스 내 상대 위치 (패딩 영역 포함) const relX = mouseX - canvasRect.left - padding; const relY = mouseY - canvasRect.top - padding; - // CSS Grid 1fr 계산과 동일하게 - // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) - const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); - const colWidth = availableWidth / columns; + const cellStride = BLOCK_SIZE + gap; - // 각 셀의 실제 간격 (셀 너비 + gap) - const cellStride = colWidth + gap; - - // 그리드 좌표 계산 (1부터 시작) - // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); - const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); + const row = Math.max(1, Math.floor(relY / cellStride) + 1); return { col, row }; } /** - * 그리드 좌표 → 픽셀 좌표 변환 + * V6: 블록 그리드 좌표 → 픽셀 좌표 변환 */ export function gridToPixelPosition( col: number, @@ -319,14 +258,13 @@ export function gridToPixelPosition( gap: number, padding: number ): { x: number; y: number; width: number; height: number } { - const totalGap = gap * (columns - 1); - const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; + const cellStride = BLOCK_SIZE + gap; return { - x: padding + (col - 1) * (colWidth + gap), - y: padding + (row - 1) * (rowHeight + gap), - width: colWidth * colSpan + gap * (colSpan - 1), - height: rowHeight * rowSpan + gap * (rowSpan - 1), + x: padding + (col - 1) * cellStride, + y: padding + (row - 1) * cellStride, + width: BLOCK_SIZE * colSpan + gap * (colSpan - 1), + height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1), }; } @@ -560,3 +498,126 @@ export function getAllEffectivePositions( return result; } + +// ======================================== +// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환) +// ======================================== + +const V5_BASE_COLUMNS = 12; +const V5_BASE_ROW_HEIGHT = 48; +const V5_BASE_GAP = 16; +const V5_DESIGN_WIDTH = 1024; + +/** + * V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고 + * 좌표가 12칸 체계인 경우만 V5로 판정 + */ +function isV5GridConfig(layout: PopLayoutDataV5): boolean { + if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; + + const maxCol = Object.values(layout.components).reduce((max, comp) => { + const end = comp.position.col + comp.position.colSpan - 1; + return Math.max(max, end); + }, 0); + + return maxCol <= V5_BASE_COLUMNS; +} + +function convertV5PositionToV6( + pos: PopGridPosition, + v6DesignColumns: number, +): PopGridPosition { + const colRatio = v6DesignColumns / V5_BASE_COLUMNS; + const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP); + + const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); + let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); + const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); + + if (newCol + newColSpan - 1 > v6DesignColumns) { + newColSpan = v6DesignColumns - newCol + 1; + } + + return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; +} + +/** + * V5 레이아웃을 V6 블록 좌표로 런타임 변환 + * - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환 + * - 모드별 overrides 폐기 (자동 줄바꿈으로 대체) + * - DB 데이터는 건드리지 않음 (메모리에서만 변환) + */ +export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 { + // V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음) + // 좌표 변환 필요 여부와 무관하게 항상 제거 + if (!isV5GridConfig(layout)) { + return { + ...layout, + gridConfig: { + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, + }, + overrides: undefined, + }; + } + + const v6Columns = getBlockColumns(V5_DESIGN_WIDTH); + + const rowGroups: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(id); + }); + + const convertedPositions: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns); + }); + + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + const rowMapping: Record = {}; + let v6Row = 1; + for (const v5Row of sortedRows) { + rowMapping[v5Row] = v6Row; + const maxSpan = Math.max( + ...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan) + ); + v6Row += maxSpan; + } + + const newComponents = { ...layout.components }; + Object.entries(newComponents).forEach(([id, comp]) => { + const converted = convertedPositions[id]; + const mappedRow = rowMapping[comp.position.row] ?? converted.row; + newComponents[id] = { + ...comp, + position: { ...converted, row: mappedRow }, + }; + }); + + const newModals = layout.modals?.map(modal => { + const modalComps = { ...modal.components }; + Object.entries(modalComps).forEach(([id, comp]) => { + modalComps[id] = { + ...comp, + position: convertV5PositionToV6(comp.position, v6Columns), + }; + }); + return { + ...modal, + gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, + components: modalComps, + overrides: undefined, + }; + }); + + return { + ...layout, + gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, + components: newComponents, + overrides: undefined, + modals: newModals, + }; +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 1c351cf2..f5d06036 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -295,8 +295,8 @@ function BasicSettingsTab({ const recommendation = useMemo(() => { if (!currentMode) return null; const cols = GRID_BREAKPOINTS[currentMode].columns; - if (cols >= 8) return { rows: 3, cols: 2 }; - if (cols >= 6) return { rows: 3, cols: 1 }; + if (cols >= 25) return { rows: 3, cols: 2 }; + if (cols >= 18) return { rows: 3, cols: 1 }; return { rows: 2, cols: 1 }; }, [currentMode]); From 320100c4e2581e79dd69c622e2c5196e8560e713 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Mar 2026 16:32:20 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20POP=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20+=20Dead=20Code=20=EC=A0=9C=EA=B1=B0=20V5?= =?UTF-8?q?=E2=86=92V6=20=EC=A0=84=ED=99=98=20=EA=B3=BC=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=88=84=EC=A0=81=EB=90=9C=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A0=91=EB=AF=B8=EC=82=AC,=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98,=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=9E=94?= =?UTF-8?q?=EC=9E=AC=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A6=AC=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4=ED=95=9C=EB=8B=A4.=2014=EA=B0=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95,=20365=EC=A4=84=20=EC=88=9C?= =?UTF-8?q?=EA=B0=90.=20[=ED=83=80=EC=9E=85=20=EB=A6=AC=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D]=20(14=EA=B0=9C=20=ED=8C=8C=EC=9D=BC)=20-=20PopLayout?= =?UTF-8?q?DataV5=20=E2=86=92=20PopLayoutData=20-=20PopComponentDefinition?= =?UTF-8?q?V5=20=E2=86=92=20PopComponentDefinition=20-=20PopGlobalSettings?= =?UTF-8?q?V5=20=E2=86=92=20PopGlobalSettings=20-=20PopModeOverrideV5=20?= =?UTF-8?q?=E2=86=92=20PopModeOverride=20-=20createEmptyPopLayoutV5=20?= =?UTF-8?q?=E2=86=92=20createEmptyLayout=20-=20isV5Layout=20=E2=86=92=20is?= =?UTF-8?q?PopLayout=20-=20addComponentToV5Layout=20=E2=86=92=20addCompone?= =?UTF-8?q?ntToLayout=20-=20createComponentDefinitionV5=20=E2=86=92=20crea?= =?UTF-8?q?teComponentDefinition=20-=20=EA=B5=AC=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=80=20deprecated=20=EB=B3=84=EC=B9=AD=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=20(=ED=95=98=EC=9C=84=20=ED=98=B8?= =?UTF-8?q?=ED=99=98)=20[Dead=20Code=20=EC=82=AD=EC=A0=9C]=20(gridUtils.ts?= =?UTF-8?q?=20-350=EC=A4=84)=20-=20getAdjustedBreakpoint,=20convertPositio?= =?UTF-8?q?nToMode,=20isOutOfBounds,=20=20=20mouseToGridPosition,=20gridTo?= =?UTF-8?q?PixelPosition,=20isValidPosition,=20=20=20clampPosition,=20auto?= =?UTF-8?q?LayoutComponents=20(=EC=A0=84=EB=B6=80=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=200=EA=B1=B4)=20-=20needsReview=20+=20Review?= =?UTF-8?q?Panel/ReviewItem=20(=ED=95=AD=EC=83=81=20false,=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9)=20-=20getEffectiveComponentPosition=20expor?= =?UTF-8?q?t=20=E2=86=92=20=EB=82=B4=EB=B6=80=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20[=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=EB=A1=9C=EB=8D=94=20=EB=B6=84=EB=A6=AC]=20(=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20legacyLoader.ts)=20-=20convertV5LayoutToV6=20?= =?UTF-8?q?=E2=86=92=20loadLegacyLayout=20(legacyLoader.ts)=20-=20V5=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=83=81=EC=88=98/=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20gridUtils=EC=97=90=EC=84=9C=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?[=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC]=20-=20"v5=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C"=20=E2=86=92=20"POP=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C"=20-=20"=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=9A=A9"=20=E2=86=92=20"=EB=B7=B0=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=ED=94=84=EB=A6=AC=EC=85=8B"=20/=20"=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95=EC=9A=A9"=20-?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=ED=97=A4=EB=8D=94,=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=EB=B6=84,=20=ED=95=A8=EC=88=98=20JSDoc=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?0=EA=B1=B4.=20DB=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4.=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(pop)/pop/screens/[screenId]/page.tsx | 20 +- .../components/pop/designer/PopCanvas.tsx | 134 +----- .../components/pop/designer/PopDesigner.tsx | 43 +- frontend/components/pop/designer/index.ts | 7 +- .../designer/panels/ComponentEditorPanel.tsx | 22 +- .../pop/designer/panels/ConnectionEditor.tsx | 20 +- .../pop/designer/renderers/PopRenderer.tsx | 20 +- .../pop/designer/types/pop-layout.ts | 98 ++-- .../pop/designer/utils/gridUtils.ts | 419 +----------------- .../pop/designer/utils/legacyLoader.ts | 128 ++++++ .../pop/viewer/PopViewerWithModals.tsx | 6 +- .../PopCardListV2Component.tsx | 8 +- .../registry/pop-components/pop-scanner.tsx | 6 +- .../pop-search/PopSearchConfig.tsx | 8 +- .../pop-status-bar/PopStatusBarConfig.tsx | 2 +- 15 files changed, 301 insertions(+), 640 deletions(-) create mode 100644 frontend/components/pop/designer/utils/legacyLoader.ts diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index bf2878a5..c7933033 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -17,17 +17,17 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { - PopLayoutDataV5, + PopLayoutData, GridMode, - isV5Layout, - createEmptyPopLayoutV5, + isPopLayout, + createEmptyLayout, GAP_PRESETS, GRID_BREAKPOINTS, BLOCK_GAP, BLOCK_PADDING, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; -import { convertV5LayoutToV6 } from "@/components/pop/designer/utils/gridUtils"; +import { loadLegacyLayout } from "@/components/pop/designer/utils/legacyLoader"; // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) import "@/lib/registry/pop-components"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; @@ -82,7 +82,7 @@ function PopScreenViewPage() { const { user } = useAuth(); const [screen, setScreen] = useState(null); - const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [layout, setLayout] = useState(createEmptyLayout()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -119,22 +119,22 @@ function PopScreenViewPage() { try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && isV5Layout(popLayout)) { - const v6Layout = convertV5LayoutToV6(popLayout); + if (popLayout && isPopLayout(popLayout)) { + const v6Layout = loadLegacyLayout(popLayout); setLayout(v6Layout); const componentCount = Object.keys(popLayout.components).length; console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { // 다른 버전 레이아웃은 빈 v5로 처리 console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } else { console.log("[POP] 레이아웃 없음"); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } } catch (layoutError) { console.warn("[POP] 레이아웃 로드 실패:", layoutError); - setLayout(createEmptyPopLayoutV5()); + setLayout(createEmptyLayout()); } } catch (error) { console.error("[POP] 화면 로드 실패:", error); diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index acb654ae..d12422ec 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -4,8 +4,8 @@ import { useCallback, useRef, useState, useEffect, useMemo } from "react"; import { useDrop } from "react-dnd"; import { cn } from "@/lib/utils"; import { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopComponentType, PopGridPosition, GridMode, @@ -22,7 +22,7 @@ import { BLOCK_PADDING, getBlockColumns, } from "./types/pop-layout"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; import { @@ -34,7 +34,7 @@ import { } from "@/components/ui/select"; import { toast } from "sonner"; import PopRenderer from "./renderers/PopRenderer"; -import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils"; +import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions } from "./utils/gridUtils"; import { DND_ITEM_TYPES } from "./constants"; /** @@ -95,13 +95,13 @@ const CANVAS_EXTRA_ROWS = 3; // 여유 행 수 // Props // ======================================== interface PopCanvasProps { - layout: PopLayoutDataV5; + layout: PopLayoutData; selectedComponentId: string | null; currentMode: GridMode; onModeChange: (mode: GridMode) => void; onSelectComponent: (id: string | null) => void; onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; - onUpdateComponent: (componentId: string, updates: Partial) => void; + onUpdateComponent: (componentId: string, updates: Partial) => void; onDeleteComponent: (componentId: string) => void; onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; @@ -163,7 +163,7 @@ export default function PopCanvas({ }, [layout.modals]); // activeCanvasId에 따라 렌더링할 layout 분기 - const activeLayout = useMemo((): PopLayoutDataV5 => { + const activeLayout = useMemo((): PopLayoutData => { if (activeCanvasId === "main") return layout; const modal = layout.modals?.find(m => m.id === activeCanvasId); if (!modal) return layout; // fallback @@ -401,7 +401,7 @@ export default function PopCanvas({ const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 - // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 + // 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 const currentEffectivePos = effectivePositions.get(dragItem.componentId); const componentData = layout.components[dragItem.componentId]; @@ -472,22 +472,8 @@ export default function PopCanvas({ ); }, [activeLayout.components, hiddenComponentIds]); - // 검토 필요 컴포넌트 목록 - const reviewComponents = useMemo(() => { - return visibleComponents.filter(comp => { - const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; - return needsReview(currentMode, hasOverride); - }); - }, [visibleComponents, activeLayout.overrides, currentMode]); - - // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) - const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; - - // 12칸 모드가 아닐 때만 패널 표시 - // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); - const showRightPanel = showReviewPanel || showHiddenPanel; return (
@@ -668,7 +654,7 @@ export default function PopCanvas({
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} - {showRightPanel && ( + {showHiddenPanel && (
- {/* 검토 필요 패널 */} - {showReviewPanel && ( - - )} - {/* 숨김 컴포넌트 패널 */} {showHiddenPanel && ( void; -} - -function ReviewPanel({ - components, - selectedComponentId, - onSelectComponent, -}: ReviewPanelProps) { - return ( -
- {/* 헤더 */} -
- - - 검토 필요 ({components.length}개) - -
- - {/* 컴포넌트 목록 */} -
- {components.map((comp) => ( - onSelectComponent(comp.id)} - /> - ))} -
- - {/* 안내 문구 */} -
-

- 자동 배치됨. 클릭하여 확인 후 편집 가능 -

-
-
- ); -} - -// ======================================== -// 검토 필요 아이템 (ReviewPanel 내부) -// ======================================== - -interface ReviewItemProps { - component: PopComponentDefinitionV5; - isSelected: boolean; - onSelect: () => void; -} - -function ReviewItem({ - component, - isSelected, - onSelect, -}: ReviewItemProps) { - return ( -
{ - e.stopPropagation(); - onSelect(); - }} - > - - {component.label || component.id} - - - 자동 배치됨 - -
- ); -} - // ======================================== // 숨김 컴포넌트 영역 (오른쪽 패널) // ======================================== interface HiddenPanelProps { - components: PopComponentDefinitionV5[]; + components: PopComponentDefinition[]; selectedComponentId: string | null; onSelectComponent: (id: string | null) => void; onHideComponent?: (componentId: string) => void; @@ -999,7 +889,7 @@ function HiddenPanel({ // ======================================== interface HiddenItemProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; isSelected: boolean; onSelect: () => void; } diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index de131032..259ead41 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -19,21 +19,22 @@ import PopCanvas from "./PopCanvas"; import ComponentEditorPanel from "./panels/ComponentEditorPanel"; import ComponentPalette from "./panels/ComponentPalette"; import { - PopLayoutDataV5, + PopLayoutData, PopComponentType, - PopComponentDefinitionV5, + PopComponentDefinition, PopGridPosition, GridMode, GapPreset, - createEmptyPopLayoutV5, - isV5Layout, - addComponentToV5Layout, - createComponentDefinitionV5, + createEmptyLayout, + isPopLayout, + addComponentToLayout, + createComponentDefinition, GRID_BREAKPOINTS, PopModalDefinition, PopDataConnection, } from "./types/pop-layout"; -import { getAllEffectivePositions, convertV5LayoutToV6 } from "./utils/gridUtils"; +import { getAllEffectivePositions } from "./utils/gridUtils"; +import { loadLegacyLayout } from "./utils/legacyLoader"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { PopDesignerContext } from "./PopDesignerContext"; @@ -59,10 +60,10 @@ export default function PopDesigner({ // ======================================== // 레이아웃 상태 // ======================================== - const [layout, setLayout] = useState(createEmptyPopLayoutV5()); + const [layout, setLayout] = useState(createEmptyLayout()); // 히스토리 - const [history, setHistory] = useState([]); + const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); // UI 상태 @@ -84,7 +85,7 @@ export default function PopDesigner({ const [activeCanvasId, setActiveCanvasId] = useState("main"); // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회) - const selectedComponent: PopComponentDefinitionV5 | null = (() => { + const selectedComponent: PopComponentDefinition | null = (() => { if (!selectedComponentId) return null; if (activeCanvasId === "main") { return layout.components[selectedComponentId] || null; @@ -96,7 +97,7 @@ export default function PopDesigner({ // ======================================== // 히스토리 관리 // ======================================== - const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => { + const saveToHistory = useCallback((newLayout: PopLayoutData) => { setHistory((prev) => { const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(JSON.parse(JSON.stringify(newLayout))); @@ -150,11 +151,11 @@ export default function PopDesigner({ try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { + if (loadedLayout && isPopLayout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { if (!loadedLayout.settings.gapPreset) { loadedLayout.settings.gapPreset = "medium"; } - const v6Layout = convertV5LayoutToV6(loadedLayout); + const v6Layout = loadLegacyLayout(loadedLayout); setLayout(v6Layout); setHistory([v6Layout]); setHistoryIndex(0); @@ -174,7 +175,7 @@ export default function PopDesigner({ console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`); } else { // 새 화면 또는 빈 레이아웃 - const emptyLayout = createEmptyPopLayoutV5(); + const emptyLayout = createEmptyLayout(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); @@ -183,7 +184,7 @@ export default function PopDesigner({ } catch (error) { console.error("레이아웃 로드 실패:", error); toast.error("레이아웃을 불러오는데 실패했습니다"); - const emptyLayout = createEmptyPopLayoutV5(); + const emptyLayout = createEmptyLayout(); setLayout(emptyLayout); setHistory([emptyLayout]); setHistoryIndex(0); @@ -224,13 +225,13 @@ export default function PopDesigner({ if (activeCanvasId === "main") { // 메인 캔버스 - const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + const newLayout = addComponentToLayout(layout, componentId, type, position, `${type} ${idCounter}`); setLayout(newLayout); saveToHistory(newLayout); } else { // 모달 캔버스 setLayout(prev => { - const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`); + const comp = createComponentDefinition(componentId, type, position, `${type} ${idCounter}`); const newLayout = { ...prev, modals: (prev.modals || []).map(m => { @@ -249,7 +250,7 @@ export default function PopDesigner({ ); const handleUpdateComponent = useCallback( - (componentId: string, updates: Partial) => { + (componentId: string, updates: Partial) => { // 함수적 업데이트로 stale closure 방지 setLayout((prev) => { if (activeCanvasId === "main") { @@ -302,7 +303,7 @@ export default function PopDesigner({ const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const newConnection: PopDataConnection = { ...conn, id: newId }; const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -321,7 +322,7 @@ export default function PopDesigner({ (connectionId: string, conn: Omit) => { setLayout((prev) => { const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, @@ -342,7 +343,7 @@ export default function PopDesigner({ (connectionId: string) => { setLayout((prev) => { const prevConnections = prev.dataFlow?.connections || []; - const newLayout: PopLayoutDataV5 = { + const newLayout: PopLayoutData = { ...prev, dataFlow: { ...prev.dataFlow, diff --git a/frontend/components/pop/designer/index.ts b/frontend/components/pop/designer/index.ts index 37d86aec..c58ec3db 100644 --- a/frontend/components/pop/designer/index.ts +++ b/frontend/components/pop/designer/index.ts @@ -1,4 +1,4 @@ -// POP 디자이너 컴포넌트 export (v5 그리드 시스템) +// POP 디자이너 컴포넌트 export (블록 그리드 시스템) // 타입 export * from "./types"; @@ -17,11 +17,12 @@ export { default as PopRenderer } from "./renderers/PopRenderer"; // 유틸리티 export * from "./utils/gridUtils"; +export * from "./utils/legacyLoader"; // 핵심 타입 재export (편의) export type { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopComponentType, PopGridPosition, GridMode, diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ec22426d..d79883ad 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -3,7 +3,7 @@ import React from "react"; import { cn } from "@/lib/utils"; import { - PopComponentDefinitionV5, + PopComponentDefinition, PopGridPosition, GridMode, GRID_BREAKPOINTS, @@ -33,15 +33,15 @@ import ConnectionEditor from "./ConnectionEditor"; interface ComponentEditorPanelProps { /** 선택된 컴포넌트 */ - component: PopComponentDefinitionV5 | null; + component: PopComponentDefinition | null; /** 현재 모드 */ currentMode: GridMode; /** 컴포넌트 업데이트 */ - onUpdateComponent?: (updates: Partial) => void; + onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; /** 그리드에 배치된 모든 컴포넌트 */ - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; /** 컴포넌트 선택 콜백 */ onSelectComponent?: (componentId: string) => void; /** 현재 선택된 컴포넌트 ID */ @@ -249,11 +249,11 @@ export default function ComponentEditorPanel({ // ======================================== interface PositionFormProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; currentMode: GridMode; isDefaultMode: boolean; columns: number; - onUpdate?: (updates: Partial) => void; + onUpdate?: (updates: Partial) => void; } function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) { @@ -402,13 +402,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate // ======================================== interface ComponentSettingsFormProps { - component: PopComponentDefinitionV5; - onUpdate?: (updates: Partial) => void; + component: PopComponentDefinition; + onUpdate?: (updates: Partial) => void; currentMode?: GridMode; previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; modals?: PopModalDefinition[]; - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; connections?: PopDataConnection[]; } @@ -466,8 +466,8 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn // ======================================== interface VisibilityFormProps { - component: PopComponentDefinitionV5; - onUpdate?: (updates: Partial) => void; + component: PopComponentDefinition; + onUpdate?: (updates: Partial) => void; } function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 84b56935..0a64e82a 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { - PopComponentDefinitionV5, + PopComponentDefinition, PopDataConnection, } from "../types/pop-layout"; import { @@ -26,8 +26,8 @@ import { getTableColumns } from "@/lib/api/tableManagement"; // ======================================== interface ConnectionEditorProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; connections: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; @@ -102,8 +102,8 @@ export default function ConnectionEditor({ // ======================================== interface SendSectionProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; outgoing: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; @@ -197,15 +197,15 @@ function SendSection({ // ======================================== interface SimpleConnectionFormProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; initial?: PopDataConnection; onSubmit: (data: Omit) => void; onCancel?: () => void; submitLabel: string; } -function extractSubTableName(comp: PopComponentDefinitionV5): string | null { +function extractSubTableName(comp: PopComponentDefinition): string | null { const cfg = comp.config as Record | undefined; if (!cfg) return null; @@ -423,8 +423,8 @@ function SimpleConnectionForm({ // ======================================== interface ReceiveSectionProps { - component: PopComponentDefinitionV5; - allComponents: PopComponentDefinitionV5[]; + component: PopComponentDefinition; + allComponents: PopComponentDefinition[]; incoming: PopDataConnection[]; } diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 373bed9b..89b4a551 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -5,8 +5,8 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { DND_ITEM_TYPES } from "../constants"; import { - PopLayoutDataV5, - PopComponentDefinitionV5, + PopLayoutData, + PopComponentDefinition, PopGridPosition, GridMode, GRID_BREAKPOINTS, @@ -31,7 +31,7 @@ import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; interface PopRendererProps { /** v5 레이아웃 데이터 */ - layout: PopLayoutDataV5; + layout: PopLayoutData; /** 현재 뷰포트 너비 */ viewportWidth: number; /** 현재 모드 (자동 감지 또는 수동 지정) */ @@ -182,7 +182,7 @@ export default function PopRenderer({ }, [isDesignMode, showGridGuide, columns, dynamicRowCount]); // visibility 체크 - const isVisible = (comp: PopComponentDefinitionV5): boolean => { + const isVisible = (comp: PopComponentDefinition): boolean => { if (!comp.visibility) return true; const modeVisibility = comp.visibility[mode]; return modeVisibility !== false; @@ -207,7 +207,7 @@ export default function PopRenderer({ }; // 오버라이드 적용 또는 자동 재배치 - const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + const getEffectivePosition = (comp: PopComponentDefinition): PopGridPosition => { // 1순위: 오버라이드가 있으면 사용 const override = overrides?.[mode]?.positions?.[comp.id]; if (override) { @@ -225,7 +225,7 @@ export default function PopRenderer({ }; // 오버라이드 숨김 체크 - const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => { + const isHiddenByOverride = (comp: PopComponentDefinition): boolean => { return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; }; @@ -322,7 +322,7 @@ export default function PopRenderer({ // ======================================== interface DraggableComponentProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; position: PopGridPosition; positionStyle: React.CSSProperties; isSelected: boolean; @@ -423,7 +423,7 @@ function DraggableComponent({ // ======================================== interface ResizeHandlesProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; position: PopGridPosition; breakpoint: GridBreakpoint; viewportWidth: number; @@ -544,7 +544,7 @@ function ResizeHandles({ // ======================================== interface ComponentContentProps { - component: PopComponentDefinitionV5; + component: PopComponentDefinition; effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; @@ -614,7 +614,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect // ======================================== function renderActualComponent( - component: PopComponentDefinitionV5, + component: PopComponentDefinition, effectivePosition?: PopGridPosition, onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, screenId?: string, diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 44e4c1c4..7b008caf 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -1,6 +1,4 @@ -// POP 디자이너 레이아웃 타입 정의 -// v5.0: CSS Grid 기반 그리드 시스템 -// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전 +// POP 블록 그리드 레이아웃 타입 정의 // ======================================== // 공통 타입 @@ -122,7 +120,7 @@ export function getBlockColumns(viewportWidth: number): number { } /** - * 그리드 모드 (하위 호환용 - V6에서는 뷰포트 프리셋 라벨로만 사용) + * 뷰포트 프리셋 (디자이너 해상도 전환용) */ export type GridMode = | "mobile_portrait" @@ -131,7 +129,7 @@ export type GridMode = | "tablet_landscape"; /** - * 그리드 브레이크포인트 설정 (하위 호환용) + * 뷰포트 프리셋 설정 */ export interface GridBreakpoint { minWidth?: number; @@ -200,31 +198,31 @@ export function detectGridMode(viewportWidth: number): GridMode { } /** - * v5 레이아웃 (그리드 기반) + * POP 레이아웃 데이터 */ -export interface PopLayoutDataV5 { +export interface PopLayoutData { version: "pop-5.0"; // 그리드 설정 gridConfig: PopGridConfig; // 컴포넌트 정의 (ID → 정의) - components: Record; + components: Record; // 데이터 흐름 dataFlow: PopDataFlow; // 전역 설정 - settings: PopGlobalSettingsV5; + settings: PopGlobalSettings; // 메타데이터 metadata?: PopLayoutMetadata; // 모드별 오버라이드 (위치 변경용) overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; + mobile_portrait?: PopModeOverride; + mobile_landscape?: PopModeOverride; + tablet_portrait?: PopModeOverride; }; // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성) @@ -256,9 +254,9 @@ export interface PopGridPosition { } /** - * v5 컴포넌트 정의 + * POP 컴포넌트 정의 */ -export interface PopComponentDefinitionV5 { +export interface PopComponentDefinition { id: string; type: PopComponentType; label?: string; @@ -303,9 +301,9 @@ export const GAP_PRESETS: Record = { }; /** - * v5 전역 설정 + * POP 전역 설정 */ -export interface PopGlobalSettingsV5 { +export interface PopGlobalSettings { // 터치 최소 크기 (px) touchTargetMin: number; // 기본 48 @@ -317,9 +315,9 @@ export interface PopGlobalSettingsV5 { } /** - * v5 모드별 오버라이드 + * 모드별 오버라이드 (위치/숨김) */ -export interface PopModeOverrideV5 { +export interface PopModeOverride { // 컴포넌트별 위치 오버라이드 positions?: Record>; @@ -328,13 +326,13 @@ export interface PopModeOverrideV5 { } // ======================================== -// v5 유틸리티 함수 +// 레이아웃 유틸리티 함수 // ======================================== /** - * 빈 v5 레이아웃 생성 + * 빈 POP 레이아웃 생성 */ -export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ +export const createEmptyLayout = (): PopLayoutData => ({ version: "pop-5.0", gridConfig: { rowHeight: BLOCK_SIZE, @@ -351,9 +349,9 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ }); /** - * v5 레이아웃 여부 확인 + * POP 레이아웃 데이터인지 확인 */ -export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { +export const isPopLayout = (layout: any): layout is PopLayoutData => { return layout?.version === "pop-5.0"; }; @@ -382,14 +380,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record ({ +): PopComponentDefinition => ({ id, type, label, @@ -397,21 +395,21 @@ export const createComponentDefinitionV5 = ( }); /** - * v5 레이아웃에 컴포넌트 추가 + * POP 레이아웃에 컴포넌트 추가 */ -export const addComponentToV5Layout = ( - layout: PopLayoutDataV5, +export const addComponentToLayout = ( + layout: PopLayoutData, componentId: string, type: PopComponentType, position: PopGridPosition, label?: string -): PopLayoutDataV5 => { +): PopLayoutData => { const newLayout = { ...layout }; // 컴포넌트 정의 추가 newLayout.components = { ...newLayout.components, - [componentId]: createComponentDefinitionV5(componentId, type, position, label), + [componentId]: createComponentDefinition(componentId, type, position, label), }; return newLayout; @@ -486,12 +484,12 @@ export interface PopModalDefinition { /** 모달 내부 그리드 설정 */ gridConfig: PopGridConfig; /** 모달 내부 컴포넌트 */ - components: Record; + components: Record; /** 모드별 오버라이드 */ overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; + mobile_portrait?: PopModeOverride; + mobile_landscape?: PopModeOverride; + tablet_portrait?: PopModeOverride; }; /** 모달 프레임 설정 (닫기 방식) */ frameConfig?: { @@ -507,15 +505,29 @@ export interface PopModalDefinition { } // ======================================== -// 레거시 타입 별칭 (하위 호환 - 추후 제거) +// 레거시 타입 별칭 (이전 코드 호환용) // ======================================== -// 기존 코드에서 import 오류 방지용 -/** @deprecated v5에서는 PopLayoutDataV5 사용 */ -export type PopLayoutData = PopLayoutDataV5; +/** @deprecated PopLayoutData 사용 */ +export type PopLayoutDataV5 = PopLayoutData; -/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */ -export type PopComponentDefinition = PopComponentDefinitionV5; +/** @deprecated PopComponentDefinition 사용 */ +export type PopComponentDefinitionV5 = PopComponentDefinition; -/** @deprecated v5에서는 PopGridPosition 사용 */ -export type GridPosition = PopGridPosition; +/** @deprecated PopGlobalSettings 사용 */ +export type PopGlobalSettingsV5 = PopGlobalSettings; + +/** @deprecated PopModeOverride 사용 */ +export type PopModeOverrideV5 = PopModeOverride; + +/** @deprecated createEmptyLayout 사용 */ +export const createEmptyPopLayoutV5 = createEmptyLayout; + +/** @deprecated isPopLayout 사용 */ +export const isV5Layout = isPopLayout; + +/** @deprecated addComponentToLayout 사용 */ +export const addComponentToV5Layout = addComponentToLayout; + +/** @deprecated createComponentDefinition 사용 */ +export const createComponentDefinitionV5 = createComponentDefinition; diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index e5078f64..5a8895d8 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -1,53 +1,25 @@ +// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산) + import { PopGridPosition, GridMode, GRID_BREAKPOINTS, - GridBreakpoint, - GapPreset, - GAP_PRESETS, - PopLayoutDataV5, - PopComponentDefinitionV5, - BLOCK_SIZE, - BLOCK_GAP, - BLOCK_PADDING, - getBlockColumns, + PopLayoutData, } from "../types/pop-layout"; // ======================================== -// Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환) -// ======================================== - -export function getAdjustedBreakpoint( - base: GridBreakpoint, - preset: GapPreset -): GridBreakpoint { - return { ...base }; -} - -// ======================================== -// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요) +// 리플로우 (행 그룹 기반 자동 재배치) // ======================================== /** - * V6: 단일 좌표계이므로 변환 없이 원본 반환 - * @deprecated V6에서는 좌표 변환이 불필요합니다 - */ -export function convertPositionToMode( - position: PopGridPosition, - targetMode: GridMode -): PopGridPosition { - return position; -} - -/** - * V6 행 그룹 리플로우 (방식 F) + * 행 그룹 리플로우 * - * 원리: CSS Flexbox wrap과 동일. + * CSS Flexbox wrap 원리로 자동 재배치한다. * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) - * 4. 설계 너비의 50% 이상 → 전체 너비 확장 - * 5. 리플로우 후 겹침 해결 (resolveOverlaps) + * 4. 설계 너비의 50% 이상인 컴포넌트는 전체 너비 확장 + * 5. 리플로우 후 겹침 해결 */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, @@ -66,7 +38,6 @@ export function convertAndResolvePositions( const MIN_COL_SPAN = 2; const MIN_ROW_SPAN = 2; - // 1. 원본 row 기준 그룹핑 const rowGroups: Record> = {}; components.forEach(comp => { const r = comp.position.row; @@ -77,7 +48,6 @@ export function convertAndResolvePositions( const placed: Array<{ id: string; position: PopGridPosition }> = []; let outputRow = 1; - // 2. 각 행 그룹을 순서대로 처리 const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); for (const rowKey of sortedRows) { @@ -96,7 +66,6 @@ export function convertAndResolvePositions( const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); - // 현재 줄에 안 들어가면 줄바꿈 if (currentCol + scaledSpan - 1 > targetColumns) { outputRow += Math.max(1, maxRowSpanInLine); currentCol = 1; @@ -120,50 +89,18 @@ export function convertAndResolvePositions( outputRow += Math.max(1, maxRowSpanInLine); } - // 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리) return resolveOverlaps(placed, targetColumns); } -// ======================================== -// 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음) -// ======================================== - -/** - * V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음 - * 항상 false 반환 - */ -export function needsReview( - currentMode: GridMode, - hasOverride: boolean -): boolean { - return false; -} - -/** - * @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음 - */ -export function isOutOfBounds( - originalPosition: PopGridPosition, - currentMode: GridMode, - overridePosition?: PopGridPosition | null -): boolean { - return false; -} - // ======================================== // 겹침 감지 및 해결 // ======================================== -/** - * 두 위치가 겹치는지 확인 - */ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { - // 열 겹침 체크 const aColEnd = a.col + a.colSpan - 1; const bColEnd = b.col + b.colSpan - 1; const colOverlap = !(aColEnd < b.col || bColEnd < a.col); - // 행 겹침 체크 const aRowEnd = a.row + a.rowSpan - 1; const bRowEnd = b.row + b.rowSpan - 1; const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row); @@ -171,14 +108,10 @@ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { return colOverlap && rowOverlap; } -/** - * 겹침 해결 (아래로 밀기) - */ export function resolveOverlaps( positions: Array<{ id: string; position: PopGridPosition }>, columns: number ): Array<{ id: string; position: PopGridPosition }> { - // row, col 순으로 정렬 const sorted = [...positions].sort((a, b) => a.position.row - b.position.row || a.position.col - b.position.col ); @@ -188,21 +121,15 @@ export function resolveOverlaps( sorted.forEach((item) => { let { row, col, colSpan, rowSpan } = item.position; - // 열이 범위를 초과하면 조정 if (col + colSpan - 1 > columns) { colSpan = columns - col + 1; } - // 기존 배치와 겹치면 아래로 이동 let attempts = 0; - const maxAttempts = 100; - - while (attempts < maxAttempts) { + while (attempts < 100) { const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); - if (!hasOverlap) break; - row++; attempts++; } @@ -217,110 +144,9 @@ export function resolveOverlaps( } // ======================================== -// 좌표 변환 +// 자동 배치 (새 컴포넌트 드롭 시) // ======================================== -/** - * V6: 마우스 좌표 → 블록 그리드 좌표 변환 - * 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함 - */ -export function mouseToGridPosition( - mouseX: number, - mouseY: number, - canvasRect: DOMRect, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { col: number; row: number } { - const relX = mouseX - canvasRect.left - padding; - const relY = mouseY - canvasRect.top - padding; - - const cellStride = BLOCK_SIZE + gap; - - const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); - const row = Math.max(1, Math.floor(relY / cellStride) + 1); - - return { col, row }; -} - -/** - * V6: 블록 그리드 좌표 → 픽셀 좌표 변환 - */ -export function gridToPixelPosition( - col: number, - row: number, - colSpan: number, - rowSpan: number, - canvasWidth: number, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { x: number; y: number; width: number; height: number } { - const cellStride = BLOCK_SIZE + gap; - - return { - x: padding + (col - 1) * cellStride, - y: padding + (row - 1) * cellStride, - width: BLOCK_SIZE * colSpan + gap * (colSpan - 1), - height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1), - }; -} - -// ======================================== -// 위치 검증 -// ======================================== - -/** - * 위치가 그리드 범위 내에 있는지 확인 - */ -export function isValidPosition( - position: PopGridPosition, - columns: number -): boolean { - return ( - position.col >= 1 && - position.row >= 1 && - position.colSpan >= 1 && - position.rowSpan >= 1 && - position.col + position.colSpan - 1 <= columns - ); -} - -/** - * 위치를 그리드 범위 내로 조정 - */ -export function clampPosition( - position: PopGridPosition, - columns: number -): PopGridPosition { - let { col, row, colSpan, rowSpan } = position; - - // 최소값 보장 - col = Math.max(1, col); - row = Math.max(1, row); - colSpan = Math.max(1, colSpan); - rowSpan = Math.max(1, rowSpan); - - // 열 범위 초과 방지 - if (col + colSpan - 1 > columns) { - if (col > columns) { - col = 1; - } - colSpan = columns - col + 1; - } - - return { col, row, colSpan, rowSpan }; -} - -// ======================================== -// 자동 배치 -// ======================================== - -/** - * 다음 빈 위치 찾기 - */ export function findNextEmptyPosition( existingPositions: PopGridPosition[], colSpan: number, @@ -329,168 +155,94 @@ export function findNextEmptyPosition( ): PopGridPosition { let row = 1; let col = 1; - - const maxAttempts = 1000; let attempts = 0; - while (attempts < maxAttempts) { + while (attempts < 1000) { const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan }; - // 범위 체크 if (col + colSpan - 1 > columns) { col = 1; row++; continue; } - // 겹침 체크 - const hasOverlap = existingPositions.some(pos => - isOverlapping(candidatePos, pos) - ); + const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos)); + if (!hasOverlap) return candidatePos; - if (!hasOverlap) { - return candidatePos; - } - - // 다음 위치로 이동 col++; if (col + colSpan - 1 > columns) { col = 1; row++; } - attempts++; } - // 실패 시 마지막 행에 배치 return { col: 1, row: row + 1, colSpan, rowSpan }; } -/** - * 컴포넌트들을 자동으로 배치 - */ -export function autoLayoutComponents( - components: Array<{ id: string; colSpan: number; rowSpan: number }>, - columns: number -): Array<{ id: string; position: PopGridPosition }> { - const result: Array<{ id: string; position: PopGridPosition }> = []; - - let currentRow = 1; - let currentCol = 1; - - components.forEach(comp => { - // 현재 행에 공간이 부족하면 다음 행으로 - if (currentCol + comp.colSpan - 1 > columns) { - currentRow++; - currentCol = 1; - } - - result.push({ - id: comp.id, - position: { - col: currentCol, - row: currentRow, - colSpan: comp.colSpan, - rowSpan: comp.rowSpan, - }, - }); - - currentCol += comp.colSpan; - }); - - return result; -} - // ======================================== -// 유효 위치 계산 (통합 함수) +// 유효 위치 계산 // ======================================== /** - * 컴포넌트의 유효 위치를 계산합니다. + * 컴포넌트의 유효 위치를 계산한다. * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 - * - * @param componentId 컴포넌트 ID - * @param layout 전체 레이아웃 데이터 - * @param mode 현재 그리드 모드 - * @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적) */ -export function getEffectiveComponentPosition( +function getEffectiveComponentPosition( componentId: string, - layout: PopLayoutDataV5, + layout: PopLayoutData, mode: GridMode, autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> ): PopGridPosition | null { const component = layout.components[componentId]; if (!component) return null; - // 1순위: 오버라이드가 있으면 사용 const override = layout.overrides?.[mode]?.positions?.[componentId]; if (override) { return { ...component.position, ...override }; } - // 2순위: 자동 재배치된 위치 사용 if (autoResolvedPositions) { const autoResolved = autoResolvedPositions.find(p => p.id === componentId); - if (autoResolved) { - return autoResolved.position; - } + if (autoResolved) return autoResolved.position; } else { - // 자동 재배치 직접 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const resolved = convertAndResolvePositions(componentsArray, mode); const autoResolved = resolved.find(p => p.id === componentId); - if (autoResolved) { - return autoResolved.position; - } + if (autoResolved) return autoResolved.position; } - // 3순위: 원본 위치 (12칸 모드) return component.position; } /** - * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. - * 숨김 처리된 컴포넌트는 제외됩니다. - * - * v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로 - * "화면 밖" 개념이 제거되었습니다. + * 모든 컴포넌트의 유효 위치를 일괄 계산한다. + * 숨김 처리된 컴포넌트는 제외. */ export function getAllEffectivePositions( - layout: PopLayoutDataV5, + layout: PopLayoutData, mode: GridMode ): Map { const result = new Map(); - // 숨김 처리된 컴포넌트 ID 목록 const hiddenIds = layout.overrides?.[mode]?.hidden || []; - // 자동 재배치 위치 미리 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode); - // 각 컴포넌트의 유효 위치 계산 Object.keys(layout.components).forEach(componentId => { - // 숨김 처리된 컴포넌트는 제외 - if (hiddenIds.includes(componentId)) { - return; - } + if (hiddenIds.includes(componentId)) return; const position = getEffectiveComponentPosition( - componentId, - layout, - mode, - autoResolvedPositions + componentId, layout, mode, autoResolvedPositions ); - // v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음 - // 따라서 추가 필터링 불필요 if (position) { result.set(componentId, position); } @@ -498,126 +250,3 @@ export function getAllEffectivePositions( return result; } - -// ======================================== -// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환) -// ======================================== - -const V5_BASE_COLUMNS = 12; -const V5_BASE_ROW_HEIGHT = 48; -const V5_BASE_GAP = 16; -const V5_DESIGN_WIDTH = 1024; - -/** - * V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고 - * 좌표가 12칸 체계인 경우만 V5로 판정 - */ -function isV5GridConfig(layout: PopLayoutDataV5): boolean { - if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; - - const maxCol = Object.values(layout.components).reduce((max, comp) => { - const end = comp.position.col + comp.position.colSpan - 1; - return Math.max(max, end); - }, 0); - - return maxCol <= V5_BASE_COLUMNS; -} - -function convertV5PositionToV6( - pos: PopGridPosition, - v6DesignColumns: number, -): PopGridPosition { - const colRatio = v6DesignColumns / V5_BASE_COLUMNS; - const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP); - - const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); - let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); - const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); - - if (newCol + newColSpan - 1 > v6DesignColumns) { - newColSpan = v6DesignColumns - newCol + 1; - } - - return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; -} - -/** - * V5 레이아웃을 V6 블록 좌표로 런타임 변환 - * - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환 - * - 모드별 overrides 폐기 (자동 줄바꿈으로 대체) - * - DB 데이터는 건드리지 않음 (메모리에서만 변환) - */ -export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 { - // V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음) - // 좌표 변환 필요 여부와 무관하게 항상 제거 - if (!isV5GridConfig(layout)) { - return { - ...layout, - gridConfig: { - rowHeight: BLOCK_SIZE, - gap: BLOCK_GAP, - padding: BLOCK_PADDING, - }, - overrides: undefined, - }; - } - - const v6Columns = getBlockColumns(V5_DESIGN_WIDTH); - - const rowGroups: Record = {}; - Object.entries(layout.components).forEach(([id, comp]) => { - const r = comp.position.row; - if (!rowGroups[r]) rowGroups[r] = []; - rowGroups[r].push(id); - }); - - const convertedPositions: Record = {}; - Object.entries(layout.components).forEach(([id, comp]) => { - convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns); - }); - - const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); - const rowMapping: Record = {}; - let v6Row = 1; - for (const v5Row of sortedRows) { - rowMapping[v5Row] = v6Row; - const maxSpan = Math.max( - ...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan) - ); - v6Row += maxSpan; - } - - const newComponents = { ...layout.components }; - Object.entries(newComponents).forEach(([id, comp]) => { - const converted = convertedPositions[id]; - const mappedRow = rowMapping[comp.position.row] ?? converted.row; - newComponents[id] = { - ...comp, - position: { ...converted, row: mappedRow }, - }; - }); - - const newModals = layout.modals?.map(modal => { - const modalComps = { ...modal.components }; - Object.entries(modalComps).forEach(([id, comp]) => { - modalComps[id] = { - ...comp, - position: convertV5PositionToV6(comp.position, v6Columns), - }; - }); - return { - ...modal, - gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, - components: modalComps, - overrides: undefined, - }; - }); - - return { - ...layout, - gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, - components: newComponents, - overrides: undefined, - modals: newModals, - }; -} diff --git a/frontend/components/pop/designer/utils/legacyLoader.ts b/frontend/components/pop/designer/utils/legacyLoader.ts new file mode 100644 index 00000000..42cf20d7 --- /dev/null +++ b/frontend/components/pop/designer/utils/legacyLoader.ts @@ -0,0 +1,128 @@ +// 레거시 레이아웃 로더 +// DB에 저장된 V5(12칸) 좌표를 현재 블록 좌표로 변환한다. +// DB 데이터는 건드리지 않고, 로드 시 메모리에서만 변환. + +import { + PopGridPosition, + PopLayoutData, + BLOCK_SIZE, + BLOCK_GAP, + BLOCK_PADDING, + getBlockColumns, +} from "../types/pop-layout"; + +const LEGACY_COLUMNS = 12; +const LEGACY_ROW_HEIGHT = 48; +const LEGACY_GAP = 16; +const DESIGN_WIDTH = 1024; + +function isLegacyGridConfig(layout: PopLayoutData): boolean { + if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; + + const maxCol = Object.values(layout.components).reduce((max, comp) => { + const end = comp.position.col + comp.position.colSpan - 1; + return Math.max(max, end); + }, 0); + + return maxCol <= LEGACY_COLUMNS; +} + +function convertLegacyPosition( + pos: PopGridPosition, + targetColumns: number, +): PopGridPosition { + const colRatio = targetColumns / LEGACY_COLUMNS; + const rowRatio = (LEGACY_ROW_HEIGHT + LEGACY_GAP) / (BLOCK_SIZE + BLOCK_GAP); + + const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); + let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); + const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); + + if (newCol + newColSpan - 1 > targetColumns) { + newColSpan = targetColumns - newCol + 1; + } + + return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; +} + +const BLOCK_GRID_CONFIG = { + rowHeight: BLOCK_SIZE, + gap: BLOCK_GAP, + padding: BLOCK_PADDING, +}; + +/** + * DB에서 로드한 레이아웃을 현재 블록 좌표로 변환한다. + * + * - 12칸 레거시 좌표 → 블록 좌표 변환 + * - 이미 블록 좌표인 경우 → gridConfig만 보정 + * - 구 모드별 overrides는 항상 제거 (리플로우가 대체) + */ +export function loadLegacyLayout(layout: PopLayoutData): PopLayoutData { + if (!isLegacyGridConfig(layout)) { + return { + ...layout, + gridConfig: BLOCK_GRID_CONFIG, + overrides: undefined, + }; + } + + const blockColumns = getBlockColumns(DESIGN_WIDTH); + + const rowGroups: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + const r = comp.position.row; + if (!rowGroups[r]) rowGroups[r] = []; + rowGroups[r].push(id); + }); + + const convertedPositions: Record = {}; + Object.entries(layout.components).forEach(([id, comp]) => { + convertedPositions[id] = convertLegacyPosition(comp.position, blockColumns); + }); + + const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); + const rowMapping: Record = {}; + let currentRow = 1; + for (const legacyRow of sortedRows) { + rowMapping[legacyRow] = currentRow; + const maxSpan = Math.max( + ...rowGroups[legacyRow].map(id => convertedPositions[id].rowSpan) + ); + currentRow += maxSpan; + } + + const newComponents = { ...layout.components }; + Object.entries(newComponents).forEach(([id, comp]) => { + const converted = convertedPositions[id]; + const mappedRow = rowMapping[comp.position.row] ?? converted.row; + newComponents[id] = { + ...comp, + position: { ...converted, row: mappedRow }, + }; + }); + + const newModals = layout.modals?.map(modal => { + const modalComps = { ...modal.components }; + Object.entries(modalComps).forEach(([id, comp]) => { + modalComps[id] = { + ...comp, + position: convertLegacyPosition(comp.position, blockColumns), + }; + }); + return { + ...modal, + gridConfig: BLOCK_GRID_CONFIG, + components: modalComps, + overrides: undefined, + }; + }); + + return { + ...layout, + gridConfig: BLOCK_GRID_CONFIG, + components: newComponents, + overrides: undefined, + modals: newModals, + }; +} diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx index f322d4c0..9fbe0af5 100644 --- a/frontend/components/pop/viewer/PopViewerWithModals.tsx +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import PopRenderer from "../designer/renderers/PopRenderer"; -import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; +import type { PopLayoutData, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; @@ -31,7 +31,7 @@ import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; interface PopViewerWithModalsProps { /** 전체 레이아웃 (모달 정의 포함) */ - layout: PopLayoutDataV5; + layout: PopLayoutData; /** 뷰포트 너비 */ viewportWidth: number; /** 화면 ID (이벤트 버스용) */ @@ -178,7 +178,7 @@ export default function PopViewerWithModals({ const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; - const modalLayout: PopLayoutDataV5 = { + const modalLayout: PopLayoutData = { ...layout, gridConfig: definition.gridConfig, components: definition.components, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 55829efb..6fe66e41 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -49,8 +49,8 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; -import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; -import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; +import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout"; +import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; import dynamic from "next/dynamic"; const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); @@ -216,7 +216,7 @@ export function PopCardListV2Component({ // ===== 모달 열기 (POP 화면) ===== const [popModalOpen, setPopModalOpen] = useState(false); - const [popModalLayout, setPopModalLayout] = useState(null); + const [popModalLayout, setPopModalLayout] = useState(null); const [popModalScreenId, setPopModalScreenId] = useState(""); const [popModalRow, setPopModalRow] = useState(null); @@ -228,7 +228,7 @@ export function PopCardListV2Component({ return; } const popLayout = await screenApi.getLayoutPop(sid); - if (popLayout && isV5Layout(popLayout)) { + if (popLayout && isPopLayout(popLayout)) { setPopModalLayout(popLayout); setPopModalScreenId(String(sid)); setPopModalRow(row); diff --git a/frontend/lib/registry/pop-components/pop-scanner.tsx b/frontend/lib/registry/pop-components/pop-scanner.tsx index e2230170..4ce86cc8 100644 --- a/frontend/lib/registry/pop-components/pop-scanner.tsx +++ b/frontend/lib/registry/pop-components/pop-scanner.tsx @@ -19,7 +19,7 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { BarcodeScanModal } from "@/components/common/BarcodeScanModal"; import type { PopDataConnection, - PopComponentDefinitionV5, + PopComponentDefinition, } from "@/components/pop/designer/types/pop-layout"; // ======================================== @@ -99,7 +99,7 @@ function parseScanResult( function getConnectedFields( componentId?: string, connections?: PopDataConnection[], - allComponents?: PopComponentDefinitionV5[], + allComponents?: PopComponentDefinition[], ): ConnectedFieldInfo[] { if (!componentId || !connections || !allComponents) return []; @@ -308,7 +308,7 @@ const PARSE_MODE_LABELS: Record = { interface PopScannerConfigPanelProps { config: PopScannerConfig; onUpdate: (config: PopScannerConfig) => void; - allComponents?: PopComponentDefinitionV5[]; + allComponents?: PopComponentDefinition[]; connections?: PopDataConnection[]; componentId?: string; } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 8c619429..b0752146 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -72,7 +72,7 @@ const DEFAULT_CONFIG: PopSearchConfig = { interface ConfigPanelProps { config: PopSearchConfig | undefined; onUpdate: (config: PopSearchConfig) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -151,7 +151,7 @@ export function PopSearchConfigPanel({ config, onUpdate, allComponents, connecti interface StepProps { cfg: PopSearchConfig; update: (partial: Partial) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -268,7 +268,7 @@ interface FilterConnectionSectionProps { update: (partial: Partial) => void; showFieldName: boolean; fixedFilterMode?: SearchFilterMode; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } @@ -284,7 +284,7 @@ interface ConnectedComponentInfo { function getConnectedComponentInfo( componentId?: string, connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[], ): ConnectedComponentInfo { const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; if (!componentId || !connections || !allComponents) return empty; diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx index 3b0ce864..8118dfe2 100644 --- a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx @@ -22,7 +22,7 @@ import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types"; interface ConfigPanelProps { config: StatusBarConfig | undefined; onUpdate: (config: StatusBarConfig) => void; - allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } From 3bd0eff82e3d577c1b1c675dde48d188c0a6b732 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 16 Mar 2026 10:32:58 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=203=20-=20po?= =?UTF-8?q?p-work-detail=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20+=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=84=B8=EB=B6=80=EC=A7=84=ED=96=89?= =?UTF-8?q?=ED=99=94=EB=A9=B4(4502)=EC=9D=98=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B5=AC=ED=98=84:=20pop-work-detail=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B3=BC=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EC=9D=84=20=ED=86=B5=ED=95=B4,=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EB=B3=84=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8/?= =?UTF-8?q?=EA=B2=80=EC=82=AC/=EC=8B=A4=EC=A0=81=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= =?UTF-8?q?.=20[=EC=8B=A0=EA=B7=9C]=20pop-work-detail=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20(4=ED=8C=8C=EC=9D=BC)=20-=20PopWorkDetailC?= =?UTF-8?q?omponent:=20parentRow=20=E2=86=92=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=EC=B6=94=EC=B6=9C=20=E2=86=92=20process?= =?UTF-8?q?=5Fwork=5Fresult=20=EC=A1=B0=ED=9A=8C,=20=20=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94(PRE/IN/POST=20?= =?UTF-8?q?3=EB=8B=A8=EA=B3=84=20=EC=9E=91=EC=97=85=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9)=20+=20=EC=9A=B0=EC=B8=A1=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8(5=EC=A2=85:=20check/inspec?= =?UTF-8?q?t/=20=20=20input/procedure/material)=20+=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=A0=9C=EC=96=B4(start/pause/resume)=20+=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EB=93=B1=EB=A1=9D=20+=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EC=99=84=EB=A3=8C=20-=20PopWorkDetailConfig:=20sho?= =?UTF-8?q?wTimer/showQuantityInput/phaseLabels=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20-=20PopWorkDetailPreview:=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=ED=94=84=EB=A6=AC=EB=B7=B0=20-?= =?UTF-8?q?=20index.tsx:=20PopComponentRegistry=20=EB=93=B1=EB=A1=9D=20(ca?= =?UTF-8?q?tegory:=20display,=20touchOptimized)=20[=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=8B=9C=EC=8A=A4=ED=85=9C]=20Po?= =?UTF-8?q?pDesigner.tsx=20=EB=8C=80=EA=B7=9C=EB=AA=A8=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20handleMoveComponent/handleRes?= =?UTF-8?q?izeComponent/handleRequestResize:=20=20=20layout=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=B0=B8=EC=A1=B0=20=E2=86=92=20setLayout(prev=20?= =?UTF-8?q?=3D>=20...)=20=ED=95=A8=EC=88=98=ED=98=95=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A1=9C=20=EC=A0=84=ED=99=98=20=20=20+=20ac?= =?UTF-8?q?tiveCanvasId=20=EB=B6=84=EA=B8=B0:=20main=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EB=A1=9C=EC=A7=81,=20modal-*=EC=9D=B4?= =?UTF-8?q?=EB=A9=B4=20modals=20=EB=B0=B0=EC=97=B4=20=EB=82=B4=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EB=AA=A8=EB=8B=AC=20=EC=88=98=EC=A0=95=20-=20PopCa?= =?UTF-8?q?rdListV2Config:=20=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1/=EC=97=B4=EA=B8=B0=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20(usePopDesignerContext=20=EC=97=B0=EB=8F=99)=20-=20?= =?UTF-8?q?PopCardListV2Component:=20modal-*=20screenId=20=E2=86=92=20setS?= =?UTF-8?q?haredData=20+=20=5F=5Fpop=5Fmodal=5Fopen=5F=5F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20-=20PopViewerWithModals:=20parentRow=20pro?= =?UTF-8?q?p=20+=20fullscreen=20=EB=AA=A8=EB=8B=AC=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?+=20flex=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20[=EA=B8=B0?= =?UTF-8?q?=ED=83=80]=20-=20ComponentPalette:=20pop-work-detail=20?= =?UTF-8?q?=ED=8C=94=EB=A0=88=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20+=20Clipboar?= =?UTF-8?q?dCheck=20=EC=95=84=EC=9D=B4=EC=BD=98=20-=20pop-layout.ts:=20Pop?= =?UTF-8?q?ComponentType=EC=97=90=20pop-work-detail=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=ED=81=AC=EA=B8=B0=2038x26=20-=20PopRen?= =?UTF-8?q?derer:=20COMPONENT=5FTYPE=5FLABELS=EC=97=90=20pop-work-detail?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20types.ts:=20PopWorkDetailConfig=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20-=20PopCanvas.t?= =?UTF-8?q?sx:=20activeLayout.components=20=EC=B0=B8=EC=A1=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(=EB=AA=A8=EB=8B=AC=20=EC=BA=94=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=20=ED=98=B8=ED=99=98)=20DB=20=EB=B3=80=EA=B2=BD=200=EA=B1=B4.?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=200?= =?UTF-8?q?=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/pop/designer/PopCanvas.tsx | 2 +- .../components/pop/designer/PopDesigner.tsx | 345 +++++--- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/renderers/PopRenderer.tsx | 1 + .../pop/designer/types/pop-layout.ts | 3 +- .../pop/viewer/PopViewerWithModals.tsx | 34 +- frontend/lib/registry/pop-components/index.ts | 1 + .../PopCardListV2Component.tsx | 12 +- .../pop-card-list-v2/PopCardListV2Config.tsx | 57 +- .../PopWorkDetailComponent.tsx | 832 ++++++++++++++++++ .../pop-work-detail/PopWorkDetailConfig.tsx | 72 ++ .../pop-work-detail/PopWorkDetailPreview.tsx | 30 + .../pop-components/pop-work-detail/index.tsx | 39 + frontend/lib/registry/pop-components/types.ts | 11 + 14 files changed, 1299 insertions(+), 148 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx create mode 100644 frontend/lib/registry/pop-components/pop-work-detail/index.tsx diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index d12422ec..a306c1f1 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -403,7 +403,7 @@ export default function PopCanvas({ // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 const currentEffectivePos = effectivePositions.get(dragItem.componentId); - const componentData = layout.components[dragItem.componentId]; + const componentData = activeLayout.components[dragItem.componentId]; if (!currentEffectivePos && !componentData) return; diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 259ead41..8e6df1a3 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -389,97 +389,156 @@ export default function PopDesigner({ const handleMoveComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { - const component = layout.components[componentId]; - if (!component) return; - - // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - // 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 - const currentHidden = layout.overrides?.[currentMode]?.hidden || []; - const isHidden = currentHidden.includes(componentId); - const newHidden = isHidden - ? currentHidden.filter(id => id !== componentId) - : currentHidden; - - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + setLayout((prev) => { + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + + if (currentMode === "tablet_landscape") { + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - // 숨김 배열 업데이트 (빈 배열이면 undefined로) - hidden: newHidden.length > 0 ? newHidden : undefined, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } + }; + saveToHistory(newLayout); + return newLayout; + } else { + const currentHidden = prev.overrides?.[currentMode]?.hidden || []; + const newHidden = currentHidden.filter(id => id !== componentId); + const newLayout = { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + const currentHidden = m.overrides?.[currentMode]?.hidden || []; + const newHidden = currentHidden.filter(id => id !== componentId); + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + } + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); + setHasChanges(true); }, - [layout, saveToHistory, currentMode] + [saveToHistory, currentMode, activeCanvasId] ); const handleResizeComponent = useCallback( (componentId: string, newPosition: PopGridPosition) => { - const component = layout.components[componentId]; - if (!component) return; - - // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - // 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장 - // 현재는 간단히 매번 저장 (최적화 가능) - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + setLayout((prev) => { + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + + if (currentMode === "tablet_landscape") { + return { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - }, - }, - }; - setLayout(newLayout); - setHasChanges(true); - } + }; + } else { + return { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + } else { + // 모달 캔버스 + return { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + }), + }; + } + }); + setHasChanges(true); }, - [layout, currentMode] + [currentMode, activeCanvasId] ); const handleResizeEnd = useCallback( @@ -493,51 +552,87 @@ export default function PopDesigner({ // 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등) const handleRequestResize = useCallback( (componentId: string, newRowSpan: number, newColSpan?: number) => { - const component = layout.components[componentId]; - if (!component) return; + setLayout((prev) => { + const buildPosition = (comp: PopComponentDefinition) => ({ + ...comp.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }); - const newPosition = { - ...component.position, - rowSpan: newRowSpan, - ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), - }; - - // 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정 - if (currentMode === "tablet_landscape") { - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...component, - position: newPosition, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } else { - // 다른 모드인 경우: 오버라이드에 저장 - const newLayout = { - ...layout, - overrides: { - ...layout.overrides, - [currentMode]: { - ...layout.overrides?.[currentMode], - positions: { - ...layout.overrides?.[currentMode]?.positions, - [componentId]: newPosition, + if (activeCanvasId === "main") { + const component = prev.components[componentId]; + if (!component) return prev; + const newPosition = buildPosition(component); + + if (currentMode === "tablet_landscape") { + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...component, position: newPosition }, }, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); - setHasChanges(true); - } + }; + saveToHistory(newLayout); + return newLayout; + } else { + const newLayout = { + ...prev, + overrides: { + ...prev.overrides, + [currentMode]: { + ...prev.overrides?.[currentMode], + positions: { + ...prev.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const component = m.components[componentId]; + if (!component) return m; + const newPosition = buildPosition(component); + + if (currentMode === "tablet_landscape") { + return { + ...m, + components: { + ...m.components, + [componentId]: { ...component, position: newPosition }, + }, + }; + } else { + return { + ...m, + overrides: { + ...m.overrides, + [currentMode]: { + ...m.overrides?.[currentMode], + positions: { + ...m.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + } + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); + setHasChanges(true); }, - [layout, currentMode, saveToHistory] + [currentMode, saveToHistory, activeCanvasId] ); // ======================================== diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 3817b54d..ddedc7d0 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: UserCircle, description: "사용자 프로필 / PC 전환 / 로그아웃", }, + { + type: "pop-work-detail", + label: "작업 상세", + icon: ClipboardCheck, + description: "공정별 체크리스트/검사/실적 상세 작업 화면", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 89b4a551..3af031b4 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -84,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-field": "입력", "pop-scanner": "스캐너", "pop-profile": "프로필", + "pop-work-detail": "작업 상세", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 7b008caf..f859cf5d 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -7,7 +7,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail"; /** * 데이터 흐름 정의 @@ -377,6 +377,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record; } /** 열린 모달 상태 */ interface OpenModal { definition: PopModalDefinition; returnTo?: string; + fullscreen?: boolean; } // ======================================== @@ -61,10 +64,17 @@ export default function PopViewerWithModals({ currentMode, overrideGap, overridePadding, + parentRow, }: PopViewerWithModalsProps) { const router = useRouter(); const [modalStack, setModalStack] = useState([]); - const { subscribe, publish } = usePopEvent(screenId); + const { subscribe, publish, setSharedData } = usePopEvent(screenId); + + useEffect(() => { + if (parentRow) { + setSharedData("parentRow", parentRow); + } + }, [parentRow, setSharedData]); // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 const stableConnections = useMemo( @@ -96,6 +106,7 @@ export default function PopViewerWithModals({ title?: string; mode?: string; returnTo?: string; + fullscreen?: boolean; }; if (data?.modalId) { @@ -104,6 +115,7 @@ export default function PopViewerWithModals({ setModalStack(prev => [...prev, { definition: modalDef, returnTo: data.returnTo, + fullscreen: data.fullscreen, }]); } } @@ -173,7 +185,7 @@ export default function PopViewerWithModals({ {/* 모달 스택 렌더링 */} {modalStack.map((modal, index) => { - const { definition } = modal; + const { definition, fullscreen } = modal; const isTopModal = index === modalStack.length - 1; const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; @@ -185,10 +197,15 @@ export default function PopViewerWithModals({ overrides: definition.overrides, }; - const detectedMode = currentMode || detectGridMode(viewportWidth); - const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); - const isFull = modalWidth >= viewportWidth; - const rendererWidth = isFull ? viewportWidth : modalWidth - 32; + const isFull = fullscreen || (() => { + const detectedMode = currentMode || detectGridMode(viewportWidth); + const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); + return modalWidth >= viewportWidth; + })(); + const rendererWidth = isFull + ? viewportWidth + : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32; + const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth); return ( { - // 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지) if (!isTopModal || !closeOnOverlay) e.preventDefault(); }} onEscapeKeyDown={(e) => { if (!isTopModal || !closeOnEsc) e.preventDefault(); }} > - + {definition.title} diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 351d6700..28e6a746 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -26,3 +26,4 @@ import "./pop-status-bar"; import "./pop-field"; import "./pop-scanner"; import "./pop-profile"; +import "./pop-work-detail"; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 6d04d91c..8c3c6447 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -139,7 +139,7 @@ export function PopCardListV2Component({ currentColSpan, onRequestResize, }: PopCardListV2ComponentProps) { - const { subscribe, publish } = usePopEvent(screenId || "default"); + const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default"); const router = useRouter(); const { userId: currentUserId } = useAuth(); @@ -250,6 +250,13 @@ export function PopCardListV2Component({ const [popModalRow, setPopModalRow] = useState(null); const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => { + // 내부 모달 캔버스 (디자이너에서 생성한 modal-*)인 경우 이벤트 발행 + if (screenIdStr.startsWith("modal-")) { + setSharedData("parentRow", row); + publish("__pop_modal_open__", { modalId: screenIdStr, fullscreen: true }); + return; + } + // 외부 POP 화면 ID인 경우 기존 fetch 방식 try { const sid = parseInt(screenIdStr, 10); if (isNaN(sid)) { @@ -268,7 +275,7 @@ export function PopCardListV2Component({ } catch { toast.error("POP 화면을 불러오는데 실패했습니다."); } - }, []); + }, [publish, setSharedData]); const handleCardSelect = useCallback((row: RowData) => { @@ -1176,6 +1183,7 @@ export function PopCardListV2Component({ viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024} screenId={popModalScreenId} currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)} + parentRow={popModalRow ?? undefined} /> )}
diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 04ba0622..9fc1339a 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "rea import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -2940,6 +2941,7 @@ function TabActions({ onUpdate: (partial: Partial) => void; columns: ColumnInfo[]; }) { + const designerCtx = usePopDesignerContext(); const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; @@ -3013,15 +3015,52 @@ function TabActions({
{clickAction === "modal-open" && (
-
- POP 화면 ID - onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} - placeholder="화면 ID (예: 4481)" - className="h-7 flex-1 text-[10px]" - /> -
+ {/* 모달 캔버스 (디자이너 모드) */} + {designerCtx && ( +
+ {modalConfig.screenId?.startsWith("modal-") ? ( + + ) : ( + + )} +
+ )} + {/* 뷰어 모드 또는 직접 입력 폴백 */} + {!designerCtx && ( +
+ 모달 ID + onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} + placeholder="모달 ID" + className="h-7 flex-1 text-[10px]" + /> +
+ )}
모달 제목 ; + +interface WorkResultRow { + id: string; + work_order_process_id: string; + source_work_item_id: string; + source_detail_id: string; + work_phase: string; + item_title: string; + item_sort_order: string; + detail_type: string; + detail_label: string; + detail_sort_order: string; + spec_value: string | null; + lower_limit: string | null; + upper_limit: string | null; + input_type: string | null; + result_value: string | null; + status: string; + is_passed: string | null; + recorded_by: string | null; + recorded_at: string | null; +} + +interface WorkGroup { + phase: string; + title: string; + itemId: string; + sortOrder: number; + total: number; + completed: number; +} + +type WorkPhase = "PRE" | "IN" | "POST"; +const PHASE_ORDER: Record = { PRE: 1, IN: 2, POST: 3 }; + +interface ProcessTimerData { + started_at: string | null; + paused_at: string | null; + total_paused_time: string | null; + status: string; + good_qty: string | null; + defect_qty: string | null; +} + +// ======================================== +// Props +// ======================================== + +interface PopWorkDetailComponentProps { + config?: PopWorkDetailConfig; + screenId?: string; + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export function PopWorkDetailComponent({ + config, + screenId, + componentId, +}: PopWorkDetailComponentProps) { + const { getSharedData } = usePopEvent(screenId || "default"); + const { user } = useAuth(); + + const cfg: PopWorkDetailConfig = { + showTimer: config?.showTimer ?? true, + showQuantityInput: config?.showQuantityInput ?? true, + phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + }; + + // parentRow에서 현재 공정 정보 추출 + const parentRow = getSharedData("parentRow"); + const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; + const currentProcess = processFlow?.find((p) => p.isCurrent); + const workOrderProcessId = currentProcess?.processId + ? String(currentProcess.processId) + : undefined; + const processName = currentProcess?.processName ?? "공정 상세"; + + // ======================================== + // 상태 + // ======================================== + + const [allResults, setAllResults] = useState([]); + const [processData, setProcessData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [tick, setTick] = useState(Date.now()); + const [savingIds, setSavingIds] = useState>(new Set()); + + // 수량 입력 로컬 상태 + const [goodQty, setGoodQty] = useState(""); + const [defectQty, setDefectQty] = useState(""); + + // ======================================== + // D-FE1: 데이터 로드 + // ======================================== + + const fetchData = useCallback(async () => { + if (!workOrderProcessId) { + setLoading(false); + return; + } + + try { + setLoading(true); + + const [resultRes, processRes] = await Promise.all([ + dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: workOrderProcessId }, + }), + dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: workOrderProcessId }, + }), + ]); + + setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); + + const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; + setProcessData(proc); + if (proc) { + setGoodQty(proc.good_qty ?? ""); + setDefectQty(proc.defect_qty ?? ""); + } + } catch { + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [workOrderProcessId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ======================================== + // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 + // ======================================== + + const groups = useMemo(() => { + const map = new Map(); + for (const row of allResults) { + const key = row.source_work_item_id; + if (!map.has(key)) { + map.set(key, { + phase: row.work_phase, + title: row.item_title, + itemId: key, + sortOrder: parseInt(row.item_sort_order || "0", 10), + total: 0, + completed: 0, + }); + } + const g = map.get(key)!; + g.total++; + if (row.status === "completed") g.completed++; + } + return Array.from(map.values()).sort( + (a, b) => + (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || + a.sortOrder - b.sortOrder + ); + }, [allResults]); + + // phase별로 그룹핑 + const groupsByPhase = useMemo(() => { + const result: Record = {}; + for (const g of groups) { + if (!result[g.phase]) result[g.phase] = []; + result[g.phase].push(g); + } + return result; + }, [groups]); + + // 첫 그룹 자동 선택 + useEffect(() => { + if (groups.length > 0 && !selectedGroupId) { + setSelectedGroupId(groups[0].itemId); + } + }, [groups, selectedGroupId]); + + // ======================================== + // D-FE3: 우측 체크리스트 + // ======================================== + + const currentItems = useMemo( + () => + allResults + .filter((r) => r.source_work_item_id === selectedGroupId) + .sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)), + [allResults, selectedGroupId] + ); + + const saveResultValue = useCallback( + async ( + rowId: string, + resultValue: string, + isPassed: string | null, + newStatus: string + ) => { + setSavingIds((prev) => new Set(prev).add(rowId)); + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] }, + { type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] }, + ...(isPassed !== null + ? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }] + : []), + { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] }, + { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] }, + ], + data: { items: [{ id: rowId }], fieldValues: {} }, + }); + + setAllResults((prev) => + prev.map((r) => + r.id === rowId + ? { + ...r, + result_value: resultValue, + status: newStatus, + is_passed: isPassed, + recorded_by: user?.userId ?? null, + recorded_at: new Date().toISOString(), + } + : r + ) + ); + } catch { + toast.error("저장에 실패했습니다."); + } finally { + setSavingIds((prev) => { + const next = new Set(prev); + next.delete(rowId); + return next; + }); + } + }, + [user?.userId] + ); + + // ======================================== + // D-FE4: 타이머 + // ======================================== + + useEffect(() => { + if (!cfg.showTimer || !processData?.started_at) return; + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, [cfg.showTimer, processData?.started_at]); + + const elapsedMs = useMemo(() => { + if (!processData?.started_at) return 0; + const now = tick; + const totalMs = now - new Date(processData.started_at).getTime(); + const pausedSec = parseInt(processData.total_paused_time || "0", 10); + const currentPauseMs = processData.paused_at + ? now - new Date(processData.paused_at).getTime() + : 0; + return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs); + }, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]); + + const formattedTime = useMemo(() => { + const totalSec = Math.floor(elapsedMs / 1000); + const h = String(Math.floor(totalSec / 3600)).padStart(2, "0"); + const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0"); + const s = String(totalSec % 60).padStart(2, "0"); + return `${h}:${m}:${s}`; + }, [elapsedMs]); + + const isPaused = !!processData?.paused_at; + const isStarted = !!processData?.started_at; + + const handleTimerAction = useCallback( + async (action: "start" | "pause" | "resume") => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/api/pop/production/timer", { + workOrderProcessId, + action, + }); + // 타이머 상태 새로고침 + const res = await dataApi.getTableData("work_order_process", { + size: 1, + filters: { id: workOrderProcessId }, + }); + const proc = (res.data?.[0] ?? null) as ProcessTimerData | null; + if (proc) setProcessData(proc); + } catch { + toast.error("타이머 제어에 실패했습니다."); + } + }, + [workOrderProcessId] + ); + + // ======================================== + // D-FE5: 수량 등록 + 완료 + // ======================================== + + const handleQuantityRegister = useCallback(async () => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] }, + { type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] }, + ], + data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, + }); + toast.success("수량이 등록되었습니다."); + } catch { + toast.error("수량 등록에 실패했습니다."); + } + }, [workOrderProcessId, goodQty, defectQty]); + + const handleProcessComplete = useCallback(async () => { + if (!workOrderProcessId) return; + try { + await apiClient.post("/pop/execute-action", { + tasks: [ + { type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] }, + { type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] }, + ], + data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, + }); + toast.success("공정이 완료되었습니다."); + setProcessData((prev) => + prev ? { ...prev, status: "completed" } : prev + ); + } catch { + toast.error("공정 완료 처리에 실패했습니다."); + } + }, [workOrderProcessId]); + + // ======================================== + // 안전 장치 + // ======================================== + + if (!parentRow) { + return ( +
+ + 카드를 선택해주세요 +
+ ); + } + + if (!workOrderProcessId) { + return ( +
+ + 공정 정보를 찾을 수 없습니다 +
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (allResults.length === 0) { + return ( +
+ + 작업기준이 등록되지 않았습니다 +
+ ); + } + + const isProcessCompleted = processData?.status === "completed"; + + // ======================================== + // 렌더링 + // ======================================== + + return ( +
+ {/* 헤더 */} +
+

{processName}

+ {cfg.showTimer && ( +
+ + + {formattedTime} + + {!isProcessCompleted && ( + <> + {!isStarted && ( + + )} + {isStarted && !isPaused && ( + + )} + {isStarted && isPaused && ( + + )} + + )} +
+ )} +
+ + {/* 본문: 좌측 사이드바 + 우측 체크리스트 */} +
+ {/* 좌측 사이드바 */} +
+ {(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => { + const phaseGroups = groupsByPhase[phase]; + if (!phaseGroups || phaseGroups.length === 0) return null; + return ( +
+
+ {cfg.phaseLabels[phase] ?? phase} +
+ {phaseGroups.map((g) => ( + + ))} +
+ ); + })} +
+ + {/* 우측 체크리스트 */} +
+ {selectedGroupId && ( +
+ {currentItems.map((item) => ( + + ))} +
+ )} +
+
+ + {/* 하단: 수량 입력 + 완료 */} + {cfg.showQuantityInput && ( +
+ +
+ 양품 + setGoodQty(e.target.value)} + disabled={isProcessCompleted} + /> +
+
+ 불량 + setDefectQty(e.target.value)} + disabled={isProcessCompleted} + /> +
+ +
+ {!isProcessCompleted && ( + + )} + {isProcessCompleted && ( + + 완료됨 + + )} +
+ )} +
+ ); +} + +// ======================================== +// 체크리스트 개별 항목 +// ======================================== + +interface ChecklistItemProps { + item: WorkResultRow; + saving: boolean; + disabled: boolean; + onSave: ( + rowId: string, + resultValue: string, + isPassed: string | null, + newStatus: string + ) => void; +} + +function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) { + const isSaving = saving; + const isDisabled = disabled || isSaving; + + switch (item.detail_type) { + case "check": + return ; + case "inspect": + return ; + case "input": + return ; + case "procedure": + return ; + case "material": + return ; + default: + return ( +
+ 알 수 없는 유형: {item.detail_type} +
+ ); + } +} + +// ===== check: 체크박스 ===== + +function CheckItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const checked = item.result_value === "Y"; + return ( +
+ { + const val = v ? "Y" : "N"; + onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); + }} + /> + {item.detail_label} + {saving && } + {item.status === "completed" && !saving && ( + + 완료 + + )} +
+ ); +} + +// ===== inspect: 측정값 입력 (범위 판정) ===== + +function InspectItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const lower = parseFloat(item.lower_limit ?? ""); + const upper = parseFloat(item.upper_limit ?? ""); + const hasRange = !isNaN(lower) && !isNaN(upper); + + const handleBlur = () => { + if (!inputVal || disabled) return; + const numVal = parseFloat(inputVal); + let passed: string | null = null; + if (hasRange) { + passed = numVal >= lower && numVal <= upper ? "Y" : "N"; + } + onSave(item.id, inputVal, passed, "completed"); + }; + + const isPassed = item.is_passed; + + return ( +
+
+ {item.detail_label} + {hasRange && ( + + 기준: {item.lower_limit} ~ {item.upper_limit} + {item.spec_value ? ` (표준: ${item.spec_value})` : ""} + + )} +
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="측정값 입력" + /> + {saving && } + {isPassed === "Y" && !saving && ( + 합격 + )} + {isPassed === "N" && !saving && ( + 불합격 + )} +
+
+ ); +} + +// ===== input: 자유 입력 ===== + +function InputItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const inputType = item.input_type === "number" ? "number" : "text"; + + const handleBlur = () => { + if (!inputVal || disabled) return; + onSave(item.id, inputVal, null, "completed"); + }; + + return ( +
+
{item.detail_label}
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="값 입력" + /> + {saving && } +
+
+ ); +} + +// ===== procedure: 절차 확인 (읽기 전용 + 체크) ===== + +function ProcedureItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const checked = item.result_value === "Y"; + return ( +
+
+ {item.spec_value || item.detail_label} +
+
+ { + onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); + }} + /> + 확인 + {saving && } +
+
+ ); +} + +// ===== material: 자재/LOT 입력 ===== + +function MaterialItem({ + item, + disabled, + saving, + onSave, +}: { + item: WorkResultRow; + disabled: boolean; + saving: boolean; + onSave: ChecklistItemProps["onSave"]; +}) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + + const handleBlur = () => { + if (!inputVal || disabled) return; + onSave(item.id, inputVal, null, "completed"); + }; + + return ( +
+
{item.detail_label}
+
+ setInputVal(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + placeholder="LOT 번호 입력" + /> + {saving && } +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx new file mode 100644 index 00000000..7b75cf78 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import type { PopWorkDetailConfig } from "../types"; + +interface PopWorkDetailConfigPanelProps { + config?: PopWorkDetailConfig; + onChange?: (config: PopWorkDetailConfig) => void; +} + +const DEFAULT_PHASE_LABELS: Record = { + PRE: "작업 전", + IN: "작업 중", + POST: "작업 후", +}; + +export function PopWorkDetailConfigPanel({ + config, + onChange, +}: PopWorkDetailConfigPanelProps) { + const cfg: PopWorkDetailConfig = { + showTimer: config?.showTimer ?? true, + showQuantityInput: config?.showQuantityInput ?? true, + phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS }, + }; + + const update = (partial: Partial) => { + onChange?.({ ...cfg, ...partial }); + }; + + return ( +
+
+ + update({ showTimer: v })} + /> +
+ +
+ + update({ showQuantityInput: v })} + /> +
+ +
+ + {(["PRE", "IN", "POST"] as const).map((phase) => ( +
+ + {phase} + + + update({ + phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value }, + }) + } + /> +
+ ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx new file mode 100644 index 00000000..d5eed206 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailPreview.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ClipboardCheck } from "lucide-react"; +import type { PopWorkDetailConfig } from "../types"; + +interface PopWorkDetailPreviewProps { + config?: PopWorkDetailConfig; +} + +export function PopWorkDetailPreviewComponent({ config }: PopWorkDetailPreviewProps) { + const labels = config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }; + return ( +
+ + + 작업 상세 + +
+ {Object.values(labels).map((l) => ( + + {l} + + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx new file mode 100644 index 00000000..941db8d4 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopWorkDetailComponent } from "./PopWorkDetailComponent"; +import { PopWorkDetailConfigPanel } from "./PopWorkDetailConfig"; +import { PopWorkDetailPreviewComponent } from "./PopWorkDetailPreview"; +import type { PopWorkDetailConfig } from "../types"; + +const defaultConfig: PopWorkDetailConfig = { + showTimer: true, + showQuantityInput: true, + phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, +}; + +PopComponentRegistry.registerComponent({ + id: "pop-work-detail", + name: "작업 상세", + description: "공정별 체크리스트/검사/실적 상세 작업 화면", + category: "display", + icon: "ClipboardCheck", + component: PopWorkDetailComponent, + configPanel: PopWorkDetailConfigPanel, + preview: PopWorkDetailPreviewComponent, + defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { + key: "process_completed", + label: "공정 완료", + type: "event", + category: "event", + description: "공정 작업 전체 완료 이벤트", + }, + ], + receivable: [], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 3680578e..a32a53cd 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1000,3 +1000,14 @@ export const VIRTUAL_SUB_STATUS = "__subStatus__" as const; export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const; export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const; export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; + + +// ============================================= +// pop-work-detail 전용 타입 +// ============================================= + +export interface PopWorkDetailConfig { + showTimer: boolean; + showQuantityInput: boolean; + phaseLabels: Record; +}