Files
wace_rps/frontend/app/(main)/COMPANY_16/equipment/plc-settings/page.tsx
T
DDD1542 d03f92947d feat: Implement dynamic search filter in Shipping Plan page
- 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.
2026-04-03 09:28:59 +09:00

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>
);
}