861 lines
30 KiB
TypeScript
861 lines
30 KiB
TypeScript
"use client";
|
|
|
|
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, Zap, Loader2 } from "lucide-react";
|
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
|
import type {
|
|
PopWorkDetailConfig,
|
|
WorkDetailInfoBarField,
|
|
ResultSectionConfig,
|
|
ResultSectionType,
|
|
PlcDataConfig,
|
|
} from "../types";
|
|
|
|
interface PopWorkDetailConfigPanelProps {
|
|
config?: PopWorkDetailConfig;
|
|
onChange?: (config: PopWorkDetailConfig) => void;
|
|
}
|
|
|
|
const SECTION_TYPE_META: Record<ResultSectionType, { label: string }> = {
|
|
"total-qty": { label: "생산수량" },
|
|
"good-defect": { label: "양품/불량" },
|
|
"defect-types": { label: "불량 유형 상세" },
|
|
"note": { label: "비고" },
|
|
"box-packing": { label: "박스 포장" },
|
|
"label-print": { label: "라벨 출력" },
|
|
"photo": { label: "사진" },
|
|
"document": { label: "문서" },
|
|
"material-input": { label: "자재 투입" },
|
|
"barcode-scan": { label: "바코드 스캔" },
|
|
"plc-data": { label: "PLC 데이터" },
|
|
};
|
|
|
|
const ALL_SECTION_TYPES = Object.keys(SECTION_TYPE_META) as ResultSectionType[];
|
|
|
|
const DEFAULT_PHASE_LABELS: Record<string, string> = {
|
|
PRE: "작업 전",
|
|
IN: "작업 중",
|
|
POST: "작업 후",
|
|
};
|
|
|
|
const DEFAULT_INFO_BAR = {
|
|
enabled: true,
|
|
fields: [] as WorkDetailInfoBarField[],
|
|
};
|
|
|
|
const DEFAULT_STEP_CONTROL = {
|
|
requireStartBeforeInput: false,
|
|
autoAdvance: true,
|
|
};
|
|
|
|
const DEFAULT_NAVIGATION = {
|
|
showPrevNext: true,
|
|
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,
|
|
}: PopWorkDetailConfigPanelProps) {
|
|
const cfg: PopWorkDetailConfig = {
|
|
showTimer: config?.showTimer ?? true,
|
|
showQuantityInput: config?.showQuantityInput ?? false,
|
|
displayMode: config?.displayMode ?? "list",
|
|
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
|
|
infoBar: config?.infoBar ?? { ...DEFAULT_INFO_BAR },
|
|
stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL },
|
|
navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION },
|
|
resultSections: config?.resultSections ?? [],
|
|
};
|
|
|
|
const update = (partial: Partial<PopWorkDetailConfig>) => {
|
|
onChange?.({ ...cfg, ...partial });
|
|
};
|
|
|
|
const [newFieldLabel, setNewFieldLabel] = useState("");
|
|
const [newFieldColumn, setNewFieldColumn] = useState("");
|
|
|
|
const addInfoBarField = () => {
|
|
if (!newFieldLabel || !newFieldColumn) return;
|
|
const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }];
|
|
update({ infoBar: { ...cfg.infoBar, fields } });
|
|
setNewFieldLabel("");
|
|
setNewFieldColumn("");
|
|
};
|
|
|
|
const removeInfoBarField = (idx: number) => {
|
|
const fields = (cfg.infoBar.fields ?? []).filter((_, i) => i !== idx);
|
|
update({ infoBar: { ...cfg.infoBar, fields } });
|
|
};
|
|
|
|
// --- 실적 입력 섹션 관리 ---
|
|
const sections = cfg.resultSections ?? [];
|
|
const usedTypes = new Set(sections.map((s) => s.type));
|
|
const availableTypes = ALL_SECTION_TYPES.filter((t) => !usedTypes.has(t));
|
|
|
|
const updateSections = (next: ResultSectionConfig[]) => {
|
|
update({ resultSections: next });
|
|
};
|
|
|
|
const addSection = (type: ResultSectionType) => {
|
|
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) => {
|
|
updateSections(sections.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const toggleSection = (idx: number, enabled: boolean) => {
|
|
const next = [...sections];
|
|
next[idx] = { ...next[idx], enabled };
|
|
updateSections(next);
|
|
};
|
|
|
|
const moveSection = (idx: number, dir: -1 | 1) => {
|
|
const target = idx + dir;
|
|
if (target < 0 || target >= sections.length) return;
|
|
const next = [...sections];
|
|
[next[idx], next[target]] = [next[target], next[idx]];
|
|
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">
|
|
{/* 기본 설정 */}
|
|
<Section title="기본 설정">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">표시 모드</Label>
|
|
<Select value={cfg.displayMode} onValueChange={(v) => update({ displayMode: v as "list" | "step" })}>
|
|
<SelectTrigger className="h-7 w-28 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="list">리스트</SelectItem>
|
|
<SelectItem value="step">스텝</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<ToggleRow label="타이머 표시" checked={cfg.showTimer} onChange={(v) => update({ showTimer: v })} />
|
|
</Section>
|
|
|
|
{/* 실적 입력 섹션 */}
|
|
<Section title="실적 입력 섹션">
|
|
{sections.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-1">등록된 섹션이 없습니다</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{sections.map((s, i) => (
|
|
<div
|
|
key={s.id}
|
|
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
|
|
type="button"
|
|
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
|
disabled={i === 0}
|
|
onClick={() => moveSection(i, -1)}
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
|
disabled={i === sections.length - 1}
|
|
onClick={() => moveSection(i, 1)}
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<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
|
|
checked={s.enabled}
|
|
onCheckedChange={(v) => toggleSection(i, v)}
|
|
className="scale-75"
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 shrink-0"
|
|
onClick={() => removeSection(i)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{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
|
|
label="정보 바 표시"
|
|
checked={cfg.infoBar.enabled}
|
|
onChange={(v) => update({ infoBar: { ...cfg.infoBar, enabled: v } })}
|
|
/>
|
|
{cfg.infoBar.enabled && (
|
|
<div className="space-y-2 pt-1">
|
|
{(cfg.infoBar.fields ?? []).map((f, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<span className="w-16 truncate text-xs text-muted-foreground">{f.label}</span>
|
|
<span className="flex-1 truncate text-xs font-mono">{f.column}</span>
|
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => removeInfoBarField(i)}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<div className="flex items-center gap-1">
|
|
<Input className="h-7 text-xs" placeholder="라벨" value={newFieldLabel} onChange={(e) => setNewFieldLabel(e.target.value)} />
|
|
<Input className="h-7 text-xs" placeholder="컬럼명" value={newFieldColumn} onChange={(e) => setNewFieldColumn(e.target.value)} />
|
|
<Button size="icon" variant="outline" className="h-7 w-7 shrink-0" onClick={addInfoBarField}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* 단계 제어 */}
|
|
<Section title="단계 제어">
|
|
<ToggleRow
|
|
label="시작 전 입력 잠금"
|
|
checked={cfg.stepControl.requireStartBeforeInput}
|
|
onChange={(v) => update({ stepControl: { ...cfg.stepControl, requireStartBeforeInput: v } })}
|
|
/>
|
|
<ToggleRow
|
|
label="완료 시 자동 다음 이동"
|
|
checked={cfg.stepControl.autoAdvance}
|
|
onChange={(v) => update({ stepControl: { ...cfg.stepControl, autoAdvance: v } })}
|
|
/>
|
|
</Section>
|
|
|
|
{/* 네비게이션 */}
|
|
<Section title="네비게이션">
|
|
<ToggleRow
|
|
label="이전/다음 버튼"
|
|
checked={cfg.navigation.showPrevNext}
|
|
onChange={(v) => update({ navigation: { ...cfg.navigation, showPrevNext: v } })}
|
|
/>
|
|
<ToggleRow
|
|
label="공정 완료 버튼"
|
|
checked={cfg.navigation.showCompleteButton}
|
|
onChange={(v) => update({ navigation: { ...cfg.navigation, showCompleteButton: v } })}
|
|
/>
|
|
</Section>
|
|
|
|
{/* 단계 라벨 */}
|
|
<Section title="단계 라벨">
|
|
{(["PRE", "IN", "POST"] as const).map((phase) => (
|
|
<div key={phase} className="flex items-center gap-2">
|
|
<span className="w-12 text-xs font-medium text-muted-foreground">{phase}</span>
|
|
<Input
|
|
className="h-7 text-xs"
|
|
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
|
|
onChange={(e) => update({ phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value } })}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 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,
|
|
}: {
|
|
types: ResultSectionType[];
|
|
onAdd: (type: ResultSectionType) => void;
|
|
}) {
|
|
const [selected, setSelected] = useState<string>("");
|
|
|
|
const handleAdd = () => {
|
|
if (!selected) return;
|
|
onAdd(selected as ResultSectionType);
|
|
setSelected("");
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 pt-1">
|
|
<Select value={selected} onValueChange={setSelected}>
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
<SelectValue placeholder="섹션 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{types.map((t) => (
|
|
<SelectItem key={t} value={t} className="text-xs">
|
|
{SECTION_TYPE_META[t]?.label ?? t}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
|
disabled={!selected}
|
|
onClick={handleAdd}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-semibold text-muted-foreground">{title}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">{label}</Label>
|
|
<Switch checked={checked} onCheckedChange={onChange} />
|
|
</div>
|
|
);
|
|
}
|