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; +}