From a2c532c7c7676cfacc8a1c7de1fcbbcb9ef68100 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 12 Mar 2026 18:26:47 +0900 Subject: [PATCH 1/4] =?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/4] =?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/4] =?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 3bd0eff82e3d577c1b1c675dde48d188c0a6b732 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 16 Mar 2026 10:32:58 +0900 Subject: [PATCH 4/4] =?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; +}