Files
pipeline/frontend/components/admin/FileReaderConnectionList.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

205 lines
8.2 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import { Plus, Pencil, Trash2, Play, FileText, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { FileReaderAPI, FileReaderConfig } from "@/lib/api/fileReader";
import { FileReaderConnectionModal } from "./FileReaderConnectionModal";
const RESULT_BADGE: Record<string, string> = {
success: "bg-green-100 text-green-700",
partial: "bg-amber-100 text-amber-700",
failure: "bg-rose-100 text-rose-700",
skipped: "bg-gray-100 text-gray-600",
};
export const FileReaderConnectionList: React.FC = () => {
const { toast } = useToast();
const [items, setItems] = useState<FileReaderConfig[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [confirmDel, setConfirmDel] = useState<FileReaderConfig | null>(null);
const load = async () => {
setLoading(true);
try {
const list = await FileReaderAPI.list();
setItems(list);
} catch (e) {
toast({ title: "조회 실패", description: (e as Error).message, variant: "destructive" });
} finally {
setLoading(false);
}
};
useEffect(() => { void load(); }, []);
const handleRunUpload = async (cfg: FileReaderConfig) => {
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.runUpload(cfg.id!, inp.files[0]);
toast({
title: r.status === "failure" ? "적재 실패" : "적재 완료",
description: `${r.rowsTotal}행 / 성공 ${r.rowsInserted} / 실패 ${r.rowsFailed}`,
variant: r.status === "failure" ? "destructive" : "default",
});
void load();
} catch (e) {
toast({ title: "실패", description: (e as Error).message, variant: "destructive" });
}
};
inp.click();
};
const handleRunWatch = async (cfg: FileReaderConfig) => {
try {
const r = await FileReaderAPI.runWatch(cfg.id!);
toast({
title: "스캔 완료",
description: `스캔 ${r.scanned} / 처리 ${r.processed} / 스킵 ${r.skipped} / 실패 ${r.failed}`,
});
void load();
} catch (e) {
toast({ title: "스캔 실패", description: (e as Error).message, variant: "destructive" });
}
};
const filtered = items.filter(i =>
!search || i.name.toLowerCase().includes(search.toLowerCase()) ||
(i.target_table || "").toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<Input
placeholder="이름/타겟 테이블로 검색"
value={search}
onChange={e => setSearch(e.target.value)}
className="max-w-sm"
/>
<div className="flex gap-2">
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RefreshCcw className="w-4 h-4 mr-1" />
</Button>
<Button onClick={() => { setEditingId(null); setModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{filtered.length === 0 ? (
<div className="border rounded-md p-12 text-center text-muted-foreground">
<FileText className="w-10 h-10 mx-auto mb-3 opacity-50" />
. "새 파일 리더" .
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{filtered.map(cfg => (
<div key={cfg.id} className="border rounded-md p-4 bg-card flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold text-base flex items-center gap-2">
<FileText className="w-4 h-4" /> {cfg.name}
</div>
<div className="text-xs text-muted-foreground mt-1">
{cfg.file_type.toUpperCase()} · {cfg.source_mode === "watch" ? "감시 폴더" : "업로드"} ·
{" "}target: {cfg.target_schema || "public"}.{cfg.target_table || "(미지정)"}
</div>
</div>
<Badge className={cfg.is_active === "Y" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"}>
{cfg.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
{cfg.source_mode === "watch" && cfg.host_path && (
<div className="text-xs font-mono break-all text-muted-foreground">
📁 {cfg.host_path}/{cfg.file_pattern || "*"}
</div>
)}
{cfg.last_run_at && (
<div className="text-xs flex items-center gap-2">
<Badge className={RESULT_BADGE[cfg.last_run_result || ""] || "bg-gray-100"}>
{cfg.last_run_result || "-"}
</Badge>
<span className="text-muted-foreground">
{new Date(cfg.last_run_at).toLocaleString("ko-KR")} · {cfg.last_processed_count || 0}
</span>
</div>
)}
<div className="flex items-center gap-1 mt-2">
{cfg.source_mode === "watch" ? (
<Button size="sm" variant="outline" onClick={() => handleRunWatch(cfg)}>
<Play className="w-3 h-3 mr-1" />
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => handleRunUpload(cfg)}>
<Play className="w-3 h-3 mr-1" /> +
</Button>
)}
<Button size="sm" variant="ghost"
onClick={() => { setEditingId(cfg.id!); setModalOpen(true); }}>
<Pencil className="w-3 h-3" />
</Button>
<Button size="sm" variant="ghost"
onClick={() => setConfirmDel(cfg)}>
<Trash2 className="w-3 h-3 text-rose-600" />
</Button>
</div>
</div>
))}
</div>
)}
{modalOpen && (
<FileReaderConnectionModal
open={modalOpen}
editingId={editingId}
onClose={() => setModalOpen(false)}
onSaved={() => { setModalOpen(false); void load(); }}
/>
)}
<AlertDialog open={!!confirmDel} onOpenChange={(o) => !o && setConfirmDel(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{confirmDel?.name}" , . ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!confirmDel) return;
try {
await FileReaderAPI.remove(confirmDel.id!);
toast({ title: "삭제 완료" });
setConfirmDel(null);
void load();
} catch (e) {
toast({ title: "삭제 실패", description: (e as Error).message, variant: "destructive" });
}
}}
></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default FileReaderConnectionList;