Files
pipeline/frontend/app/(main)/COMPANY_10/equipment/plc-settings/page.tsx
T

502 lines
27 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 { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
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">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.key === "is_active" ? "center" : undefined,
render: col.key === "is_active"
? (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge>
: undefined,
}))}
data={ts.groupData(datatypes)}
loading={dtLoading}
emptyMessage="등록된 PLC 데이터타입이 없어요"
showCheckbox
checkedIds={dtChecked}
onCheckedChange={setDtChecked}
onRowDoubleClick={(row) => openDtEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</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">
<EDataTable
columns={[
{ key: "config_name", label: "설정명" },
{ key: "source_connection_id", label: "소스연결ID", width: "w-[110px]" },
{ key: "source_table", label: "소스테이블", width: "w-[120px]" },
{ key: "target_table", label: "대상테이블", width: "w-[120px]" },
{ key: "collection_type", label: "수집유형", width: "w-[90px]" },
{ key: "schedule_cron", label: "스케줄(Cron)", width: "w-[120px]", render: (val: any) => <span className="font-mono text-[13px]">{val}</span> },
{ key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge> },
] as EDataTableColumn[]}
data={configs}
loading={cfgLoading}
emptyMessage="등록된 수집 설정이 없어요"
showCheckbox
checkedIds={cfgChecked}
onCheckedChange={setCfgChecked}
onRowDoubleClick={(row) => openCfgEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</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>
);
}