Files
pipeline/frontend/components/admin/FileReaderConnectionModal.tsx
T
chpark 6a1b79dc81
Build and Push Images / build-and-push (push) Has been cancelled
feat(file-reader): CSV/TXT/Excel 파일 데이터 소스 — UI 업로드 + 폴더 감시 + 매핑 + 적재
- 신규 테이블 file_reader_configs / file_reader_mappings / file_reader_history (마이그레이션 313)
- 파서: csv-parse + xlsx 라이브러리 추가, CSV/TSV/TXT/XLSX 통합 파서 (parsers.ts)
- 서비스: 파일→매핑→타겟 DB INSERT/UPSERT/REPLACE, 호스트 경로 허용 루트 검증
- 스케줄러: source_mode='watch' 설정마다 node-cron 등록, 1분 주기 reload
- 라우트: /api/file-reader/configs CRUD + preview + run-upload + run-watch + history
- 프론트: 데이터 소스 페이지 "파일 리더" 탭 placeholder → FileReaderConnectionList 컴포넌트
- FileReaderConnectionModal: 기본/파싱/타겟/매핑 통합 폼 + 샘플 업로드 미리보기
- 환경변수 FILE_READER_ALLOWED_ROOTS (콤마 구분, 기본 /home/wace/file-imports,/mnt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:38:10 +09:00

441 lines
18 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, Upload, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
FileReaderAPI, FileReaderConfig, FileReaderMapping, FileType, SourceMode, SaveMode,
} from "@/lib/api/fileReader";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
interface Props {
open: boolean;
editingId: number | null;
onClose: () => void;
onSaved: () => void;
}
const defaultConfig: Partial<FileReaderConfig> = {
name: "",
company_code: "*",
file_type: "csv",
source_mode: "upload",
has_header: true,
delimiter: ",",
encoding: "utf-8",
skip_rows: 0,
target_schema: "public",
save_mode: "INSERT",
is_active: "Y",
file_pattern: "*.csv",
processed_action: "mark",
};
export const FileReaderConnectionModal: React.FC<Props> = ({ open, editingId, onClose, onSaved }) => {
const { toast } = useToast();
const [cfg, setCfg] = useState<Partial<FileReaderConfig>>(defaultConfig);
const [mappings, setMappings] = useState<FileReaderMapping[]>([]);
const [dbList, setDbList] = useState<Array<{ id: number; connection_name: string }>>([]);
const [allowedRoots, setAllowedRoots] = useState<string[]>([]);
const [previewHeaders, setPreviewHeaders] = useState<string[]>([]);
const [previewRows, setPreviewRows] = useState<Record<string, unknown>[]>([]);
const [previewTotal, setPreviewTotal] = useState(0);
const [saving, setSaving] = useState(false);
const [tempId, setTempId] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
void (async () => {
try {
const roots = await FileReaderAPI.allowedRoots();
setAllowedRoots(roots);
} catch {/* ignore */}
try {
const dbs = await ExternalDbConnectionAPI.getConnections();
setDbList((dbs as any[]).map(d => ({ id: d.id, connection_name: d.connection_name })));
} catch {/* ignore */}
if (editingId) {
try {
const { config, mappings: m } = await FileReaderAPI.get(editingId);
setCfg(config);
setMappings(m);
setTempId(editingId);
} catch (e) {
toast({ title: "조회 실패", description: (e as Error).message, variant: "destructive" });
}
} else {
setCfg(defaultConfig);
setMappings([]);
setTempId(null);
}
setPreviewHeaders([]);
setPreviewRows([]);
setPreviewTotal(0);
})();
}, [open, editingId]);
const addMapping = (fromCol?: string) => {
setMappings(m => [...m, {
from_column: fromCol || "",
from_column_type: "header",
to_column: fromCol || "",
to_data_type: "TEXT",
is_required: false,
mapping_order: m.length,
}]);
};
const updateMapping = (i: number, patch: Partial<FileReaderMapping>) => {
setMappings(m => m.map((x, idx) => idx === i ? { ...x, ...patch } : x));
};
const removeMapping = (i: number) => {
setMappings(m => m.filter((_, idx) => idx !== i));
};
const handlePreview = async () => {
// 미리보기는 저장된 config 필요 — 임시 저장 후 preview
let id = tempId;
if (!id) {
try {
id = await FileReaderAPI.create({ ...cfg, name: cfg.name || `temp_${Date.now()}` });
setTempId(id);
} catch (e) {
toast({ title: "임시 저장 실패", description: (e as Error).message, variant: "destructive" });
return;
}
} else {
try {
await FileReaderAPI.update(id, cfg);
} catch (e) {
toast({ title: "설정 동기화 실패", description: (e as Error).message, variant: "destructive" });
return;
}
}
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".csv,.tsv,.txt,.xlsx,.xls";
inp.onchange = async () => {
if (!inp.files || inp.files.length === 0) return;
try {
const r = await FileReaderAPI.preview(id!, inp.files[0]);
setPreviewHeaders(r.headers);
setPreviewRows(r.sampleRows);
setPreviewTotal(r.totalRows);
toast({ title: "미리보기", description: `${r.totalRows}행 / 헤더 ${r.headers.length}` });
} catch (e) {
toast({ title: "미리보기 실패", description: (e as Error).message, variant: "destructive" });
}
};
inp.click();
};
const handleSave = async () => {
if (!cfg.name) {
toast({ title: "이름 필수", variant: "destructive" });
return;
}
setSaving(true);
try {
const payload = { ...cfg, mappings };
if (tempId) {
await FileReaderAPI.update(tempId, payload);
} else {
await FileReaderAPI.create(payload);
}
toast({ title: "저장 완료" });
onSaved();
} catch (e) {
toast({ title: "저장 실패", description: (e as Error).message, variant: "destructive" });
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingId ? "파일 리더 수정" : "새 파일 리더"}</DialogTitle>
</DialogHeader>
{/* 기본 정보 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={cfg.name || ""} onChange={e => setCfg(c => ({ ...c, name: e.target.value }))} />
</div>
<div>
<Label> </Label>
<Select value={cfg.file_type}
onValueChange={(v: FileType) => setCfg(c => ({ ...c, file_type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV</SelectItem>
<SelectItem value="tsv">TSV</SelectItem>
<SelectItem value="txt">TXT</SelectItem>
<SelectItem value="xlsx">Excel (xlsx)</SelectItem>
<SelectItem value="xls">Excel (xls)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> </Label>
<Select value={cfg.source_mode}
onValueChange={(v: SourceMode) => setCfg(c => ({ ...c, source_mode: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="upload">UI (1 )</SelectItem>
<SelectItem value="watch"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Switch checked={cfg.is_active === "Y"}
onCheckedChange={(v) => setCfg(c => ({ ...c, is_active: v ? "Y" : "N" }))} />
<span className="text-sm pb-2"></span>
</div>
{cfg.source_mode === "watch" && (
<>
<div className="col-span-2">
<Label> ( )</Label>
<Input value={cfg.host_path || ""} placeholder="/home/wace/file-imports/erp"
onChange={e => setCfg(c => ({ ...c, host_path: e.target.value }))} />
<div className="text-xs text-muted-foreground mt-1">
: {allowedRoots.length ? allowedRoots.join(", ") : "(로드 중)"}
</div>
</div>
<div>
<Label> (glob)</Label>
<Input value={cfg.file_pattern || "*"} placeholder="*.csv"
onChange={e => setCfg(c => ({ ...c, file_pattern: e.target.value }))} />
</div>
<div>
<Label> (cron 5/6)</Label>
<Input value={cfg.cron_schedule || ""} placeholder="*/5 * * * *"
onChange={e => setCfg(c => ({ ...c, cron_schedule: e.target.value }))} />
</div>
<div>
<Label> </Label>
<Select value={cfg.processed_action || "mark"}
onValueChange={v => setCfg(c => ({ ...c, processed_action: v as any }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mark">DB에 </SelectItem>
<SelectItem value="archive">_processed </SelectItem>
<SelectItem value="delete"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>archive </Label>
<Input value={cfg.archive_subdir || "_processed"}
onChange={e => setCfg(c => ({ ...c, archive_subdir: e.target.value }))} />
</div>
</>
)}
{/* 파싱 옵션 */}
<div className="flex items-end gap-2">
<Switch checked={cfg.has_header !== false}
onCheckedChange={(v) => setCfg(c => ({ ...c, has_header: v }))} />
<span className="text-sm pb-2"> </span>
</div>
<div>
<Label> (CSV/TSV/TXT)</Label>
<Input value={cfg.delimiter || ","}
onChange={e => setCfg(c => ({ ...c, delimiter: e.target.value }))} />
</div>
<div>
<Label></Label>
<Input value={cfg.encoding || "utf-8"}
onChange={e => setCfg(c => ({ ...c, encoding: e.target.value }))} />
</div>
<div>
<Label> </Label>
<Input type="number" value={cfg.skip_rows ?? 0}
onChange={e => setCfg(c => ({ ...c, skip_rows: Number(e.target.value) }))} />
</div>
{(cfg.file_type === "xlsx" || cfg.file_type === "xls") && (
<div className="col-span-2">
<Label> ( )</Label>
<Input value={cfg.sheet_name || ""}
onChange={e => setCfg(c => ({ ...c, sheet_name: e.target.value }))} />
</div>
)}
{/* 타겟 DB */}
<div>
<Label> DB</Label>
<Select value={cfg.target_db_id ? String(cfg.target_db_id) : ""}
onValueChange={v => setCfg(c => ({ ...c, target_db_id: Number(v) }))}>
<SelectTrigger><SelectValue placeholder="DB 선택" /></SelectTrigger>
<SelectContent>
{dbList.map(d => (
<SelectItem key={d.id} value={String(d.id)}>{d.connection_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label> </Label>
<Select value={cfg.save_mode || "INSERT"}
onValueChange={(v: SaveMode) => setCfg(c => ({ ...c, save_mode: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="INSERT">INSERT</SelectItem>
<SelectItem value="UPSERT">UPSERT</SelectItem>
<SelectItem value="REPLACE">REPLACE ( INSERT)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> </Label>
<Input value={cfg.target_schema || "public"}
onChange={e => setCfg(c => ({ ...c, target_schema: e.target.value }))} />
</div>
<div>
<Label> </Label>
<Input value={cfg.target_table || ""}
onChange={e => setCfg(c => ({ ...c, target_table: e.target.value }))} />
</div>
{cfg.save_mode === "UPSERT" && (
<div className="col-span-2">
<Label>UPSERT conflict ( )</Label>
<Input value={cfg.conflict_keys || ""} placeholder="id,date"
onChange={e => setCfg(c => ({ ...c, conflict_keys: e.target.value }))} />
</div>
)}
<div className="col-span-2">
<Label></Label>
<Textarea value={cfg.description || ""}
onChange={e => setCfg(c => ({ ...c, description: e.target.value }))} />
</div>
</div>
{/* 미리보기 */}
<div className="border-t pt-3 mt-2">
<div className="flex items-center justify-between mb-2">
<Label className="text-base"> </Label>
<Button variant="outline" size="sm" onClick={handlePreview}>
<Upload className="w-3 h-3 mr-1" /> +
</Button>
</div>
{previewHeaders.length > 0 && (
<div className="text-xs space-y-2">
<div className="text-muted-foreground">
{previewHeaders.length} · {previewTotal} ( {previewRows.length})
</div>
<div className="overflow-x-auto border rounded">
<table className="text-xs">
<thead>
<tr>
{previewHeaders.map(h => (
<th key={h} className="border-b px-2 py-1 bg-muted text-left font-mono">{h}</th>
))}
</tr>
</thead>
<tbody>
{previewRows.slice(0, 10).map((row, i) => (
<tr key={i}>
{previewHeaders.map(h => (
<td key={h} className="border-b px-2 py-1 font-mono">
{row[h] === null || row[h] === undefined ? "" : String(row[h])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<Button variant="outline" size="sm"
onClick={() => previewHeaders.forEach(h => addMapping(h))}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
)}
</div>
{/* 매핑 */}
<div className="border-t pt-3 mt-2">
<div className="flex items-center justify-between mb-2">
<Label className="text-base"> ({mappings.length})</Label>
<Button variant="outline" size="sm" onClick={() => addMapping()}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
{mappings.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-4 border rounded">
. "모든 헤더 → 매핑에 추가" .
</div>
) : (
<div className="space-y-1 max-h-80 overflow-y-auto">
<div className="grid grid-cols-12 gap-1 text-xs text-muted-foreground px-1">
<div className="col-span-3"> /</div>
<div className="col-span-1"></div>
<div className="col-span-3"> </div>
<div className="col-span-2"> </div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
</div>
{mappings.map((m, i) => (
<div key={i} className="grid grid-cols-12 gap-1">
<Input className="col-span-3 text-xs" value={m.from_column}
onChange={e => updateMapping(i, { from_column: e.target.value })} />
<Select value={m.from_column_type}
onValueChange={v => updateMapping(i, { from_column_type: v as any })}>
<SelectTrigger className="col-span-1 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="header"></SelectItem>
<SelectItem value="index"></SelectItem>
</SelectContent>
</Select>
<Input className="col-span-3 text-xs" value={m.to_column}
onChange={e => updateMapping(i, { to_column: e.target.value })} />
<Select value={m.to_data_type}
onValueChange={v => updateMapping(i, { to_data_type: v })}>
<SelectTrigger className="col-span-2 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">TEXT</SelectItem>
<SelectItem value="INTEGER">INTEGER</SelectItem>
<SelectItem value="NUMERIC">NUMERIC</SelectItem>
<SelectItem value="BOOLEAN">BOOLEAN</SelectItem>
<SelectItem value="DATE">DATE</SelectItem>
<SelectItem value="TIMESTAMP">TIMESTAMP</SelectItem>
<SelectItem value="TIMESTAMPTZ">TIMESTAMPTZ</SelectItem>
<SelectItem value="JSONB">JSONB</SelectItem>
</SelectContent>
</Select>
<Input className="col-span-2 text-xs" placeholder="TRIM / x*1000"
value={m.transform_expr || ""}
onChange={e => updateMapping(i, { transform_expr: e.target.value })} />
<Button variant="ghost" size="sm" className="col-span-1"
onClick={() => removeMapping(i)}>
<Trash2 className="w-3 h-3 text-rose-600" />
</Button>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}><X className="w-3 h-3 mr-1" /> </Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "저장 중..." : "저장"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default FileReaderConnectionModal;