diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 205c6a59..4c28dfed 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -872,7 +872,7 @@ export const saveResult = async ( [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { - prevGoodQty = prevProcess.rows[0].total_good; + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } @@ -887,8 +887,8 @@ export const saveResult = async ( [wo_id, seq_no, companyCode] ); - const totalInput = siblingCheck.rows[0].total_input; - const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10); + const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; + const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; const remainingAcceptable = prevGoodQty - totalInput; // 모든 분할 행 완료 + 잔여 접수가능 0 -> 원본(마스터)도 completed @@ -1111,7 +1111,7 @@ export const confirmResult = async ( [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { - prevGoodQty = prevProcess.rows[0].total_good; + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } @@ -1125,8 +1125,8 @@ export const confirmResult = async ( [wo_id, seq_no, companyCode] ); - const totalInput = siblingCheck.rows[0].total_input; - const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10); + const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; + const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; const remainingAcceptable = prevGoodQty - totalInput; if (incompleteCount === 0 && remainingAcceptable <= 0) { @@ -1183,7 +1183,8 @@ export const getResultHistory = async ( try { const companyCode = req.user!.companyCode; - const { work_order_process_id } = req.query; + const rawWopId = req.query.work_order_process_id; + const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; if (!work_order_process_id) { return res.status(400).json({ @@ -1270,7 +1271,8 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) const pool = getPool(); try { const companyCode = req.user!.companyCode; - const { work_order_process_id } = req.query; + const rawWopId = req.query.work_order_process_id; + const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; if (!work_order_process_id) { return res.status(400).json({ @@ -1304,7 +1306,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) AND parent_process_id IS NOT NULL`, [wo_id, seq_no, companyCode] ); - const myInputQty = totalAccepted.rows[0].total_input; + const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; // 앞공정 양품+특채 합산 let prevGoodQty = instrQty; @@ -1317,7 +1319,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { - prevGoodQty = prevProcess.rows[0].total_good; + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } @@ -1427,7 +1429,7 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => [row.wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { - prevGoodQty = prevProcess.rows[0].total_good; + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } @@ -1517,7 +1519,7 @@ export const cancelAccept = async ( const current = await pool.query( `SELECT id, status, input_qty, total_production_qty, result_status, - parent_process_id, wo_id, seq_no + parent_process_id, wo_id, seq_no, process_name FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index dee674f1..d0ef53f4 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -7,7 +7,6 @@ import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "luc import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; @@ -62,7 +61,8 @@ function PopScreenViewPage() { const params = useParams(); const searchParams = useSearchParams(); const router = useRouter(); - const screenId = parseInt(params.screenId as string); + const screenId = parseInt(params.screenId as string, 10); + const isValidScreenId = !isNaN(screenId) && screenId > 0; const isPreviewMode = searchParams.get("preview") === "true"; @@ -126,22 +126,15 @@ function PopScreenViewPage() { if (popLayout && isPopLayout(popLayout)) { const v6Layout = loadLegacyLayout(popLayout); setLayout(v6Layout); - const componentCount = Object.keys(popLayout.components).length; - console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { - // 다른 버전 레이아웃은 빈 v5로 처리 - console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); setLayout(createEmptyLayout()); } else { - console.log("[POP] 레이아웃 없음"); setLayout(createEmptyLayout()); } - } catch (layoutError) { - console.warn("[POP] 레이아웃 로드 실패:", layoutError); + } catch { setLayout(createEmptyLayout()); } } catch (error) { - console.error("[POP] 화면 로드 실패:", error); setError("화면을 불러오는데 실패했습니다."); showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." }); } finally { @@ -149,10 +142,13 @@ function PopScreenViewPage() { } }; - if (screenId) { + if (isValidScreenId) { loadScreen(); + } else if (params.screenId) { + setError("유효하지 않은 화면 ID입니다."); + setLoading(false); } - }, [screenId]); + }, [screenId, isValidScreenId]); // 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등) const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => { 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 8a4e37bc..a6c26838 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 @@ -8,7 +8,6 @@ */ import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; -import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Check, X, } from "lucide-react"; @@ -55,6 +54,10 @@ import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout"; import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; import dynamic from "next/dynamic"; const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); +const LazyPopWorkDetail = dynamic( + () => import("../pop-work-detail/PopWorkDetailComponent").then((m) => ({ default: m.PopWorkDetailComponent })), + { ssr: false }, +); type RowData = Record; @@ -140,7 +143,6 @@ export function PopCardListV2Component({ onRequestResize, }: PopCardListV2ComponentProps) { const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default"); - const router = useRouter(); const { userId: currentUserId } = useAuth(); const isCartListMode = config?.cartListMode?.enabled === true; @@ -243,6 +245,10 @@ export function PopCardListV2Component({ const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); const [selectProcessing, setSelectProcessing] = useState(false); + // ===== 내장 작업 상세 모달 ===== + const [workDetailOpen, setWorkDetailOpen] = useState(false); + const [workDetailRow, setWorkDetailRow] = useState(null); + // ===== 모달 열기 (POP 화면) ===== const [popModalOpen, setPopModalOpen] = useState(false); const [popModalLayout, setPopModalLayout] = useState(null); @@ -280,6 +286,14 @@ export function PopCardListV2Component({ const handleCardSelect = useCallback((row: RowData) => { if (row.__isAcceptClone) return; + if (effectiveConfig?.cardClickAction === "built-in-work-detail") { + const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined; + if (subStatus && subStatus !== "in_progress") return; + setWorkDetailRow(row); + setWorkDetailOpen(true); + return; + } + if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) { const mc = effectiveConfig.cardClickModalConfig; @@ -693,6 +707,7 @@ export function PopCardListV2Component({ const scrollAreaRef = useRef(null); const ownerSortColumn = config?.ownerSortColumn; + const ownerFilterMode = config?.ownerFilterMode || "priority"; const displayCards = useMemo(() => { let source = filteredRows; @@ -707,13 +722,13 @@ export function PopCardListV2Component({ others.push(row); } } - source = [...mine, ...others]; + source = ownerFilterMode === "only" ? mine : [...mine, ...others]; } if (!isExpanded) return source.slice(0, visibleCardCount); const start = (currentPage - 1) * expandedCardsPerPage; return source.slice(start, start + expandedCardsPerPage); - }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, ownerFilterMode, currentUserId]); const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; const needsPagination = isExpanded && totalPages > 1; @@ -1091,15 +1106,15 @@ export function PopCardListV2Component({ useEffect(() => { if (isCartListMode) { - const cartListMode = config!.cartListMode!; - if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; } + const cartListMode = config?.cartListMode; + if (!cartListMode?.sourceScreenId) { setLoading(false); setRows([]); return; } const fetchCartData = async () => { setLoading(true); setError(null); try { try { - const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId!); + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); const componentsMap = layoutJson?.components || {}; const componentList = Object.values(componentsMap) as any[]; const matched = cartListMode.sourceComponentId @@ -1375,6 +1390,27 @@ export function PopCardListV2Component({ )} + {/* 내장 작업 상세 모달 (풀스크린) */} + { + setWorkDetailOpen(open); + if (!open) setWorkDetailRow(null); + }}> + + + 작업 상세 + +
+ {workDetailRow && ( + + )} +
+
+
+ {/* POP 화면 모달 (풀스크린) */} { setPopModalOpen(open); 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 9fc1339a..a86c2e50 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 @@ -48,7 +48,6 @@ import type { CardSortConfig, V2OverflowConfig, V2CardClickAction, - V2CardClickModalConfig, ActionButtonUpdate, TimelineDataSource, StatusValueMapping, @@ -117,37 +116,35 @@ const V2_DEFAULT_CONFIG: PopCardListV2Config = { cardGap: 8, scrollDirection: "vertical", overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, - cardClickAction: "none", + cardClickAction: "modal-open", }; // ===== 탭 정의 ===== -type V2ConfigTab = "data" | "design" | "actions"; +type V2ConfigTab = "info" | "actions"; const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [ - { id: "data", label: "데이터" }, - { id: "design", label: "카드 디자인" }, + { id: "info", label: "정보" }, { id: "actions", label: "동작" }, ]; // ===== 셀 타입 라벨 ===== -const V2_CELL_TYPE_LABELS: Record = { +const V2_CELL_TYPE_LABELS: Record = { text: { label: "텍스트", group: "기본" }, field: { label: "필드 (라벨+값)", group: "기본" }, image: { label: "이미지", group: "기본" }, badge: { label: "배지", group: "기본" }, - button: { label: "버튼", group: "동작" }, "number-input": { label: "숫자 입력", group: "입력" }, - "cart-button": { label: "담기 버튼", group: "입력" }, - "package-summary": { label: "포장 요약", group: "요약" }, "status-badge": { label: "상태 배지", group: "표시" }, timeline: { label: "타임라인", group: "표시" }, "footer-status": { label: "하단 상태", group: "표시" }, "action-buttons": { label: "액션 버튼", group: "동작" }, + "process-qty-summary": { label: "공정 수량 요약", group: "표시" }, + "mes-process-card": { label: "MES 공정 카드", group: "표시" }, }; -const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const; +const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작"] as const; // ===== 그리드 유틸 ===== @@ -197,10 +194,8 @@ const shortType = (t: string): string => { // ===== 메인 컴포넌트 ===== export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) { - const [tab, setTab] = useState("data"); - const [tables, setTables] = useState([]); + const [tab, setTab] = useState("info"); const [columns, setColumns] = useState([]); - const [selectedColumns, setSelectedColumns] = useState([]); const cfg: PopCardListV2Config = { ...V2_DEFAULT_CONFIG, @@ -215,28 +210,12 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) }; useEffect(() => { - fetchTableList() - .then(setTables) - .catch(() => setTables([])); - }, []); - - useEffect(() => { - if (!cfg.dataSource.tableName) { - setColumns([]); - return; - } + if (!cfg.dataSource.tableName) { setColumns([]); return; } fetchTableColumns(cfg.dataSource.tableName) .then(setColumns) .catch(() => setColumns([])); }, [cfg.dataSource.tableName]); - useEffect(() => { - if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { - setSelectedColumns(cfg.selectedColumns); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cfg.dataSource.tableName]); - return (
{/* 탭 바 */} @@ -257,56 +236,142 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) ))}
- {/* 탭 컨텐츠 */} - {tab === "data" && ( - { - setSelectedColumns([]); - update({ - dataSource: { ...cfg.dataSource, tableName }, - selectedColumns: [], - cardGrid: { ...cfg.cardGrid, cells: [] }, - }); - }} - onColumnsChange={(cols) => { - setSelectedColumns(cols); - update({ selectedColumns: cols }); - }} - onDataSourceChange={(dataSource) => update({ dataSource })} - onSortChange={(sort) => - update({ dataSource: { ...cfg.dataSource, sort } }) - } - /> - )} - - {tab === "design" && ( - update({ cardGrid })} - onGridColumnsChange={(gridColumns) => update({ gridColumns })} - onCardGapChange={(cardGap) => update({ cardGap })} - /> - )} + {tab === "info" && } {tab === "actions" && ( - + )} ); } -// ===== 탭 1: 데이터 ===== +// ===== 탭 1: 정보 (연결 흐름 요약) ===== + +function TabInfo({ + cfg, + onUpdate, +}: { + cfg: PopCardListV2Config; + onUpdate: (partial: Partial) => void; +}) { + const ds = cfg.dataSource; + const joins = ds.joins || []; + const clickAction = cfg.cardClickAction || "none"; + const cellTypes = cfg.cardGrid.cells.map((c) => c.type); + const hasTimeline = cellTypes.includes("timeline"); + const hasActionButtons = cellTypes.includes("action-buttons"); + const currentCols = cfg.gridColumns || 3; + + return ( +
+ {/* 카드 열 수 (편집 가능) */} +
+ +
+ {[1, 2, 3, 4].map((n) => ( + + ))} +
+
+ + {/* 데이터 소스 */} +
+ +
+ {ds.tableName ? ( + <> +
{ds.tableName}
+ {joins.map((j, i) => ( +
+ + + {j.targetTable} + ({j.joinType}) +
+ ))} + {ds.sort?.[0] && ( +
+ 정렬: {ds.sort[0].column} ({ds.sort[0].direction === "asc" ? "오름차순" : "내림차순"}) +
+ )} + + ) : ( + 테이블 미설정 + )} +
+
+ + {/* 카드 구성 */} +
+ +
+
{cfg.cardGrid.rows}행 x {cfg.cardGrid.cols}열 그리드, 셀 {cfg.cardGrid.cells.length}개
+
+ {hasTimeline && ( + 타임라인 + )} + {hasActionButtons && ( + 액션 버튼 + )} + {cellTypes.includes("status-badge") && ( + 상태 배지 + )} + {cellTypes.includes("number-input") && ( + 수량 입력 + )} + {cellTypes.filter((t) => t === "field" || t === "text").length > 0 && ( + + 텍스트/필드 {cellTypes.filter((t) => t === "field" || t === "text").length}개 + + )} +
+
+
+ + {/* 동작 흐름 */} +
+ +
+ {clickAction === "none" && ( + 동작 없음 + )} + {clickAction === "modal-open" && ( +
+
모달 열기
+ {cfg.cardClickModalConfig?.screenId ? ( +
+ 대상: {cfg.cardClickModalConfig.screenId} + {cfg.cardClickModalConfig.modalTitle && ` (${cfg.cardClickModalConfig.modalTitle})`} +
+ ) : ( +
모달 미설정 - 동작 탭에서 설정하세요
+ )} +
+ )} + {clickAction === "built-in-work-detail" && ( +
+
작업 상세 (내장)
+
진행중(in_progress) 카드만 열림
+
+ )} +
+
+
+ ); +} + +// ===== (레거시) 탭: 데이터 ===== function TabData({ cfg, @@ -1414,7 +1479,7 @@ function CellDetailEditor({ {CELL_TYPE_GROUPS.map((group) => { - const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group); + const types = Object.entries(V2_CELL_TYPE_LABELS).filter(([, v]) => v.group === group); if (types.length === 0) return null; return ( @@ -2942,9 +3007,9 @@ function TabActions({ 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: "" }; + const [advancedOpen, setAdvancedOpen] = useState(false); const [processColumns, setProcessColumns] = useState([]); const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable); @@ -2971,31 +3036,11 @@ function TabActions({ return (
- {/* 소유자 우선 정렬 */} -
- -
- -
-

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

-
- - {/* 카드 선택 시 */} + {/* 카드 선택 시 동작 */}
- {(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => ( + {(["none", "modal-open", "built-in-work-detail"] as V2CardClickAction[]).map((action) => ( ))}
+ + {/* 모달 열기 설정 */} {clickAction === "modal-open" && (
- {/* 모달 캔버스 (디자이너 모드) */} {designerCtx && (
{modalConfig.screenId?.startsWith("modal-") ? ( @@ -3049,7 +3094,6 @@ function TabActions({ )}
)} - {/* 뷰어 모드 또는 직접 입력 폴백 */} {!designerCtx && (
모달 ID @@ -3122,118 +3166,111 @@ function TabActions({ )}
)} + + {/* 작업 상세 내장 모드 안내 */} + {clickAction === "built-in-work-detail" && ( +

+ 카드 클릭 시 작업 상세 모달이 자동으로 열립니다. + 진행중(in_progress) 상태 카드만 열 수 있습니다. + 작업 상세 설정은 작업 상세 컴포넌트에서 직접 설정하세요. +

+ )}
- {/* 필터 전 비표시 */} -
- - onUpdate({ hideUntilFiltered: checked })} - /> -
- {cfg.hideUntilFiltered && ( -

- 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. + {/* 내 작업 표시 모드 */} +

+ +
+ {([ + { value: "off", label: "전체 보기" }, + { value: "priority", label: "우선 표시" }, + { value: "only", label: "내 작업만" }, + ] as const).map((opt) => { + const current = !cfg.ownerSortColumn + ? "off" + : cfg.ownerFilterMode === "only" + ? "only" + : "priority"; + return ( + + ); + })} +
+

+ {!cfg.ownerSortColumn + ? "모든 작업자의 카드가 동일하게 표시됩니다" + : cfg.ownerFilterMode === "only" + ? "내가 담당인 작업만 표시되고, 다른 작업은 숨겨집니다" + : "내가 담당인 작업이 상단에 표시되고, 다른 작업은 비활성화로 표시됩니다"}

- )} - - {/* 스크롤 방향 */} -
- -
- {(["vertical", "horizontal"] as const).map((dir) => ( - - ))} -
- {/* 오버플로우 */} + {/* 고급 설정 (접이식) */}
- -
- {(["loadMore", "pagination"] as const).map((mode) => ( - - ))} -
-
-
- - onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })} - className="mt-0.5 h-7 text-[10px]" - /> -
- {overflow.mode === "loadMore" && ( + + {advancedOpen && ( +
+ {/* 필터 전 비표시 */} +
+ + onUpdate({ hideUntilFiltered: checked })} + /> +
+ {cfg.hideUntilFiltered && ( +

+ 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. +

+ )} + + {/* 기본 표시 수 */}
- + onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })} + value={(cfg.overflow || { visibleCount: 6 }).visibleCount} + onChange={(e) => onUpdate({ + overflow: { + ...(cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }), + visibleCount: Number(e.target.value) || 6, + }, + })} className="mt-0.5 h-7 text-[10px]" /> +

+ 처음에 표시되는 카드 수 (기본: 6개) +

- )} - {overflow.mode === "pagination" && ( -
- - onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })} - className="mt-0.5 h-7 text-[10px]" - /> -
- )} -
-
- - {/* 장바구니 */} -
- - { - if (checked) { - onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } }); - } else { - onUpdate({ cartAction: undefined }); - } - }} - /> +
+ )}
); 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 909b1811..6c0b15bd 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 @@ -679,6 +679,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, e.stopPropagation(); const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction]; const firstAction = actions[0]; + if (!firstAction) return; const config: Record = { ...firstAction, @@ -1263,14 +1264,15 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C function ProcessFlowStrip({ steps, currentIdx, instrQty }: { steps: TimelineProcessStep[]; currentIdx: number; instrQty: number; }) { - const prevStep = currentIdx > 0 ? steps[currentIdx - 1] : null; - const currStep = steps[currentIdx]; - const nextStep = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null; + const safeIdx = currentIdx >= 0 && currentIdx < steps.length ? currentIdx : -1; + const prevStep = safeIdx > 0 ? steps[safeIdx - 1] : null; + const currStep = safeIdx >= 0 ? steps[safeIdx] : null; + const nextStep = safeIdx >= 0 && safeIdx < steps.length - 1 ? steps[safeIdx + 1] : null; - const hiddenBefore = currentIdx > 1 ? currentIdx - 1 : 0; - const hiddenAfter = currentIdx < steps.length - 2 ? steps.length - currentIdx - 2 : 0; + const hiddenBefore = safeIdx > 1 ? safeIdx - 1 : 0; + const hiddenAfter = safeIdx >= 0 && safeIdx < steps.length - 2 ? steps.length - safeIdx - 2 : 0; - const allBeforeDone = hiddenBefore > 0 && steps.slice(0, currentIdx - 1).every(s => { + const allBeforeDone = hiddenBefore > 0 && safeIdx > 1 && steps.slice(0, safeIdx - 1).every(s => { const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending"; return sem === "done"; }); diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 2bf9a517..954aab61 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react" import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList, - Plus, Trash2, Save, FileCheck, + Plus, Trash2, Save, FileCheck, Construction, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,7 @@ import { dataApi } from "@/lib/api/data"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useAuth } from "@/hooks/useAuth"; -import type { PopWorkDetailConfig, ResultSectionConfig } from "../types"; +import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType } from "../types"; import type { TimelineProcessStep } from "../types"; // ======================================== @@ -119,12 +119,18 @@ const DEFAULT_INFO_FIELDS = [ const DEFAULT_CFG: PopWorkDetailConfig = { showTimer: true, - showQuantityInput: true, + showQuantityInput: false, displayMode: "list", phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, infoBar: { enabled: true, fields: [] }, stepControl: { requireStartBeforeInput: false, autoAdvance: true }, navigation: { showPrevNext: true, showCompleteButton: true }, + resultSections: [ + { id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } }, + { id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } }, + { id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } }, + { id: "note", type: "note", enabled: true, showCondition: { type: "always" } }, + ], }; // ======================================== @@ -137,6 +143,7 @@ interface PopWorkDetailComponentProps { componentId?: string; currentRowSpan?: number; currentColSpan?: number; + parentRow?: RowData; } // ======================================== @@ -146,6 +153,7 @@ interface PopWorkDetailComponentProps { export function PopWorkDetailComponent({ config, screenId, + parentRow: parentRowProp, }: PopWorkDetailComponentProps) { const { getSharedData, publish } = usePopEvent(screenId || "default"); const { user } = useAuth(); @@ -160,7 +168,7 @@ export function PopWorkDetailComponent({ phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels }, }; - const parentRow = getSharedData("parentRow"); + const parentRow = parentRowProp ?? getSharedData("parentRow"); const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; const currentProcess = processFlow?.find((p) => p.isCurrent); const workOrderProcessId = parentRow?.__splitProcessId @@ -869,7 +877,7 @@ export function PopWorkDetailComponent({
0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`, + width: `${currentItems.length > 0 && selectedGroup.total > 0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`, }} />
@@ -1073,6 +1081,22 @@ interface BatchHistoryItem { changed_by: string | null; } +const IMPLEMENTED_SECTIONS = new Set(["total-qty", "good-defect", "defect-types", "note"]); + +const SECTION_LABELS: Record = { + "total-qty": "생산수량", + "good-defect": "양품/불량", + "defect-types": "불량 유형 상세", + "note": "비고", + "box-packing": "박스 포장", + "label-print": "라벨 출력", + "photo": "사진", + "document": "문서", + "material-input": "자재 투입", + "barcode-scan": "바코드 스캔", + "plc-data": "PLC 데이터", +}; + interface ResultPanelProps { workOrderProcessId: string; processData: ProcessTimerData | null; @@ -1467,6 +1491,19 @@ function ResultPanel({
)} + {/* 미구현 섹션 플레이스홀더 (순서 보존) */} + {enabledSections + .filter((s) => !IMPLEMENTED_SECTIONS.has(s.type)) + .map((s) => ( +
+ +
+

{SECTION_LABELS[s.type] ?? s.type}

+

준비 중인 기능입니다

+
+
+ ))} + {/* 등록 버튼 */}
+ +
+ + {SECTION_TYPE_META[s.type]?.label ?? s.type} + + toggleSection(i, v)} + className="scale-75" + /> + +
+ ))} + + )} + {availableTypes.length > 0 && } {/* 정보 바 */} @@ -163,6 +265,49 @@ export function PopWorkDetailConfigPanel({ ); } +function SectionAdder({ + types, + onAdd, +}: { + types: ResultSectionType[]; + onAdd: (type: ResultSectionType) => void; +}) { + const [selected, setSelected] = useState(""); + + const handleAdd = () => { + if (!selected) return; + onAdd(selected as ResultSectionType); + setSelected(""); + }; + + return ( +
+ + +
+ ); +} + function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
diff --git a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx index f453fd7a..65566d9a 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx @@ -8,7 +8,7 @@ import type { PopWorkDetailConfig } from "../types"; const defaultConfig: PopWorkDetailConfig = { showTimer: true, - showQuantityInput: true, + showQuantityInput: false, displayMode: "list", phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, infoBar: { @@ -28,6 +28,12 @@ const defaultConfig: PopWorkDetailConfig = { showPrevNext: true, showCompleteButton: true, }, + resultSections: [ + { id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } }, + { id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } }, + { id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } }, + { id: "note", type: "note", enabled: true, showCondition: { type: "always" } }, + ], }; PopComponentRegistry.registerComponent({ diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index e96e4849..e89165fe 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -959,7 +959,7 @@ export interface CardGridConfigV2 { // ----- V2 카드 선택 동작 ----- -export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open"; +export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open" | "built-in-work-detail"; export interface V2CardClickModalConfig { screenId: string; @@ -1004,6 +1004,8 @@ export interface PopCardListV2Config { cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; ownerSortColumn?: string; + ownerFilterMode?: "priority" | "only"; + workDetailConfig?: PopWorkDetailConfig; } /** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ @@ -1045,7 +1047,10 @@ export type ResultSectionType = | "box-packing" | "label-print" | "photo" - | "document"; + | "document" + | "material-input" + | "barcode-scan" + | "plc-data"; export interface ResultSectionConfig { id: string;