feat: pop-work-detail에 PLC 데이터 섹션 추가

- types.ts: PlcDataConfig 인터페이스 추가, ResultSectionConfig에 plcConfig 필드 추가
- PopWorkDetailConfig.tsx: PLC 데이터 설정 패널 구현
  - 외부 DB 연결 선택 -> 테이블 -> 컬럼 순차 선택
  - 디바이스/태그 유니크값 자동 조회
  - 표시 설정 (라벨, 단위, 갱신주기, 표시방식)
  - 매핑 저장 설정 (대상 테이블/컬럼, 모드)
- PopWorkDetailComponent.tsx: PLC 데이터 런타임 표시 구현
  - 외부 DB에서 값 주기적 폴링
  - 큰 숫자/게이지 표시 + PLC 자동 배지
  - 수동 입력 fallback 제공
  - 매핑 저장 ON 시 값 변경 시 대상 테이블에 저장
This commit is contained in:
SeongHyun Kim 2026-03-25 17:26:51 +09:00
parent 49da393f17
commit 8db6b4984b
3 changed files with 840 additions and 12 deletions

View File

@ -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<ResultSectionType>(["total-qty", "good-defect", "defect-types", "note"]);
const IMPLEMENTED_SECTIONS = new Set<ResultSectionType>(["total-qty", "good-defect", "defect-types", "note", "plc-data"]);
const SECTION_LABELS: Record<ResultSectionType, string> = {
"total-qty": "생산수량",
@ -1655,6 +1656,17 @@ function ResultPanel({
</div>
)}
{/* PLC 데이터 섹션 */}
{enabledSections
.filter((s) => s.type === "plc-data")
.map((s) => (
<PlcDataSection
key={s.id}
config={s.plcConfig}
workOrderProcessId={workOrderProcessId}
/>
))}
{/* 미구현 섹션 플레이스홀더 (순서 보존) */}
{!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<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [manualMode, setManualMode] = useState(false);
const [manualValue, setManualValue] = useState("");
const intervalRef = useRef<ReturnType<typeof setInterval> | 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<string, unknown>;
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 (
<div className="rounded-xl border-2 border-dashed border-blue-200 bg-blue-50/50 p-5">
<div className="flex items-center gap-2 text-blue-600">
<Zap className="h-5 w-5" />
<span className="text-sm font-semibold">PLC - </span>
</div>
<p className="mt-2 text-xs text-blue-500">
DB / .
</p>
</div>
);
}
// 수동 모드
if (manualMode) {
return (
<div className="rounded-xl border-2 border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-800">{displayLabel}</h3>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500">
</span>
</div>
{displayUnit && (
<span className="text-sm font-medium text-gray-500">{displayUnit}</span>
)}
</div>
<div className="flex items-center gap-2">
<input
type="number"
className="h-14 flex-1 rounded-xl border-2 border-gray-200 px-4 text-center text-3xl font-bold text-gray-900 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
value={manualValue}
onChange={(e) => setManualValue(e.target.value)}
placeholder="0"
/>
</div>
<div className="mt-3 flex justify-end">
<button
className="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-100"
onClick={() => setManualMode(false)}
>
PLC
</button>
</div>
</div>
);
}
// 자동(PLC) 모드
const formattedValue = value != null ? Number(value).toLocaleString() : "--";
return (
<div className="rounded-xl border-2 border-blue-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{error ? (
<div className="h-2 w-2 rounded-full bg-red-500" />
) : (
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
)}
<h3 className="font-semibold text-gray-800">{displayLabel}</h3>
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-600">
PLC
</span>
</div>
{displayUnit && (
<span className="text-sm font-medium text-gray-500">{displayUnit}</span>
)}
</div>
{/* 큰 숫자 표시 */}
{config.displayMode === "gauge" ? (
<div className="mb-3 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 p-6">
<div className="text-center">
<div className="text-5xl font-bold tracking-wider text-blue-700" style={{ fontVariantNumeric: "tabular-nums" }}>
{loading && !value ? (
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-400" />
) : (
formattedValue
)}
</div>
{/* 간단한 게이지 바 */}
<div className="mx-auto mt-4 h-3 w-full max-w-xs overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-400 to-blue-600 transition-all duration-500"
style={{ width: value ? `${Math.min(100, (Number(value) / 1000) * 100)}%` : "0%" }}
/>
</div>
</div>
</div>
) : (
<div className="mb-3 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 p-6">
<div className="text-center text-5xl font-bold tracking-wider text-blue-700" style={{ fontVariantNumeric: "tabular-nums" }}>
{loading && !value ? (
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-400" />
) : (
formattedValue
)}
</div>
</div>
)}
{/* 실시간 상태 */}
<div className="flex items-center justify-between text-xs">
<div className={cn("flex items-center gap-1.5", error ? "text-red-500" : "text-green-600")}>
{error ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{error}
</>
) : (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin" style={{ animationDuration: "3s" }} />
({config.refreshInterval} )
</>
)}
</div>
<span className="text-gray-400">
{config.deviceFilter && `${config.deviceFilter}`}
{config.tagFilter && ` / ${config.tagFilter}`}
</span>
</div>
{/* 수동 입력 폴백 */}
<div className="mt-3 border-t border-gray-100 pt-3">
<div className="flex items-center gap-2">
<input
type="number"
className="h-9 flex-1 rounded-lg border px-3 text-sm text-gray-400"
placeholder="수동입력 (fallback)"
value={manualValue}
onChange={(e) => setManualValue(e.target.value)}
disabled
/>
<button
className="h-9 rounded-lg bg-gray-100 px-3 text-xs text-gray-500 transition-colors hover:bg-gray-200"
onClick={() => setManualMode(true)}
>
</button>
</div>
</div>
</div>
);
}
// ========================================
// 유틸리티
// ========================================

View File

@ -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<PlcDataConfig>) => {
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 (
<div className="space-y-5">
{/* 기본 설정 */}
@ -148,7 +188,11 @@ export function PopWorkDetailConfigPanel({
{sections.map((s, i) => (
<div
key={s.id}
className="flex items-center gap-1 rounded-md border px-2 py-1"
className={
s.type === "plc-data"
? "flex items-center gap-1 rounded-md border-2 border-blue-300 bg-blue-50 px-2 py-1"
: "flex items-center gap-1 rounded-md border px-2 py-1"
}
>
<div className="flex flex-col">
<button
@ -168,7 +212,11 @@ export function PopWorkDetailConfigPanel({
<ChevronDown className="h-3 w-3" />
</button>
</div>
<span className="flex-1 truncate text-xs font-medium">
<span className={
s.type === "plc-data"
? "flex-1 truncate text-xs font-medium text-blue-700"
: "flex-1 truncate text-xs font-medium"
}>
{SECTION_TYPE_META[s.type]?.label ?? s.type}
</span>
<Switch
@ -191,6 +239,14 @@ export function PopWorkDetailConfigPanel({
{availableTypes.length > 0 && <SectionAdder types={availableTypes} onAdd={addSection} />}
</Section>
{/* PLC 데이터 상세 설정 */}
{plcSection && plcSectionIdx >= 0 && (
<PlcDataSettingsPanel
plcConfig={plcSection.plcConfig ?? DEFAULT_PLC_CONFIG}
onChange={(partial) => updatePlcConfig(plcSectionIdx, partial)}
/>
)}
{/* 정보 바 */}
<Section title="작업지시 정보 바">
<ToggleRow
@ -265,6 +321,483 @@ export function PopWorkDetailConfigPanel({
);
}
// ========================================
// PLC 데이터 상세 설정 패널
// ========================================
interface PlcDataSettingsPanelProps {
plcConfig: PlcDataConfig;
onChange: (partial: Partial<PlcDataConfig>) => void;
}
interface DbConnectionOption {
id: number;
connection_name: string;
}
function PlcDataSettingsPanel({ plcConfig, onChange }: PlcDataSettingsPanelProps) {
// 외부 DB 연결 목록
const [connections, setConnections] = useState<DbConnectionOption[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
// 테이블 목록
const [tables, setTables] = useState<string[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<string[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 디바이스/태그 유니크 값
const [devices, setDevices] = useState<string[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
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, unknown>) => 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, unknown>) => 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 (
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded bg-blue-500">
<Zap className="h-3 w-3 text-white" />
</div>
<span className="text-xs font-semibold text-blue-800">PLC </span>
</div>
{/* PLC 연동 - DB 연결 */}
<div className="space-y-2">
<div className="text-[11px] font-medium text-blue-700">PLC </div>
{/* DB 연결 */}
<div className="space-y-1">
<span className="text-xs text-gray-600">DB </span>
<Select
value={plcConfig.connectionId}
onValueChange={(v) => onChange({ connectionId: v, tableName: "", deviceColumn: "", valueColumn: "", timestampColumn: "", deviceFilter: "", tagFilter: "" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={connectionsLoading ? "불러오는 중..." : "외부 DB 선택"} />
</SelectTrigger>
<SelectContent>
{connections.map((c) => (
<SelectItem key={c.id} value={String(c.id)} className="text-xs">
{c.connection_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테이블 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
<Select
value={plcConfig.tableName}
onValueChange={(v) => onChange({ tableName: v, deviceColumn: "", valueColumn: "", timestampColumn: "", deviceFilter: "", tagFilter: "" })}
disabled={!plcConfig.connectionId}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={tablesLoading ? "불러오는 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t} value={t} className="text-xs">{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 디바이스 컬럼 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.deviceColumn}
onValueChange={(v) => onChange({ deviceColumn: v, deviceFilter: "", tagFilter: "" })}
disabled={!plcConfig.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={columnsLoading ? "불러오는 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 컬럼 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.valueColumn}
onValueChange={(v) => onChange({ valueColumn: v })}
disabled={!plcConfig.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 타임스탬프 컬럼 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.timestampColumn}
onValueChange={(v) => onChange({ timestampColumn: v })}
disabled={!plcConfig.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 디바이스 필터 */}
<div className="space-y-2">
<div className="text-[11px] font-medium text-blue-700"> </div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
{devicesLoading ? (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : devices.length > 0 ? (
<Select
value={plcConfig.deviceFilter}
onValueChange={(v) => onChange({ deviceFilter: v, tagFilter: "" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="디바이스 선택" />
</SelectTrigger>
<SelectContent>
{devices.map((d) => (
<SelectItem key={d} value={d} className="text-xs">{d}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
placeholder="디바이스 ID 직접 입력"
value={plcConfig.deviceFilter}
onChange={(e) => onChange({ deviceFilter: e.target.value })}
/>
)}
</div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
{tagsLoading ? (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : tags.length > 0 ? (
<Select
value={plcConfig.tagFilter}
onValueChange={(v) => onChange({ tagFilter: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="태그 선택" />
</SelectTrigger>
<SelectContent>
{tags.map((t) => (
<SelectItem key={t} value={t} className="text-xs">{t}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
placeholder="태그명 직접 입력"
value={plcConfig.tagFilter}
onChange={(e) => onChange({ tagFilter: e.target.value })}
/>
)}
</div>
</div>
{/* 표시 설정 */}
<div className="space-y-2">
<div className="text-[11px] font-medium text-blue-700"> </div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
<Input
className="h-8 text-xs"
placeholder="예: 총 생산수량"
value={plcConfig.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
<Input
className="h-8 text-xs"
placeholder="예: EA"
value={plcConfig.unit}
onChange={(e) => onChange({ unit: e.target.value })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600"> </span>
<Select
value={String(plcConfig.refreshInterval)}
onValueChange={(v) => onChange({ refreshInterval: Number(v) })}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10" className="text-xs">10</SelectItem>
<SelectItem value="30" className="text-xs">30</SelectItem>
<SelectItem value="60" className="text-xs">60</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.displayMode}
onValueChange={(v) => onChange({ displayMode: v as "number" | "gauge" })}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number" className="text-xs"></SelectItem>
<SelectItem value="gauge" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="border-t border-blue-200" />
{/* 매핑 저장 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-700"> </span>
<Switch
checked={plcConfig.mapping?.enabled ?? false}
onCheckedChange={(v) =>
onChange({
mapping: {
enabled: v,
targetTable: plcConfig.mapping?.targetTable ?? "",
targetColumn: plcConfig.mapping?.targetColumn ?? "",
mode: plcConfig.mapping?.mode ?? "latest",
},
})
}
/>
</div>
{plcConfig.mapping?.enabled && (
<>
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<div className="flex gap-1">
<Input
className="h-7 flex-1 text-xs"
placeholder="테이블명"
value={plcConfig.mapping.targetTable}
onChange={(e) =>
onChange({
mapping: { ...plcConfig.mapping!, targetTable: e.target.value },
})
}
/>
<Input
className="h-7 flex-1 text-xs"
placeholder="컬럼명"
value={plcConfig.mapping.targetColumn}
onChange={(e) =>
onChange({
mapping: { ...plcConfig.mapping!, targetColumn: e.target.value },
})
}
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.mapping.mode}
onValueChange={(v) =>
onChange({
mapping: { ...plcConfig.mapping!, mode: v as "latest" | "accumulated" | "delta" },
})
}
>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest" className="text-xs">latest</SelectItem>
<SelectItem value="accumulated" className="text-xs">accumulated</SelectItem>
<SelectItem value="delta" className="text-xs">delta</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
</div>
);
}
// ========================================
// 공통 하위 컴포넌트
// ========================================
function SectionAdder({
types,
onAdd,

View File

@ -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 {