d03f92947d
- Integrated DynamicSearchFilter component to manage search filters. - Removed individual search state variables and replaced with a single searchFilters state. - Updated fetchData function to handle new filter structure. - Refactored search filter UI to utilize DynamicSearchFilter. - Adjusted table header styles for better visibility and consistency. style: Update global styles for improved UI consistency - Unified font size across the application to 16px, excluding buttons. - Adjusted header padding and font size for better readability. - Enhanced dark mode styles for checkboxes to ensure visibility. feat: Add Options Setting page for category and numbering configurations - Created a new OptionsSettingPage component with tabs for category and numbering settings. - Implemented drag-to-resize functionality for the category column list. - Integrated CategoryColumnList and CategoryValueManager components for managing categories. feat: Introduce useTableSettings hook for table configuration management - Developed useTableSettings hook to manage column visibility, order, and width. - Implemented localStorage persistence for table settings. - Enhanced TableSettingsModal to accept defaultVisibleKeys for initial column visibility. chore: Update AdminPageRenderer to include new COMPANY_16 routes - Added new routes for COMPANY_16 master-data options and other pages.
548 lines
30 KiB
TypeScript
548 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, Pencil, Cpu, Settings2, Search, Inbox,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { toast } from "sonner";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
|
|
/* ───── 테이블명 ───── */
|
|
const DATATYPE_TABLE = "plc_data_type";
|
|
|
|
const DATATYPE_COLUMNS = [
|
|
{ key: "equipment_code", label: "설비코드" },
|
|
{ key: "data_type", label: "데이터타입" },
|
|
{ key: "unit", label: "단위" },
|
|
{ key: "tag_address", label: "태그주소" },
|
|
{ key: "collection_interval", label: "수집주기" },
|
|
{ key: "lower_limit", label: "하한값" },
|
|
{ key: "upper_limit", label: "상한값" },
|
|
{ key: "is_active", label: "사용여부" },
|
|
];
|
|
const COLLECTION_TABLE = "plc_collection_config";
|
|
const EQUIPMENT_TABLE = "equipment_mng";
|
|
|
|
/* ───── Cron 한글 변환 ───── */
|
|
const cronToKorean = (cron: string): string => {
|
|
if (!cron) return "";
|
|
const parts = cron.trim().split(/\s+/);
|
|
if (parts.length < 5) return cron;
|
|
const [min, hour] = parts;
|
|
if (min === "*" && hour === "*") return "매 분마다";
|
|
if (min !== "*" && hour === "*") return `매시 ${min}분마다`;
|
|
if (min === "0" && hour !== "*") return `매일 ${hour}시 정각`;
|
|
if (min === "*/5") return "5분마다";
|
|
if (min === "*/10") return "10분마다";
|
|
if (min === "*/30") return "30분마다";
|
|
return cron;
|
|
};
|
|
|
|
/* ───── 카테고리 flatten ───── */
|
|
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
|
const result: { code: string; label: string }[] = [];
|
|
for (const v of vals) {
|
|
result.push({ code: v.valueCode, label: v.valueLabel });
|
|
if (v.children?.length) result.push(...flattenCategories(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
|
|
export default function PlcSettingsPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-plc-settings", DATATYPE_TABLE, DATATYPE_COLUMNS);
|
|
|
|
const [activeTab, setActiveTab] = useState("datatype");
|
|
|
|
/* ───── PLC 데이터타입 ───── */
|
|
const [datatypes, setDatatypes] = useState<any[]>([]);
|
|
const [dtLoading, setDtLoading] = useState(false);
|
|
const [dtCount, setDtCount] = useState(0);
|
|
const [dtChecked, setDtChecked] = useState<string[]>([]);
|
|
const [dtModalOpen, setDtModalOpen] = useState(false);
|
|
const [dtEditMode, setDtEditMode] = useState(false);
|
|
const [dtForm, setDtForm] = useState<Record<string, any>>({});
|
|
const [dtSaving, setDtSaving] = useState(false);
|
|
const [dtKeyword, setDtKeyword] = useState("");
|
|
|
|
/* ───── 수집 설정 ───── */
|
|
const [configs, setConfigs] = useState<any[]>([]);
|
|
const [cfgLoading, setCfgLoading] = useState(false);
|
|
const [cfgCount, setCfgCount] = useState(0);
|
|
const [cfgChecked, setCfgChecked] = useState<string[]>([]);
|
|
const [cfgModalOpen, setCfgModalOpen] = useState(false);
|
|
const [cfgEditMode, setCfgEditMode] = useState(false);
|
|
const [cfgForm, setCfgForm] = useState<Record<string, any>>({});
|
|
const [cfgSaving, setCfgSaving] = useState(false);
|
|
const [cfgKeyword, setCfgKeyword] = useState("");
|
|
|
|
/* ───── FK + 카테고리 옵션 ───── */
|
|
const [equipOptions, setEquipOptions] = useState<{ code: string; label: string }[]>([]);
|
|
const [collectionTypeOptions, setCollectionTypeOptions] = useState<{ code: string; label: string }[]>([]);
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
|
|
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
|
|
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
|
|
} catch { /* skip */ }
|
|
try {
|
|
const catRes = await apiClient.get(`/table-categories/${COLLECTION_TABLE}/collection_type/values`);
|
|
if (catRes.data?.data?.length > 0) {
|
|
setCollectionTypeOptions(flattenCategories(catRes.data.data));
|
|
}
|
|
} catch { /* skip */ }
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
|
const fetchDatatypes = useCallback(async (keyword?: string) => {
|
|
setDtLoading(true);
|
|
try {
|
|
const kw = keyword !== undefined ? keyword : dtKeyword;
|
|
const filters: any[] = [];
|
|
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
|
|
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setDatatypes(rows);
|
|
setDtCount(rows.length);
|
|
} catch { toast.error("PLC 데이터타입 조회에 실패했어요"); }
|
|
finally { setDtLoading(false); }
|
|
}, [dtKeyword]);
|
|
|
|
const fetchConfigs = useCallback(async (keyword?: string) => {
|
|
setCfgLoading(true);
|
|
try {
|
|
const kw = keyword !== undefined ? keyword : cfgKeyword;
|
|
const filters: any[] = [];
|
|
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
|
|
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setConfigs(rows);
|
|
setCfgCount(rows.length);
|
|
} catch { toast.error("수집 설정 조회에 실패했어요"); }
|
|
finally { setCfgLoading(false); }
|
|
}, [cfgKeyword]);
|
|
|
|
useEffect(() => { fetchDatatypes(); fetchConfigs(); }, []);
|
|
|
|
/* ═══════════════════ 데이터타입 CRUD ═══════════════════ */
|
|
const openDtCreate = () => { setDtForm({}); setDtEditMode(false); setDtModalOpen(true); };
|
|
const openDtEdit = (row: any) => { setDtForm({ ...row }); setDtEditMode(true); setDtModalOpen(true); };
|
|
const saveDt = async () => {
|
|
if (!dtForm.equipment_code) { toast.error("설비코드는 필수 입력이에요"); return; }
|
|
setDtSaving(true);
|
|
try {
|
|
if (dtEditMode) {
|
|
await apiClient.put(`/table-management/tables/${DATATYPE_TABLE}/edit`, {
|
|
originalData: { id: dtForm.id }, updatedData: dtForm,
|
|
});
|
|
toast.success("PLC 데이터타입을 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/add`, dtForm);
|
|
toast.success("PLC 데이터타입을 등록했어요");
|
|
}
|
|
setDtModalOpen(false);
|
|
fetchDatatypes();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setDtSaving(false); }
|
|
};
|
|
const deleteDt = async () => {
|
|
if (dtChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("PLC 데이터타입 삭제", { description: `선택한 ${dtChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${DATATYPE_TABLE}/delete`, {
|
|
data: dtChecked.map(id => ({ id })),
|
|
});
|
|
toast.success(`${dtChecked.length}건을 삭제했어요`);
|
|
setDtChecked([]);
|
|
fetchDatatypes();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ 수집 설정 CRUD ═══════════════════ */
|
|
const openCfgCreate = () => { setCfgForm({}); setCfgEditMode(false); setCfgModalOpen(true); };
|
|
const openCfgEdit = (row: any) => { setCfgForm({ ...row }); setCfgEditMode(true); setCfgModalOpen(true); };
|
|
const saveCfg = async () => {
|
|
if (!cfgForm.config_name) { toast.error("설정명은 필수 입력이에요"); return; }
|
|
setCfgSaving(true);
|
|
try {
|
|
if (cfgEditMode) {
|
|
await apiClient.put(`/table-management/tables/${COLLECTION_TABLE}/edit`, {
|
|
originalData: { id: cfgForm.id }, updatedData: cfgForm,
|
|
});
|
|
toast.success("수집 설정을 수정했어요");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/add`, cfgForm);
|
|
toast.success("수집 설정을 등록했어요");
|
|
}
|
|
setCfgModalOpen(false);
|
|
fetchConfigs();
|
|
} catch { toast.error("저장에 실패했어요"); }
|
|
finally { setCfgSaving(false); }
|
|
};
|
|
const deleteCfg = async () => {
|
|
if (cfgChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
|
const ok = await confirm("수집 설정 삭제", { description: `선택한 ${cfgChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${COLLECTION_TABLE}/delete`, {
|
|
data: cfgChecked.map(id => ({ id })),
|
|
});
|
|
toast.success(`${cfgChecked.length}건을 삭제했어요`);
|
|
setCfgChecked([]);
|
|
fetchConfigs();
|
|
} catch { toast.error("삭제에 실패했어요"); }
|
|
};
|
|
|
|
/* ═══════════════════ JSX ═══════════════════ */
|
|
return (
|
|
<div className="flex flex-col gap-3 p-3">
|
|
{ConfirmDialogComponent}
|
|
|
|
<div className="rounded-lg border bg-card">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<div className="border-b px-3">
|
|
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
|
<TabsTrigger
|
|
value="datatype"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
|
>
|
|
<Cpu className="w-4 h-4 mr-2" />
|
|
PLC 데이터타입
|
|
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{dtCount}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="collection"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
|
>
|
|
<Settings2 className="w-4 h-4 mr-2" />
|
|
수집 설정
|
|
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{cfgCount}</Badge>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* ──── PLC 데이터타입 탭 ──── */}
|
|
<TabsContent value="datatype" className="p-3 mt-0">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="h-8 pl-8 w-56 text-sm"
|
|
placeholder="설비코드 검색..."
|
|
value={dtKeyword}
|
|
onChange={(e) => setDtKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && fetchDatatypes(dtKeyword)}
|
|
/>
|
|
</div>
|
|
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchDatatypes(dtKeyword)}>
|
|
<Search className="w-3.5 h-3.5 mr-1" />검색
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setDtKeyword(""); fetchDatatypes(""); }}>
|
|
초기화
|
|
</Button>
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary">{dtCount}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openDtCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = datatypes.find(r => dtChecked.includes(r.id));
|
|
if (sel) openDtEdit(sel); else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={deleteDt}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={datatypes.length > 0 && dtChecked.length === datatypes.length}
|
|
onCheckedChange={(v) => setDtChecked(v ? datatypes.map(r => r.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{dtLoading ? (
|
|
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
) : datatypes.length === 0 ? (
|
|
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 PLC 데이터타입이 없어요</p></TableCell></TableRow>
|
|
) : datatypes.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", dtChecked.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setDtChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openDtEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={dtChecked.includes(row.id)} onCheckedChange={(v) => setDtChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
{ts.visibleColumns.map((col) => (
|
|
<TableCell key={col.key} className={col.key === "is_active" ? "text-center" : ""}>
|
|
{col.key === "is_active"
|
|
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
: row[col.key] ?? ""}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ──── 수집 설정 탭 ──── */}
|
|
<TabsContent value="collection" className="p-3 mt-0">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="h-8 pl-8 w-56 text-sm"
|
|
placeholder="설정명 검색..."
|
|
value={cfgKeyword}
|
|
onChange={(e) => setCfgKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && fetchConfigs(cfgKeyword)}
|
|
/>
|
|
</div>
|
|
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchConfigs(cfgKeyword)}>
|
|
<Search className="w-3.5 h-3.5 mr-1" />검색
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setCfgKeyword(""); fetchConfigs(""); }}>
|
|
초기화
|
|
</Button>
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary">{cfgCount}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={openCfgCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
|
<Button size="sm" variant="outline" onClick={() => {
|
|
const sel = configs.find(r => cfgChecked.includes(r.id));
|
|
if (sel) openCfgEdit(sel); else toast.error("수정할 항목을 선택해주세요");
|
|
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
|
<Button size="sm" variant="destructive" onClick={deleteCfg}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
|
</div>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={configs.length > 0 && cfgChecked.length === configs.length}
|
|
onCheckedChange={(v) => setCfgChecked(v ? configs.map(r => r.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설정명</TableHead>
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스연결ID</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스테이블</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">대상테이블</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수집유형</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">스케줄(Cron)</TableHead>
|
|
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{cfgLoading ? (
|
|
<TableRow><TableCell colSpan={8} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
) : configs.length === 0 ? (
|
|
<TableRow><TableCell colSpan={8} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 수집 설정이 없어요</p></TableCell></TableRow>
|
|
) : configs.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn("cursor-pointer", cfgChecked.includes(row.id) && "bg-primary/5")}
|
|
onClick={() => setCfgChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
onDoubleClick={() => openCfgEdit(row)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={cfgChecked.includes(row.id)} onCheckedChange={(v) => setCfgChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
</TableCell>
|
|
<TableCell>{row.config_name}</TableCell>
|
|
<TableCell>{row.source_connection_id}</TableCell>
|
|
<TableCell>{row.source_table}</TableCell>
|
|
<TableCell>{row.target_table}</TableCell>
|
|
<TableCell>{row.collection_type}</TableCell>
|
|
<TableCell className="font-mono text-[13px]">{row.schedule_cron}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* ═══════════════════ PLC 데이터타입 모달 ═══════════════════ */}
|
|
<Dialog open={dtModalOpen} onOpenChange={setDtModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{dtEditMode ? "PLC 데이터타입 수정" : "PLC 데이터타입 등록"}</DialogTitle>
|
|
<DialogDescription>PLC 데이터타입 정보를 입력해주세요</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">설비코드 <span className="text-destructive">*</span></Label>
|
|
<Select value={dtForm.equipment_code || ""} onValueChange={(v) => setDtForm(p => ({ ...p, equipment_code: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="설비를 선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{equipOptions.map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">데이터타입</Label>
|
|
<Input className="h-9" value={dtForm.data_type || ""} onChange={(e) => setDtForm(p => ({ ...p, data_type: e.target.value }))} placeholder="예: 온도, 압력" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">단위</Label>
|
|
<Input className="h-9" value={dtForm.unit || ""} onChange={(e) => setDtForm(p => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, bar" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">태그주소</Label>
|
|
<Input className="h-9" value={dtForm.tag_address || ""} onChange={(e) => setDtForm(p => ({ ...p, tag_address: e.target.value }))} placeholder="예: D100" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">수집주기</Label>
|
|
<Input className="h-9" value={dtForm.collection_interval || ""} onChange={(e) => setDtForm(p => ({ ...p, collection_interval: e.target.value }))} placeholder="예: 1000ms" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">하한값</Label>
|
|
<Input className="h-9" type="number" value={dtForm.lower_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, lower_limit: e.target.value }))} placeholder="하한값" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">상한값</Label>
|
|
<Input className="h-9" type="number" value={dtForm.upper_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, upper_limit: e.target.value }))} placeholder="상한값" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked={dtForm.is_active ?? true} onCheckedChange={(v) => setDtForm(p => ({ ...p, is_active: !!v }))} />
|
|
<Label className="text-sm">사용</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDtModalOpen(false)}>취소</Button>
|
|
<Button onClick={saveDt} disabled={dtSaving}>
|
|
{dtSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장해요
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ═══════════════════ 수집 설정 모달 ═══════════════════ */}
|
|
<Dialog open={cfgModalOpen} onOpenChange={setCfgModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{cfgEditMode ? "수집 설정 수정" : "수집 설정 등록"}</DialogTitle>
|
|
<DialogDescription>수집 설정 정보를 입력해주세요</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">설정명 <span className="text-destructive">*</span></Label>
|
|
<Input className="h-9" value={cfgForm.config_name || ""} onChange={(e) => setCfgForm(p => ({ ...p, config_name: e.target.value }))} placeholder="설정명을 입력해주세요" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">소스연결ID</Label>
|
|
<Input className="h-9" value={cfgForm.source_connection_id || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_connection_id: e.target.value }))} placeholder="소스연결ID" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">수집유형</Label>
|
|
{collectionTypeOptions.length > 0 ? (
|
|
<Select value={cfgForm.collection_type || ""} onValueChange={(v) => setCfgForm(p => ({ ...p, collection_type: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
|
<SelectContent>
|
|
{collectionTypeOptions.map(o => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input className="h-9" value={cfgForm.collection_type || ""} onChange={(e) => setCfgForm(p => ({ ...p, collection_type: e.target.value }))} placeholder="수집유형" />
|
|
)}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">소스테이블</Label>
|
|
<Input className="h-9" value={cfgForm.source_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_table: e.target.value }))} placeholder="소스테이블명" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold text-muted-foreground">대상테이블</Label>
|
|
<Input className="h-9" value={cfgForm.target_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, target_table: e.target.value }))} placeholder="대상테이블명" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs font-semibold text-muted-foreground">스케줄 (Cron)</Label>
|
|
<Input className="h-9 font-mono text-sm" value={cfgForm.schedule_cron || ""} onChange={(e) => setCfgForm(p => ({ ...p, schedule_cron: e.target.value }))} placeholder="예: */5 * * * * (5분마다)" />
|
|
{cfgForm.schedule_cron && (
|
|
<p className="text-xs text-muted-foreground mt-1">{cronToKorean(cfgForm.schedule_cron)}</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked={cfgForm.is_active ?? true} onCheckedChange={(v) => setCfgForm(p => ({ ...p, is_active: !!v }))} />
|
|
<Label className="text-sm">사용</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCfgModalOpen(false)}>취소</Button>
|
|
<Button onClick={saveCfg} disabled={cfgSaving}>
|
|
{cfgSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장해요
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|