From 3bd0eff82e3d577c1b1c675dde48d188c0a6b732 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 16 Mar 2026 10:32:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=203=20-=20pop-wo?= =?UTF-8?q?rk-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; +}