summaryrefslogtreecommitdiff
path: root/lib/swp/table
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-24 19:44:04 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-24 19:44:04 +0900
commit231c4eb86771a44b24248ca403fcbb8c44fff74b (patch)
tree90725c5c216058223bf2ccd9a9d710a8003a037e /lib/swp/table
parent39fc95095ac4b99186294f21fe6d8ac0cfab1f6e (diff)
(김준회) SWP 파일 업로드 처리, 다운로드는 임시 처리(네트워크경로에서 다운로드받도록)
Diffstat (limited to 'lib/swp/table')
-rw-r--r--lib/swp/table/swp-help-dialog.tsx153
-rw-r--r--lib/swp/table/swp-table-columns.tsx83
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx139
3 files changed, 351 insertions, 24 deletions
diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx
new file mode 100644
index 00000000..18f29644
--- /dev/null
+++ b/lib/swp/table/swp-help-dialog.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { HelpCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+
+export function SwpUploadHelpDialog() {
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <HelpCircle className="h-4 w-4" />
+ 업로드 가이드
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl" opacityControl={false}>
+ <DialogHeader>
+ <DialogTitle>파일 업로드 가이드</DialogTitle>
+ <DialogDescription>
+ 올바른 파일명 형식으로 업로드해주세요
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 파일명 형식 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-semibold">📋 파일명 형식</h3>
+ <div className="rounded-lg bg-muted p-4 font-mono text-sm">
+ [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+ </div>
+ <p className="text-xs text-muted-foreground">
+ ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다
+ </p>
+ </div>
+
+ {/* 각 항목 설명 - 1라인 형태 */}
+ <div className="space-y-3">
+ <h3 className="text-sm font-semibold">📝 항목 설명</h3>
+
+ <div className="flex items-center gap-3 rounded-lg border p-3">
+ <Badge variant="secondary" className="font-mono shrink-0">
+ OWN_DOC_NO
+ </Badge>
+ <div className="text-sm">
+ <span className="font-medium">벤더의 문서번호</span>
+ <span className="text-muted-foreground"> - 프로젝트마다 유니크해야 합니다</span>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 rounded-lg border p-3">
+ <Badge variant="secondary" className="font-mono shrink-0">
+ REV_NO
+ </Badge>
+ <div className="text-sm">
+ <span className="font-medium">리비전 번호</span>
+ <span className="text-muted-foreground"> - 보통 01, 02 같은 식으로 피드백에 따라 증가합니다</span>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 rounded-lg border p-3">
+ <Badge variant="secondary" className="font-mono shrink-0">
+ STAGE
+ </Badge>
+ <div className="text-sm">
+ <span className="font-medium">스테이지</span>
+ <span className="text-muted-foreground"> - 중공업이 설정한 스테이지입니다 (예: IFA, IFC, AFC, BFC)</span>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 rounded-lg border p-3">
+ <Badge variant="secondary" className="font-mono shrink-0">
+ YYYYMMDDhhmmss
+ </Badge>
+ <div className="text-sm">
+ <span className="font-medium">날짜 및 시간</span>
+ <span className="text-muted-foreground"> - 업로드 날짜 정보를 기입합니다 (14자리 숫자)</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 예시 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-semibold">✅ 올바른 예시</h3>
+ <div className="space-y-2">
+ <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
+ <code className="text-xs font-mono text-green-700 dark:text-green-300">
+ VD-DOC-001_01_IFA_20250124143000.pdf
+ </code>
+ </div>
+ <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
+ <code className="text-xs font-mono text-green-700 dark:text-green-300">
+ TECH-SPEC-002_02_IFC_20250124150000.dwg
+ </code>
+ </div>
+ </div>
+ </div>
+
+ {/* 잘못된 예시 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-semibold">❌ 잘못된 예시</h3>
+ <div className="space-y-2">
+ <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+ <code className="text-xs font-mono text-red-700 dark:text-red-300">
+ VD-DOC-001-01-IFA-20250124.pdf
+ </code>
+ <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+ ✗ 언더스코어(_) 대신 하이픈(-) 사용
+ </p>
+ </div>
+ <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+ <code className="text-xs font-mono text-red-700 dark:text-red-300">
+ VD-DOC-001_01_IFA.pdf
+ </code>
+ <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+ ✗ 날짜/시간 정보 누락
+ </p>
+ </div>
+ <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+ <code className="text-xs font-mono text-red-700 dark:text-red-300">
+ VD-DOC-001_01_IFA_20250124.pdf
+ </code>
+ <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+ ✗ 시간 정보 누락 (14자리가 아님)
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* 주의사항 */}
+ <div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-4">
+ <h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">
+ ⚠️ 주의사항
+ </h3>
+ <ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside">
+ <li>파일명 형식이 올바르지 않으면 업로드가 실패합니다</li>
+ <li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li>
+ <li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li>
+ </ul>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index b18e2b27..573acf1b 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -3,10 +3,13 @@
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { ChevronDown, ChevronRight, FileIcon, Download } from "lucide-react";
+import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { ko } from "date-fns/locale";
import type { SwpDocumentWithStats } from "../actions";
+import { downloadSwpFile } from "../actions";
+import { useState } from "react";
+import { toast } from "sonner";
export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
{
@@ -388,19 +391,75 @@ export const swpFileColumns: ColumnDef<FileRow>[] = [
id: "actions",
header: "작업",
cell: ({ row }) => (
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- // TODO: 파일 다운로드 로직 구현
- console.log("Download file:", row.original.FILE_NM);
- }}
- >
- <Download className="h-4 w-4 mr-1" />
- 다운로드
- </Button>
+ <DownloadButton fileId={row.original.id} fileName={row.original.FILE_NM} />
),
size: 120,
},
];
+// ============================================================================
+// 다운로드 버튼 컴포넌트: 임시 구성. Download.aspx 동작 안해서 일단 네트워크드라이브 사용하도록 처리
+// ============================================================================
+
+interface DownloadButtonProps {
+ fileId: number;
+ fileName: string;
+}
+
+function DownloadButton({ fileId, fileName }: DownloadButtonProps) {
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const handleDownload = async () => {
+ try {
+ setIsDownloading(true);
+
+ // 서버 액션 호출
+ const result = await downloadSwpFile(fileId);
+
+ if (!result.success || !result.data) {
+ toast.error(result.error || "파일 다운로드 실패");
+ return;
+ }
+
+ // Blob 생성 및 다운로드
+ const blob = new Blob([result.data as unknown as BlobPart], { type: result.mimeType });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = result.fileName || fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ toast.success(`파일 다운로드 완료: ${result.fileName}`);
+ } catch (error) {
+ console.error("다운로드 오류:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDownload}
+ disabled={isDownloading}
+ >
+ {isDownloading ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ 다운로드 중...
+ </>
+ ) : (
+ <>
+ <Download className="h-4 w-4 mr-1" />
+ 다운로드
+ </>
+ )}
+ </Button>
+ );
+}
+
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
index 6858f42e..03082b26 100644
--- a/lib/swp/table/swp-table-toolbar.tsx
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -16,11 +16,13 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
-import { RefreshCw, Download, Search, X, Check, ChevronsUpDown } from "lucide-react";
-import { syncSwpProjectAction, type SwpTableFilters } from "../actions";
+import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react";
+import { syncSwpProjectAction, uploadSwpFilesAction, type SwpTableFilters } from "../actions";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
+import { useRef } from "react";
+import { SwpUploadHelpDialog } from "./swp-help-dialog";
interface SwpTableToolbarProps {
filters: SwpTableFilters;
@@ -34,11 +36,13 @@ export function SwpTableToolbar({
projects = [],
}: SwpTableToolbarProps) {
const [isSyncing, startSync] = useTransition();
+ const [isUploading, startUpload] = useTransition();
const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters);
const { toast } = useToast();
const router = useRouter();
const [projectSearchOpen, setProjectSearchOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
+ const fileInputRef = useRef<HTMLInputElement>(null);
// 동기화 핸들러
const handleSync = () => {
@@ -86,17 +90,112 @@ export function SwpTableToolbar({
/**
* 파일 업로드 핸들러
- * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기 (단, cpyCd는 어떻게 해결할지 고민해봐야 함...)
+ * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기
* 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리
- *
- * 개발중인 동안은 토스트 반환하도록 처리
*/
const handleUploadFiles = () => {
- toast({
- title: "파일 업로드",
- description: "현재 개발중입니다.",
+ // 프로젝트와 벤더 코드 체크
+ const projectNo = localFilters.projNo;
+ const vndrCd = localFilters.vndrCd;
+
+ if (!projectNo) {
+ toast({
+ variant: "destructive",
+ title: "프로젝트 선택 필요",
+ description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.",
+ });
+ return;
+ }
+
+ if (!vndrCd) {
+ toast({
+ variant: "destructive",
+ title: "업체 코드 입력 필요",
+ description: "파일을 업로드할 업체 코드를 입력해주세요.",
+ });
+ return;
+ }
+
+ // 파일 선택 다이얼로그 열기
+ fileInputRef.current?.click();
+ };
+
+ /**
+ * 파일 선택 핸들러
+ */
+ const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFiles = event.target.files;
+ if (!selectedFiles || selectedFiles.length === 0) {
+ return;
+ }
+
+ const projectNo = localFilters.projNo!;
+ const vndrCd = localFilters.vndrCd!;
+
+ startUpload(async () => {
+ try {
+ toast({
+ title: "파일 업로드 시작",
+ description: `${selectedFiles.length}개 파일을 업로드합니다...`,
+ });
+
+ // 파일을 Buffer로 변환
+ const fileInfos = await Promise.all(
+ Array.from(selectedFiles).map(async (file) => {
+ const arrayBuffer = await file.arrayBuffer();
+ return {
+ fileName: file.name,
+ fileBuffer: Buffer.from(arrayBuffer),
+ };
+ })
+ );
+
+ // 서버 액션 호출
+ const result = await uploadSwpFilesAction(projectNo, vndrCd, fileInfos);
+
+ if (result.success) {
+ toast({
+ title: "업로드 완료",
+ description: result.message,
+ });
+
+ // 페이지 새로고침
+ router.refresh();
+ } else {
+ toast({
+ variant: "destructive",
+ title: "업로드 실패",
+ description: result.message,
+ });
+ }
+
+ // 실패한 파일이 있으면 상세 정보 표시
+ const failedFiles = result.details.filter((d) => !d.success);
+ if (failedFiles.length > 0) {
+ console.error("실패한 파일:", failedFiles);
+ failedFiles.forEach((f) => {
+ toast({
+ variant: "destructive",
+ title: `${f.fileName} 업로드 실패`,
+ description: f.error || "알 수 없는 오류",
+ });
+ });
+ }
+ } catch (error) {
+ console.error("파일 업로드 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "업로드 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ } finally {
+ // 파일 입력 초기화
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
});
- }
+ };
// 검색 적용
const handleSearch = () => {
@@ -141,10 +240,26 @@ export function SwpTableToolbar({
<div className="text-sm text-muted-foreground">
SWP 문서 관리 시스템
</div>
- <div>
- <Button variant="outline" size="sm" onClick={handleUploadFiles}>
- 파일 업로드하기
+ <div className="flex items-center gap-2">
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleFileChange}
+ accept="*/*"
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleUploadFiles}
+ disabled={isUploading || !localFilters.projNo || !localFilters.vndrCd}
+ >
+ <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} />
+ {isUploading ? "업로드 중..." : "파일 업로드"}
</Button>
+
+ <SwpUploadHelpDialog />
</div>
</div>