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 30fe9a7f..9cb08dc7 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, Construction, AlertTriangle, + Plus, Trash2, Save, FileCheck, Construction, AlertTriangle, Zap, RefreshCw, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -17,8 +17,9 @@ 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, ResultSectionType } from "../types"; +import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType, PlcDataConfig } from "../types"; import type { TimelineProcessStep } from "../types"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; // ======================================== // 타입 @@ -1260,7 +1261,7 @@ interface BatchHistoryItem { changed_by: string | null; } -const IMPLEMENTED_SECTIONS = new Set(["total-qty", "good-defect", "defect-types", "note"]); +const IMPLEMENTED_SECTIONS = new Set(["total-qty", "good-defect", "defect-types", "note", "plc-data"]); const SECTION_LABELS: Record = { "total-qty": "생산수량", @@ -1655,6 +1656,17 @@ function ResultPanel({ )} + {/* PLC 데이터 섹션 */} + {enabledSections + .filter((s) => s.type === "plc-data") + .map((s) => ( + + ))} + {/* 미구현 섹션 플레이스홀더 (순서 보존) */} {!isConfirmed && enabledSections .filter((s) => !IMPLEMENTED_SECTIONS.has(s.type)) @@ -1744,6 +1756,267 @@ function ResultPanel({ ); } +// ======================================== +// PLC 데이터 섹션 (런타임 표시) +// ======================================== + +interface PlcDataSectionProps { + config?: PlcDataConfig; + workOrderProcessId: string; +} + +function PlcDataSection({ config, workOrderProcessId }: PlcDataSectionProps) { + const [value, setValue] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [manualMode, setManualMode] = useState(false); + const [manualValue, setManualValue] = useState(""); + const intervalRef = useRef | null>(null); + + const isConfigured = + config && + config.connectionId && + config.tableName && + config.valueColumn && + config.deviceColumn; + + const fetchPlcValue = useCallback(async () => { + if (!isConfigured || !config) return; + + setLoading(true); + setError(null); + + try { + // 쿼리: 디바이스/태그 필터 기반 최신 값 조회 + let query = `SELECT "${config.valueColumn}"`; + if (config.timestampColumn) { + query += `, "${config.timestampColumn}"`; + } + query += ` FROM "${config.tableName}" WHERE 1=1`; + + if (config.deviceFilter) { + query += ` AND "${config.deviceColumn}" = '${config.deviceFilter}'`; + } + if (config.tagFilter) { + // tag_name 컬럼 추정 - deviceColumn 외 첫 텍스트 컬럼 + // 실제로는 설정에서 명시적 tagColumn이 있으면 좋겠지만, + // 현재는 tag_name이 데이터에 있다고 가정 + query += ` AND tag_name = '${config.tagFilter}'`; + } + + if (config.timestampColumn) { + query += ` ORDER BY "${config.timestampColumn}" DESC`; + } + query += ` LIMIT 1`; + + const res = await ExternalDbConnectionAPI.executeQuery(Number(config.connectionId), query); + + if (res.success && res.data && res.data.length > 0) { + const row = res.data[0] as Record; + const rawValue = row[config.valueColumn]; + setValue(rawValue != null ? String(rawValue) : null); + setLastUpdated(new Date()); + } else { + setValue(null); + setError("데이터 없음"); + } + } catch (err) { + setError("조회 실패"); + console.error("PLC 데이터 조회 오류:", err); + } finally { + setLoading(false); + } + }, [config, isConfigured]); + + // 초기 로드 + 폴링 + useEffect(() => { + if (!isConfigured || manualMode) return; + + fetchPlcValue(); + + const interval = (config?.refreshInterval ?? 30) * 1000; + intervalRef.current = setInterval(fetchPlcValue, interval); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchPlcValue, isConfigured, manualMode, config?.refreshInterval]); + + // 매핑 저장 (값 변경 시) + useEffect(() => { + if (!config?.mapping?.enabled || !value || !workOrderProcessId) return; + if (!config.mapping.targetTable || !config.mapping.targetColumn) return; + + // 매핑 저장은 백엔드 API를 통해서 수행 (데이터 쓰기 규칙) + const saveMapping = async () => { + try { + await apiClient.post("/pop/production/plc-mapping", { + work_order_process_id: workOrderProcessId, + target_table: config.mapping!.targetTable, + target_column: config.mapping!.targetColumn, + value, + mode: config.mapping!.mode, + }); + } catch { + // 매핑 실패는 무시 (PLC 표시에는 영향 없음) + } + }; + saveMapping(); + }, [value, config?.mapping, workOrderProcessId]); + + const displayLabel = config?.label || "PLC 데이터"; + const displayUnit = config?.unit || ""; + + // 미설정 상태 + if (!isConfigured) { + return ( +
+
+ + PLC 데이터 - 연결 필요 +
+

+ 설정 패널에서 외부 DB 연결 및 테이블/컬럼을 설정해주세요. +

+
+ ); + } + + // 수동 모드 + if (manualMode) { + return ( +
+
+
+

{displayLabel}

+ + 수동 입력 + +
+ {displayUnit && ( + {displayUnit} + )} +
+
+ setManualValue(e.target.value)} + placeholder="0" + /> +
+
+ +
+
+ ); + } + + // 자동(PLC) 모드 + const formattedValue = value != null ? Number(value).toLocaleString() : "--"; + + return ( +
+
+
+ {error ? ( +
+ ) : ( +
+ )} +

{displayLabel}

+ + PLC 자동 + +
+ {displayUnit && ( + {displayUnit} + )} +
+ + {/* 큰 숫자 표시 */} + {config.displayMode === "gauge" ? ( +
+
+
+ {loading && !value ? ( + + ) : ( + formattedValue + )} +
+ {/* 간단한 게이지 바 */} +
+
+
+
+
+ ) : ( +
+
+ {loading && !value ? ( + + ) : ( + formattedValue + )} +
+
+ )} + + {/* 실시간 상태 */} +
+
+ {error ? ( + <> + + {error} + + ) : ( + <> + + 실시간 수신 중 ({config.refreshInterval}초 간격) + + )} +
+ + {config.deviceFilter && `${config.deviceFilter}`} + {config.tagFilter && ` / ${config.tagFilter}`} + +
+ + {/* 수동 입력 폴백 */} +
+
+ setManualValue(e.target.value)} + disabled + /> + +
+
+
+ ); +} + // ======================================== // 유틸리티 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx index 97d1dd0c..f65ad30f 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -1,13 +1,20 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react"; -import type { PopWorkDetailConfig, WorkDetailInfoBarField, ResultSectionConfig, ResultSectionType } from "../types"; +import { Plus, Trash2, ChevronUp, ChevronDown, Zap, Loader2 } from "lucide-react"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import type { + PopWorkDetailConfig, + WorkDetailInfoBarField, + ResultSectionConfig, + ResultSectionType, + PlcDataConfig, +} from "../types"; interface PopWorkDetailConfigPanelProps { config?: PopWorkDetailConfig; @@ -51,6 +58,20 @@ const DEFAULT_NAVIGATION = { showCompleteButton: true, }; +const DEFAULT_PLC_CONFIG: PlcDataConfig = { + connectionId: "", + tableName: "", + deviceColumn: "", + valueColumn: "", + timestampColumn: "", + deviceFilter: "", + tagFilter: "", + label: "", + unit: "EA", + refreshInterval: 30, + displayMode: "number", +}; + export function PopWorkDetailConfigPanel({ config, onChange, @@ -96,10 +117,16 @@ export function PopWorkDetailConfigPanel({ }; const addSection = (type: ResultSectionType) => { - updateSections([ - ...sections, - { id: type, type, enabled: true, showCondition: { type: "always" } }, - ]); + const newSection: ResultSectionConfig = { + id: type, + type, + enabled: true, + showCondition: { type: "always" }, + }; + if (type === "plc-data") { + newSection.plcConfig = { ...DEFAULT_PLC_CONFIG }; + } + updateSections([...sections, newSection]); }; const removeSection = (idx: number) => { @@ -120,6 +147,19 @@ export function PopWorkDetailConfigPanel({ updateSections(next); }; + const updatePlcConfig = (idx: number, partial: Partial) => { + const next = [...sections]; + next[idx] = { + ...next[idx], + plcConfig: { ...(next[idx].plcConfig ?? DEFAULT_PLC_CONFIG), ...partial }, + }; + updateSections(next); + }; + + // PLC 섹션의 인덱스 찾기 + const plcSectionIdx = sections.findIndex((s) => s.type === "plc-data" && s.enabled); + const plcSection = plcSectionIdx >= 0 ? sections[plcSectionIdx] : null; + return (
{/* 기본 설정 */} @@ -148,7 +188,11 @@ export function PopWorkDetailConfigPanel({ {sections.map((s, i) => (
- + {SECTION_TYPE_META[s.type]?.label ?? s.type} 0 && } + {/* PLC 데이터 상세 설정 */} + {plcSection && plcSectionIdx >= 0 && ( + updatePlcConfig(plcSectionIdx, partial)} + /> + )} + {/* 정보 바 */}
) => void; +} + +interface DbConnectionOption { + id: number; + connection_name: string; +} + +function PlcDataSettingsPanel({ plcConfig, onChange }: PlcDataSettingsPanelProps) { + // 외부 DB 연결 목록 + const [connections, setConnections] = useState([]); + const [connectionsLoading, setConnectionsLoading] = useState(false); + + // 테이블 목록 + const [tables, setTables] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + + // 컬럼 목록 + const [columns, setColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // 디바이스/태그 유니크 값 + const [devices, setDevices] = useState([]); + const [devicesLoading, setDevicesLoading] = useState(false); + const [tags, setTags] = useState([]); + const [tagsLoading, setTagsLoading] = useState(false); + + // 1. 외부 DB 연결 목록 로드 + useEffect(() => { + let cancelled = false; + setConnectionsLoading(true); + ExternalDbConnectionAPI.getConnections({ is_active: "Y" }) + .then((list) => { + if (!cancelled) { + setConnections( + list.map((c) => ({ id: c.id!, connection_name: c.connection_name })) + ); + } + }) + .catch(() => { + if (!cancelled) setConnections([]); + }) + .finally(() => { + if (!cancelled) setConnectionsLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + // 2. 연결 선택 시 테이블 목록 로드 + const loadTables = useCallback(async (connId: string) => { + if (!connId) { + setTables([]); + return; + } + setTablesLoading(true); + try { + const res = await ExternalDbConnectionAPI.getTables(Number(connId)); + setTables(res.data ?? []); + } catch { + setTables([]); + } finally { + setTablesLoading(false); + } + }, []); + + useEffect(() => { + if (plcConfig.connectionId) { + loadTables(plcConfig.connectionId); + } + }, [plcConfig.connectionId, loadTables]); + + // 3. 테이블 선택 시 컬럼 목록 로드 + const loadColumns = useCallback(async (connId: string, tableName: string) => { + if (!connId || !tableName) { + setColumns([]); + return; + } + setColumnsLoading(true); + try { + const res = await ExternalDbConnectionAPI.getTableColumns(Number(connId), tableName); + const cols = (res.data ?? []).map((c: { column_name?: string; COLUMN_NAME?: string }) => + c.column_name ?? c.COLUMN_NAME ?? "" + ).filter(Boolean); + setColumns(cols); + } catch { + setColumns([]); + } finally { + setColumnsLoading(false); + } + }, []); + + useEffect(() => { + if (plcConfig.connectionId && plcConfig.tableName) { + loadColumns(plcConfig.connectionId, plcConfig.tableName); + } + }, [plcConfig.connectionId, plcConfig.tableName, loadColumns]); + + // 4. 디바이스/태그 유니크값 로드 + const loadUniqueValues = useCallback(async ( + connId: string, + tableName: string, + column: string, + setter: (v: string[]) => void, + setLoading: (v: boolean) => void, + ) => { + if (!connId || !tableName || !column) { + setter([]); + return; + } + setLoading(true); + try { + const query = `SELECT DISTINCT "${column}" FROM "${tableName}" WHERE "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 100`; + const res = await ExternalDbConnectionAPI.executeQuery(Number(connId), query); + const values = (res.data ?? []).map((row: Record) => String(row[column] ?? "")).filter(Boolean); + setter(values); + } catch { + setter([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (plcConfig.connectionId && plcConfig.tableName && plcConfig.deviceColumn) { + loadUniqueValues(plcConfig.connectionId, plcConfig.tableName, plcConfig.deviceColumn, setDevices, setDevicesLoading); + } else { + setDevices([]); + } + }, [plcConfig.connectionId, plcConfig.tableName, plcConfig.deviceColumn, loadUniqueValues]); + + // 태그 필터 값 로드 - deviceColumn과 다른 텍스트 컬럼이면 유니크 조회 + // 실제로는 device_id 선택 후 해당 디바이스의 tag_name 유니크값을 로드 + useEffect(() => { + if (!plcConfig.connectionId || !plcConfig.tableName || !plcConfig.deviceColumn) { + setTags([]); + return; + } + // tag는 deviceColumn이 아닌 나머지 text-like 컬럼에서 가져오거나, + // 사용자가 직접 입력 가능. 여기서는 device filter가 선택된 경우 해당 device의 남은 텍스트 컬럼에서 unique를 가져옴 + // 간단히: deviceColumn 외의 첫 텍스트 컬럼 또는 사용자가 지정하도록 함 + // 지금은 deviceFilter가 선택되었을 때 deviceColumn 조건으로 다른 유니크값 조회 + const tagColumn = columns.find( + (c) => c !== plcConfig.deviceColumn && c !== plcConfig.valueColumn && c !== plcConfig.timestampColumn + ); + if (tagColumn && plcConfig.deviceFilter) { + const query = `SELECT DISTINCT "${tagColumn}" FROM "${plcConfig.tableName}" WHERE "${plcConfig.deviceColumn}" = '${plcConfig.deviceFilter}' AND "${tagColumn}" IS NOT NULL ORDER BY "${tagColumn}" LIMIT 100`; + setTagsLoading(true); + ExternalDbConnectionAPI.executeQuery(Number(plcConfig.connectionId), query) + .then((res) => { + setTags((res.data ?? []).map((row: Record) => String(row[tagColumn] ?? "")).filter(Boolean)); + }) + .catch(() => setTags([])) + .finally(() => setTagsLoading(false)); + } else { + setTags([]); + } + }, [plcConfig.connectionId, plcConfig.tableName, plcConfig.deviceColumn, plcConfig.deviceFilter, plcConfig.valueColumn, plcConfig.timestampColumn, columns]); + + return ( +
+
+
+ +
+ PLC 데이터 설정 +
+ + {/* PLC 연동 - DB 연결 */} +
+
PLC 연동
+ + {/* DB 연결 */} +
+ DB 연결 + +
+ + {/* 테이블 */} +
+ 테이블 + +
+ + {/* 디바이스 컬럼 */} +
+ 디바이스 컬럼 + +
+ + {/* 값 컬럼 */} +
+ 값 컬럼 + +
+ + {/* 타임스탬프 컬럼 */} +
+ 타임스탬프 컬럼 + +
+
+ + {/* 디바이스 필터 */} +
+
디바이스 필터
+ +
+ 디바이스 + {devicesLoading ? ( +
+ 조회 중... +
+ ) : devices.length > 0 ? ( + + ) : ( + onChange({ deviceFilter: e.target.value })} + /> + )} +
+ +
+ 태그 + {tagsLoading ? ( +
+ 조회 중... +
+ ) : tags.length > 0 ? ( + + ) : ( + onChange({ tagFilter: e.target.value })} + /> + )} +
+
+ + {/* 표시 설정 */} +
+
표시 설정
+ +
+ 라벨 + onChange({ label: e.target.value })} + /> +
+ +
+ 단위 + onChange({ unit: e.target.value })} + /> +
+ +
+ 갱신 주기 + +
+ +
+ 표시 방식 + +
+
+ +
+ + {/* 매핑 저장 */} +
+
+ 매핑 저장 + + onChange({ + mapping: { + enabled: v, + targetTable: plcConfig.mapping?.targetTable ?? "", + targetColumn: plcConfig.mapping?.targetColumn ?? "", + mode: plcConfig.mapping?.mode ?? "latest", + }, + }) + } + /> +
+ + {plcConfig.mapping?.enabled && ( + <> +
+ 저장 대상 +
+ + onChange({ + mapping: { ...plcConfig.mapping!, targetTable: e.target.value }, + }) + } + /> + + onChange({ + mapping: { ...plcConfig.mapping!, targetColumn: e.target.value }, + }) + } + /> +
+
+ +
+ 매핑 모드 + +
+ + )} +
+
+ ); +} + +// ======================================== +// 공통 하위 컴포넌트 +// ======================================== + function SectionAdder({ types, onAdd, diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 9ffe58ad..29277d79 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1048,11 +1048,33 @@ export type ResultSectionType = | "barcode-scan" | "plc-data"; +/** PLC 데이터 연동 설정 */ +export interface PlcDataConfig { + connectionId: string; // external_db_connections ID + tableName: string; // 예: "edge_telemetry" + deviceColumn: string; // 예: "device_id" + valueColumn: string; // 예: "value" + timestampColumn: string; // 예: "collected_at" + deviceFilter: string; // 예: "PROD-COUNTER-01" + tagFilter: string; // 예: "생산수량_PV" + label: string; // 예: "총 생산수량" + unit: string; // 예: "EA" + refreshInterval: number; // 초 단위 (10, 30, 60) + displayMode: "number" | "gauge"; + mapping?: { + enabled: boolean; + targetTable: string; + targetColumn: string; + mode: "latest" | "accumulated" | "delta"; + }; +} + export interface ResultSectionConfig { id: string; type: ResultSectionType; enabled: boolean; showCondition?: { type: "always" | "last-process" }; + plcConfig?: PlcDataConfig; } export interface PopWorkDetailConfig {