6a1b79dc81
Build and Push Images / build-and-push (push) Has been cancelled
- 신규 테이블 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>
205 lines
8.2 KiB
TypeScript
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;
|