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:
parent
49da393f17
commit
8db6b4984b
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유틸리티
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue