diff options
Diffstat (limited to 'lib/project-gtc')
| -rw-r--r-- | lib/project-gtc/service.ts | 389 | ||||
| -rw-r--r-- | lib/project-gtc/table/add-project-dialog.tsx | 296 | ||||
| -rw-r--r-- | lib/project-gtc/table/delete-gtc-file-dialog.tsx | 160 | ||||
| -rw-r--r-- | lib/project-gtc/table/project-gtc-table-columns.tsx | 364 | ||||
| -rw-r--r-- | lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx | 74 | ||||
| -rw-r--r-- | lib/project-gtc/table/project-gtc-table.tsx | 100 | ||||
| -rw-r--r-- | lib/project-gtc/table/update-gtc-file-sheet.tsx | 222 | ||||
| -rw-r--r-- | lib/project-gtc/table/view-gtc-file-dialog.tsx | 230 | ||||
| -rw-r--r-- | lib/project-gtc/validations.ts | 32 |
9 files changed, 1867 insertions, 0 deletions
diff --git a/lib/project-gtc/service.ts b/lib/project-gtc/service.ts new file mode 100644 index 00000000..c65d9364 --- /dev/null +++ b/lib/project-gtc/service.ts @@ -0,0 +1,389 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { asc, desc, ilike, or, eq, count, and, ne, sql } from "drizzle-orm"; +import { + projectGtcFiles, + projectGtcView, + type ProjectGtcFile, + projects, +} from "@/db/schema"; +import { promises as fs } from "fs"; +import path from "path"; +import crypto from "crypto"; +import { revalidatePath } from 'next/cache'; + +// Project GTC 목록 조회 +export async function getProjectGtcList( + input: { + page: number; + perPage: number; + search?: string; + sort: Array<{ id: string; desc: boolean }>; + filters?: Record<string, unknown>; + } +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + const { data, total } = await db.transaction(async (tx) => { + let whereCondition = undefined; + + // GTC 파일이 있는 프로젝트만 필터링 + const gtcFileCondition = sql`${projectGtcView.gtcFileId} IS NOT NULL`; + + if (input.search) { + const s = `%${input.search}%`; + const searchCondition = or( + ilike(projectGtcView.code, s), + ilike(projectGtcView.name, s), + ilike(projectGtcView.type, s), + ilike(projectGtcView.originalFileName, s) + ); + whereCondition = and(gtcFileCondition, searchCondition); + } else { + whereCondition = gtcFileCondition; + } + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc( + projectGtcView[ + item.id as keyof typeof projectGtcView + ] as never + ) + : asc( + projectGtcView[ + item.id as keyof typeof projectGtcView + ] as never + ) + ) + : [desc(projectGtcView.projectCreatedAt)]; + + const dataResult = await tx + .select() + .from(projectGtcView) + .where(whereCondition) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const totalCount = await tx + .select({ count: count() }) + .from(projectGtcView) + .where(whereCondition); + + return { + data: dataResult, + total: totalCount[0]?.count || 0, + }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + }; + } catch (error) { + console.error("getProjectGtcList 에러:", error); + throw new Error("Project GTC 목록을 가져오는 중 오류가 발생했습니다."); + } + }, + [`project-gtc-list-${JSON.stringify(input)}`], + { + tags: ["project-gtc"], + revalidate: false, + } + )(); +} + +// Project GTC 파일 업로드 +export async function uploadProjectGtcFile( + projectId: number, + file: File +): Promise<{ success: boolean; data?: ProjectGtcFile; error?: string }> { + try { + // 유효성 검사 + if (!projectId) { + return { success: false, error: "프로젝트 ID는 필수입니다." }; + } + + if (!file) { + return { success: false, error: "파일은 필수입니다." }; + } + + // 허용된 파일 타입 검사 + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain' + ]; + + if (!allowedTypes.includes(file.type)) { + return { success: false, error: "PDF, Word, 또는 텍스트 파일만 업로드 가능합니다." }; + } + + // 원본 파일 이름과 확장자 분리 + const originalFileName = file.name; + const fileExtension = path.extname(originalFileName); + const fileNameWithoutExt = path.basename(originalFileName, fileExtension); + + // 해시된 파일 이름 생성 + const timestamp = Date.now(); + const randomHash = crypto.createHash('md5') + .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) + .digest('hex') + .substring(0, 8); + + const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; + + // 저장 디렉토리 설정 + const uploadDir = path.join(process.cwd(), "public", "project-gtc"); + + // 디렉토리가 없으면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }); + } catch (err) { + console.log("Directory already exists or creation failed:", err); + } + + // 파일 경로 설정 + const filePath = path.join(uploadDir, hashedFileName); + const publicFilePath = `/project-gtc/${hashedFileName}`; + + // 파일을 ArrayBuffer로 변환 + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 파일 저장 + await fs.writeFile(filePath, buffer); + + // 기존 파일이 있으면 삭제 + const existingFile = await db.query.projectGtcFiles.findFirst({ + where: eq(projectGtcFiles.projectId, projectId) + }); + + if (existingFile) { + // 기존 파일 삭제 + try { + const filePath = path.join(process.cwd(), "public", existingFile.filePath); + await fs.unlink(filePath); + } catch { + console.error("파일 삭제 실패"); + } + + // DB에서 기존 파일 정보 삭제 + await db.delete(projectGtcFiles) + .where(eq(projectGtcFiles.id, existingFile.id)); + } + + // DB에 새 파일 정보 저장 + const newFile = await db.insert(projectGtcFiles).values({ + projectId, + fileName: hashedFileName, + filePath: publicFilePath, + originalFileName, + fileSize: file.size, + mimeType: file.type, + }).returning(); + + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + + return { success: true, data: newFile[0] }; + + } catch (error) { + console.error("Project GTC 파일 업로드 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다." + }; + } +} + +// Project GTC 파일 삭제 +export async function deleteProjectGtcFile( + projectId: number +): Promise<{ success: boolean; error?: string }> { + try { + return await db.transaction(async (tx) => { + const existingFile = await tx.query.projectGtcFiles.findFirst({ + where: eq(projectGtcFiles.projectId, projectId) + }); + + if (!existingFile) { + return { success: false, error: "삭제할 파일이 없습니다." }; + } + + // 파일 시스템에서 파일 삭제 + try { + const filePath = path.join(process.cwd(), "public", existingFile.filePath); + await fs.unlink(filePath); + } catch (error) { + console.error("파일 시스템에서 파일 삭제 실패:", error); + throw new Error("파일 시스템에서 파일 삭제에 실패했습니다."); + } + + // DB에서 파일 정보 삭제 + await tx.delete(projectGtcFiles) + .where(eq(projectGtcFiles.id, existingFile.id)); + + return { success: true }; + }); + + } catch (error) { + console.error("Project GTC 파일 삭제 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "파일 삭제 중 오류가 발생했습니다." + }; + } finally { + // 트랜잭션 성공/실패와 관계없이 캐시 무효화 + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + } +} + +// 프로젝트별 GTC 파일 정보 조회 +export async function getProjectGtcFile(projectId: number): Promise<ProjectGtcFile | null> { + try { + const file = await db.query.projectGtcFiles.findFirst({ + where: eq(projectGtcFiles.projectId, projectId) + }); + + return file || null; + } catch (error) { + console.error("Project GTC 파일 조회 에러:", error); + return null; + } +} + +// 프로젝트 생성 서버 액션 +export async function createProject( + input: { + code: string; + name: string; + type: string; + } +): Promise<{ success: boolean; data?: typeof projects.$inferSelect; error?: string }> { + try { + // 유효성 검사 + if (!input.code?.trim()) { + return { success: false, error: "프로젝트 코드는 필수입니다." }; + } + + if (!input.name?.trim()) { + return { success: false, error: "프로젝트명은 필수입니다." }; + } + + if (!input.type?.trim()) { + return { success: false, error: "프로젝트 타입은 필수입니다." }; + } + + // 프로젝트 코드 중복 검사 + const existingProject = await db.query.projects.findFirst({ + where: eq(projects.code, input.code.trim()) + }); + + if (existingProject) { + return { success: false, error: "이미 존재하는 프로젝트 코드입니다." }; + } + + // 프로젝트 생성 + const newProject = await db.insert(projects).values({ + code: input.code.trim(), + name: input.name.trim(), + type: input.type.trim(), + }).returning(); + + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + + return { success: true, data: newProject[0] }; + + } catch (error) { + console.error("프로젝트 생성 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "프로젝트 생성 중 오류가 발생했습니다." + }; + } +} + +// 프로젝트 정보 수정 서버 액션 +export async function updateProject( + input: { + id: number; + code: string; + name: string; + type: string; + } +): Promise<{ success: boolean; error?: string }> { + try { + if (!input.id) { + return { success: false, error: "프로젝트 ID는 필수입니다." }; + } + if (!input.code?.trim()) { + return { success: false, error: "프로젝트 코드는 필수입니다." }; + } + if (!input.name?.trim()) { + return { success: false, error: "프로젝트명은 필수입니다." }; + } + if (!input.type?.trim()) { + return { success: false, error: "프로젝트 타입은 필수입니다." }; + } + + // 프로젝트 코드 중복 검사 (본인 제외) + const existingProject = await db.query.projects.findFirst({ + where: and( + eq(projects.code, input.code.trim()), + ne(projects.id, input.id) + ) + }); + if (existingProject) { + return { success: false, error: "이미 존재하는 프로젝트 코드입니다." }; + } + + // 업데이트 + await db.update(projects) + .set({ + code: input.code.trim(), + name: input.name.trim(), + type: input.type.trim(), + }) + .where(eq(projects.id, input.id)); + + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + + return { success: true }; + } catch (error) { + console.error("프로젝트 수정 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "프로젝트 수정 중 오류가 발생했습니다." + }; + } +} + +// 이미 GTC 파일이 등록된 프로젝트 ID 목록 조회 +export async function getProjectsWithGtcFiles(): Promise<number[]> { + try { + const result = await db + .select({ projectId: projectGtcFiles.projectId }) + .from(projectGtcFiles); + + return result.map(row => row.projectId); + } catch (error) { + console.error("GTC 파일이 등록된 프로젝트 조회 에러:", error); + return []; + } +}
\ No newline at end of file diff --git a/lib/project-gtc/table/add-project-dialog.tsx b/lib/project-gtc/table/add-project-dialog.tsx new file mode 100644 index 00000000..616ab950 --- /dev/null +++ b/lib/project-gtc/table/add-project-dialog.tsx @@ -0,0 +1,296 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Upload, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { ProjectSelector } from "@/components/ProjectSelector" +import { uploadProjectGtcFile, getProjectsWithGtcFiles } from "../service" +import { type Project } from "@/lib/rfqs/service" + +const addProjectSchema = z.object({ + projectId: z.number().min(1, "프로젝트 선택은 필수입니다."), + gtcFile: z.instanceof(File, { message: "GTC 파일은 필수입니다." }).optional(), +}) + +type AddProjectFormValues = z.infer<typeof addProjectSchema> + +interface AddProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +export function AddProjectDialog({ + open, + onOpenChange, + onSuccess, +}: AddProjectDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [excludedProjectIds, setExcludedProjectIds] = React.useState<number[]>([]) + + const form = useForm<AddProjectFormValues>({ + resolver: zodResolver(addProjectSchema), + defaultValues: { + projectId: 0, + gtcFile: undefined, + }, + }) + + // 이미 GTC 파일이 등록된 프로젝트 ID 목록 로드 + React.useEffect(() => { + async function loadExcludedProjects() { + try { + const excludedIds = await getProjectsWithGtcFiles(); + setExcludedProjectIds(excludedIds); + } catch (error) { + console.error("제외할 프로젝트 목록 로드 오류:", error); + } + } + + if (open) { + loadExcludedProjects(); + } + }, [open]); + + // 프로젝트 선택 시 폼에 자동으로 채우기 + const handleProjectSelect = (project: Project) => { + // 이미 GTC 파일이 등록된 프로젝트인지 확인 + if (excludedProjectIds.includes(project.id)) { + toast.error("이미 GTC 파일이 등록된 프로젝트입니다."); + // 선택된 프로젝트 정보 초기화 + setSelectedProject(null); + form.setValue("projectId", 0); + return; + } + + setSelectedProject(project) + form.setValue("projectId", project.id) + } + + // 파일 선택 처리 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + // PDF 파일만 허용 + if (file.type !== 'application/pdf') { + toast.error("PDF 파일만 업로드 가능합니다.") + return + } + + setSelectedFile(file) + form.setValue("gtcFile", file) + } + } + + // 파일 제거 + const handleRemoveFile = () => { + setSelectedFile(null) + form.setValue("gtcFile", undefined) + // input 요소의 value도 초기화 + const fileInput = document.getElementById('gtc-file-input') as HTMLInputElement + if (fileInput) { + fileInput.value = '' + } + } + + const onSubmit = async (data: AddProjectFormValues) => { + // 프로젝트가 선택되지 않았으면 에러 + if (!selectedProject) { + toast.error("프로젝트를 선택해주세요.") + return + } + + // 이미 GTC 파일이 등록된 프로젝트인지 다시 한번 확인 + if (excludedProjectIds.includes(selectedProject.id)) { + toast.error("이미 GTC 파일이 등록된 프로젝트입니다.") + return + } + + // GTC 파일이 없으면 에러 + if (!data.gtcFile) { + toast.error("GTC 파일은 필수입니다.") + return + } + + setIsLoading(true) + try { + // GTC 파일 업로드 + const fileResult = await uploadProjectGtcFile(selectedProject.id, data.gtcFile) + + if (!fileResult.success) { + toast.error(fileResult.error || "GTC 파일 업로드에 실패했습니다.") + return + } + + toast.success("GTC 파일이 성공적으로 업로드되었습니다.") + form.reset() + setSelectedProject(null) + setSelectedFile(null) + onOpenChange(false) + onSuccess?.() + } catch (error) { + console.error("GTC 파일 업로드 오류:", error) + toast.error("GTC 파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + form.reset() + setSelectedProject(null) + setSelectedFile(null) + } + onOpenChange(newOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>GTC 파일 추가</DialogTitle> + <DialogDescription> + 기존 프로젝트를 선택하고 GTC 파일을 업로드합니다. (이미 GTC 파일이 등록된 프로젝트는 제외됩니다) + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 프로젝트 선택 (필수) */} + <FormField + control={form.control} + name="projectId" + render={() => ( + <FormItem> + <FormLabel>프로젝트 선택 *</FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={selectedProject?.id} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 프로젝트 정보 표시 (읽기 전용) */} + {selectedProject && ( + <div className="p-4 bg-muted rounded-lg space-y-2"> + <h4 className="font-medium text-sm">선택된 프로젝트 정보</h4> + <div className="space-y-1 text-sm"> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트 코드:</span> + <span className="font-medium">{selectedProject.projectCode}</span> + </div> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트명:</span> + <span className="font-medium">{selectedProject.projectName}</span> + </div> + </div> + </div> + )} + + {/* GTC 파일 업로드 */} + <FormField + control={form.control} + name="gtcFile" + render={() => ( + <FormItem> + <FormLabel>GTC 파일 *</FormLabel> + <div className="space-y-2"> + {!selectedFile ? ( + <div className="flex items-center justify-center w-full"> + <label + htmlFor="gtc-file-input" + className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" + > + <div className="flex flex-col items-center justify-center pt-5 pb-6"> + <Upload className="w-8 h-8 mb-4 text-gray-500" /> + <p className="mb-2 text-sm text-gray-500"> + <span className="font-semibold">클릭하여 파일 선택</span> 또는 드래그 앤 드롭 + </p> + <p className="text-xs text-gray-500"> + PDF 파일만 + </p> + </div> + <input + id="gtc-file-input" + type="file" + className="hidden" + accept=".pdf" + onChange={handleFileSelect} + disabled={isLoading} + /> + </label> + </div> + ) : ( + <div className="flex items-center justify-between p-3 border rounded-lg bg-gray-50"> + <div className="flex items-center space-x-2"> + <Upload className="w-4 h-4 text-gray-500" /> + <span className="text-sm font-medium">{selectedFile.name}</span> + <span className="text-xs text-gray-500"> + ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) + </span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={handleRemoveFile} + disabled={isLoading} + > + <X className="w-4 h-4" /> + </Button> + </div> + )} + </div> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button type="submit" disabled={isLoading || !selectedProject}> + {isLoading ? "업로드 중..." : "GTC 파일 업로드"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/delete-gtc-file-dialog.tsx b/lib/project-gtc/table/delete-gtc-file-dialog.tsx new file mode 100644 index 00000000..d64be529 --- /dev/null +++ b/lib/project-gtc/table/delete-gtc-file-dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { deleteProjectGtcFile } from "../service" +import { ProjectGtcView } from "@/db/schema" + +interface DeleteGtcFileDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + projects: Row<ProjectGtcView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteGtcFileDialog({ + projects, + showTrigger = true, + onSuccess, + ...props +}: DeleteGtcFileDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 프로젝트의 GTC 파일을 삭제 + const deletePromises = projects.map(project => + deleteProjectGtcFile(project.id) + ) + + const results = await Promise.all(deletePromises) + + // 성공/실패 확인 + const successCount = results.filter(result => result.success).length + const failureCount = results.length - successCount + + if (failureCount > 0) { + toast.error(`${failureCount}개 파일 삭제에 실패했습니다.`) + return + } + + props.onOpenChange?.(false) + toast.success(`${successCount}개 GTC 파일이 성공적으로 삭제되었습니다.`) + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("파일 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({projects.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{projects.length}</span> + {projects.length === 1 ? " Project GTC" : " Project GTCs"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({projects.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{projects.length}</span> + {projects.length === 1 ? " Project GTC" : " Project GTCs"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/project-gtc-table-columns.tsx b/lib/project-gtc/table/project-gtc-table-columns.tsx new file mode 100644 index 00000000..dfdf1921 --- /dev/null +++ b/lib/project-gtc/table/project-gtc-table-columns.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Paperclip, FileText } from "lucide-react" +import { toast } from "sonner" + +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ProjectGtcView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProjectGtcView> | null>> +} + +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = async (projectId: number, fileName: string) => { + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${projectId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일 다운로드에 실패했습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + + // 다운로드 링크 생성 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 메모리 정리 + window.URL.revokeObjectURL(url); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProjectGtcView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<ProjectGtcView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef<ProjectGtcView> = { + id: "download", + header: "", + cell: ({ row }) => { + const project = row.original; + + if (!project.filePath || !project.originalFileName) { + return null; + } + + return ( + <Button + variant="ghost" + size="icon" + onClick={() => handleFileDownload(project.id, project.originalFileName!)} + title={`${project.originalFileName} 다운로드`} + className="hover:bg-muted" + > + <Paperclip className="h-4 w-4" /> + <span className="sr-only">다운로드</span> + </Button> + ); + }, + maxSize: 30, + enableSorting: false, + } + + // ---------------------------------------------------------------- + // 3) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<ProjectGtcView> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "upload", row })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "delete", row })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef<ProjectGtcView>[] } + const groupMap: Record<string, ColumnDef<ProjectGtcView>[]> = {} + + // 프로젝트 정보 그룹 + groupMap["기본 정보"] = [ + { + accessorKey: "code", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate font-medium"> + {row.getValue("code")} + </span> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + minSize: 120, + }, + { + accessorKey: "name", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate"> + {row.getValue("name")} + </span> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + minSize: 200, + }, + { + accessorKey: "type", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 타입" /> + ), + cell: ({ row }) => { + const type = row.getValue("type") as string + return ( + <div className="flex w-[100px] items-center"> + <Badge variant="secondary"> + {type} + </Badge> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + minSize: 100, + }, + ] + + // 파일 정보 그룹 + groupMap["파일 정보"] = [ + { + accessorKey: "originalFileName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC 파일" /> + ), + cell: ({ row }) => { + const fileName = row.getValue("originalFileName") as string | null + const filePath = row.original.filePath + + if (!fileName) { + return ( + <div className="flex items-center text-muted-foreground"> + <FileText className="mr-2 h-4 w-4" /> + <span>파일 없음</span> + </div> + ) + } + + return ( + <div className="flex items-center space-x-2"> + <FileText className="h-4 w-4" /> + <div className="flex flex-col"> + {filePath ? ( + <button + onClick={async (e) => { + e.preventDefault(); + e.stopPropagation(); + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${row.original.id}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일을 열 수 없습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + window.open(url, '_blank'); + } catch (error) { + console.error("파일 미리보기 오류:", error); + toast.error("파일을 열 수 없습니다."); + } + }} + className="font-medium text-left hover:underline cursor-pointer text-blue-600 hover:text-blue-800 transition-colors" + title="클릭하여 파일 열기" + > + {fileName} + </button> + ) : ( + <span className="font-medium">{fileName}</span> + )} + </div> + </div> + ) + }, + minSize: 200, + }, + ] + + // 날짜 정보 그룹 + groupMap["날짜 정보"] = [ + { + accessorKey: "gtcCreatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC 등록일" /> + ), + cell: ({ row }) => { + const date = row.getValue("gtcCreatedAt") as Date | null + if (!date) { + return <span className="text-muted-foreground">-</span> + } + return ( + <div className="flex items-center"> + <span> + {formatDateTime(new Date(date))} + </span> + </div> + ) + }, + minSize: 150, + }, + { + accessorKey: "projectCreatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 생성일" /> + ), + cell: ({ row }) => { + const date = row.getValue("projectCreatedAt") as Date + return ( + <div className="flex items-center"> + <span> + {formatDate(new Date(date))} + </span> + </div> + ) + }, + minSize: 120, + }, + ] + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<ProjectGtcView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "프로젝트 정보", "파일 정보", "날짜 정보" 등 + columns: colDefs, + }) + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx b/lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx new file mode 100644 index 00000000..ec6ba053 --- /dev/null +++ b/lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx @@ -0,0 +1,74 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { type Table } from "@tanstack/react-table" +import { Download, Plus } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeleteGtcFileDialog } from "./delete-gtc-file-dialog" +import { AddProjectDialog } from "./add-project-dialog" +import { ProjectGtcView } from "@/db/schema" + +interface ProjectGtcTableToolbarActionsProps { + table: Table<ProjectGtcView> +} + +export function ProjectGtcTableToolbarActions({ table }: ProjectGtcTableToolbarActionsProps) { + const router = useRouter() + const [showAddProjectDialog, setShowAddProjectDialog] = React.useState(false) + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteGtcFileDialog + projects={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false) + router.refresh() + }} + /> + ) : null} + + {/** 2) GTC 추가 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setShowAddProjectDialog(true)} + className="gap-2" + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">GTC 추가</span> + </Button> + + {/** 3) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "project-gtc-list", + excludeColumns: ["select", "download", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + {/** 4) 프로젝트 추가 다이얼로그 */} + <AddProjectDialog + open={showAddProjectDialog} + onOpenChange={setShowAddProjectDialog} + onSuccess={() => { + router.refresh() + }} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/project-gtc-table.tsx b/lib/project-gtc/table/project-gtc-table.tsx new file mode 100644 index 00000000..6e529ccf --- /dev/null +++ b/lib/project-gtc/table/project-gtc-table.tsx @@ -0,0 +1,100 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { DataTable } from "@/components/data-table/data-table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getProjectGtcList } from "../service"; +import { getColumns } from "./project-gtc-table-columns"; +import { DeleteGtcFileDialog } from "./delete-gtc-file-dialog"; +import { UpdateGtcFileSheet } from "./update-gtc-file-sheet"; +import { ProjectGtcTableToolbarActions } from "./project-gtc-table-toolbar-actions"; +import { ProjectGtcView } from "@/db/schema"; + +interface ProjectGtcTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getProjectGtcList>>, + ] + > +} + +export function ProjectGtcTable({ promises }: ProjectGtcTableProps) { + const router = useRouter(); + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ProjectGtcView> | null>(null) + + const [{ data, pageCount }] = + React.use(promises) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<ProjectGtcView>[] = [ + { id: "code", label: "프로젝트 코드", type: "text" }, + { id: "name", label: "프로젝트명", type: "text" }, + { + id: "type", label: "프로젝트 타입", type: "select", options: [ + { label: "Ship", value: "ship" }, + { label: "Offshore", value: "offshore" }, + { label: "Other", value: "other" }, + ] + }, + { id: "originalFileName", label: "GTC 파일명", type: "text" }, + { id: "projectCreatedAt", label: "프로젝트 생성일", type: "date" }, + { id: "gtcCreatedAt", label: "GTC 등록일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "projectCreatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <ProjectGtcTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteGtcFileDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + projects={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + router.refresh(); + }} + /> + + <UpdateGtcFileSheet + open={rowAction?.type === "upload"} + onOpenChange={() => setRowAction(null)} + project={rowAction && rowAction.type === "upload" ? rowAction.row.original : null} + /> + </> + ); +}
\ No newline at end of file diff --git a/lib/project-gtc/table/update-gtc-file-sheet.tsx b/lib/project-gtc/table/update-gtc-file-sheet.tsx new file mode 100644 index 00000000..65a6bb45 --- /dev/null +++ b/lib/project-gtc/table/update-gtc-file-sheet.tsx @@ -0,0 +1,222 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Upload } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { uploadProjectGtcFile } from "../service" +import type { ProjectGtcView } from "@/db/schema" + +const updateProjectSchema = z.object({ + gtcFile: z.instanceof(File).optional(), +}) + +type UpdateProjectFormValues = z.infer<typeof updateProjectSchema> + +interface UpdateGtcFileSheetProps { + project: ProjectGtcView | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function UpdateGtcFileSheet({ + project, + open, + onOpenChange, +}: UpdateGtcFileSheetProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + + const form = useForm<UpdateProjectFormValues>({ + resolver: zodResolver(updateProjectSchema), + defaultValues: { + gtcFile: undefined, + }, + }) + + // 기존 값 세팅 (프로젝트 변경 시) + React.useEffect(() => { + if (project) { + form.reset({ + gtcFile: undefined, + }) + setSelectedFile(null) + } + }, [project, form]) + + // 파일 선택 처리 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + // PDF 파일만 허용 + if (file.type !== 'application/pdf') { + toast.error("PDF 파일만 업로드 가능합니다.") + return + } + setSelectedFile(file) + form.setValue("gtcFile", file) + } + } + + // 폼 제출 핸들러 + async function onSubmit(data: UpdateProjectFormValues) { + if (!project) { + toast.error("프로젝트 정보를 찾을 수 없습니다.") + return + } + + setIsLoading(true) + try { + // GTC 파일이 있으면 업로드 + if (data.gtcFile) { + const fileResult = await uploadProjectGtcFile(project.id, data.gtcFile) + if (!fileResult.success) { + toast.error(fileResult.error || "GTC 파일 업로드에 실패했습니다.") + return + } + toast.success("GTC 파일이 성공적으로 업로드되었습니다.") + } else { + toast.info("변경사항이 없습니다.") + } + + form.reset() + setSelectedFile(null) + onOpenChange(false) + } catch (error) { + console.error("GTC 파일 업로드 오류:", error) + toast.error("GTC 파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + if (!project) return null + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-xl"> + <SheetHeader className="text-left"> + <SheetTitle>GTC 파일 수정</SheetTitle> + <SheetDescription> + 프로젝트 정보는 수정할 수 없으며, GTC 파일만 업로드할 수 있습니다. + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* 프로젝트 정보 (읽기 전용) */} + <div className="space-y-4"> + <div> + <FormLabel>프로젝트 코드</FormLabel> + <Input + value={project.code} + disabled + className="bg-muted" + /> + </div> + <div> + <FormLabel>프로젝트명</FormLabel> + <Input + value={project.name} + disabled + className="bg-muted" + /> + </div> + <div> + <FormLabel>프로젝트 타입</FormLabel> + <Input + value={project.type} + disabled + className="bg-muted" + /> + </div> + </div> + + {/* GTC 파일 업로드 */} + <FormField + control={form.control} + name="gtcFile" + render={() => ( + <FormItem> + <FormLabel>GTC 파일 (PDF만, 선택 시 기존 파일 교체)</FormLabel> + <div className="space-y-2"> + <label + htmlFor="gtc-file-input" + className="flex flex-col items-center justify-center w-full min-h-[8rem] border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" + > + <div className="flex flex-col items-center justify-center p-4 text-center"> + <Upload className="w-8 h-8 mb-2 text-gray-500" /> + <span className="mb-1 text-base font-semibold text-gray-800"> + {selectedFile + ? selectedFile.name + : project.originalFileName + ? `현재 파일: ${project.originalFileName}` + : "현재 파일 없음"} + </span> + {selectedFile ? ( + <span className="text-xs text-gray-500"> + ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) + </span> + ) : ( + <> + <p className="mb-2 text-sm text-gray-500"> + 또는 클릭하여 파일을 선택하세요 + </p> + <p className="text-xs text-gray-500"> + PDF 파일만 + </p> + </> + )} + </div> + <input + id="gtc-file-input" + type="file" + className="hidden" + accept=".pdf" + onChange={handleFileSelect} + disabled={isLoading} + /> + </label> + </div> + <FormMessage /> + </FormItem> + )} + /> + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button type="submit" disabled={isLoading || !selectedFile}> + {isLoading ? "업로드 중..." : "GTC 파일 업로드"} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/view-gtc-file-dialog.tsx b/lib/project-gtc/table/view-gtc-file-dialog.tsx new file mode 100644 index 00000000..f8cfecd9 --- /dev/null +++ b/lib/project-gtc/table/view-gtc-file-dialog.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { Download, FileText, Calendar, HardDrive } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import type { ProjectGtcView } from "@/db/schema" + +interface ViewGtcFileDialogProps { + project: ProjectGtcView | null + open: boolean + onOpenChange: (open: boolean) => void +} + +// 파일 크기 포맷팅 함수 +function formatBytes(bytes: number | null): string { + if (!bytes) return "0 B" + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +export function ViewGtcFileDialog({ + project, + open, + onOpenChange, +}: ViewGtcFileDialogProps) { + if (!project || !project.gtcFileId) return null + + const handleDownload = async () => { + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${project.id}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일 다운로드에 실패했습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + + // 다운로드 링크 생성 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = project.originalFileName || 'gtc-file'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 메모리 정리 + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("파일 다운로드 오류:", error); + } + } + + const handlePreview = async () => { + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${project.id}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일을 열 수 없습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + + // PDF 파일인 경우 새 탭에서 열기 + if (project.mimeType === 'application/pdf') { + const url = window.URL.createObjectURL(blob); + window.open(url, '_blank'); + // 메모리 정리는 브라우저가 탭을 닫을 때 자동으로 처리됨 + } else { + // 다른 파일 타입은 다운로드 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = project.originalFileName || 'gtc-file'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } + } catch (error) { + console.error("파일 미리보기 오류:", error); + } + } + + const getFileIcon = () => { + if (project.mimeType?.includes('pdf')) { + return "📄" + } else if (project.mimeType?.includes('word') || project.mimeType?.includes('document')) { + return "📝" + } else if (project.mimeType?.includes('text')) { + return "📃" + } + return "📎" + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>GTC 파일 정보</DialogTitle> + <DialogDescription> + 프로젝트 "{project.name}" ({project.code})의 GTC 파일 정보입니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 프로젝트 정보 */} + <div className="p-4 bg-muted rounded-lg"> + <h4 className="font-medium mb-2">프로젝트 정보</h4> + <div className="space-y-2 text-sm"> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트 코드:</span> + <span className="font-medium">{project.code}</span> + </div> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트명:</span> + <span className="font-medium">{project.name}</span> + </div> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트 타입:</span> + <Badge variant="secondary">{project.type}</Badge> + </div> + </div> + </div> + + {/* 파일 정보 */} + <div className="p-4 bg-muted rounded-lg"> + <h4 className="font-medium mb-2">파일 정보</h4> + <div className="space-y-3"> + <div className="flex items-center space-x-3"> + <span className="text-2xl">{getFileIcon()}</span> + <div className="flex-1"> + <div className="font-medium">{project.originalFileName}</div> + <div className="text-sm text-muted-foreground"> + {project.fileName} + </div> + </div> + </div> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-center space-x-2"> + <HardDrive className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">파일 크기:</span> + <span className="font-medium"> + {project.fileSize ? formatBytes(project.fileSize) : '알 수 없음'} + </span> + </div> + <div className="flex items-center space-x-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">파일 타입:</span> + <span className="font-medium"> + {project.mimeType || '알 수 없음'} + </span> + </div> + <div className="flex items-center space-x-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">업로드일:</span> + <span className="font-medium"> + {project.gtcCreatedAt ? + format(new Date(project.gtcCreatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) : + '알 수 없음' + } + </span> + </div> + <div className="flex items-center space-x-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">수정일:</span> + <span className="font-medium"> + {project.gtcUpdatedAt ? + format(new Date(project.gtcUpdatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) : + '알 수 없음' + } + </span> + </div> + </div> + </div> + </div> + </div> + + <DialogFooter className="flex space-x-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 닫기 + </Button> + <Button + type="button" + variant="outline" + onClick={handlePreview} + > + 미리보기 + </Button> + <Button + type="button" + onClick={handleDownload} + > + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/validations.ts b/lib/project-gtc/validations.ts new file mode 100644 index 00000000..963ffdd4 --- /dev/null +++ b/lib/project-gtc/validations.ts @@ -0,0 +1,32 @@ +import * as z from "zod" +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum +} from "nuqs/server" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { ProjectGtcView } from "@/db/schema" + +export const projectGtcSearchParamsSchema = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<ProjectGtcView>().withDefault([ + { id: "projectCreatedAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +export const projectGtcFileSchema = z.object({ + projectId: z.number().min(1, "프로젝트 ID는 필수입니다."), + file: z.instanceof(File).refine((file) => file.size > 0, "파일은 필수입니다."), +}) + +export type ProjectGtcSearchParams = Awaited<ReturnType<typeof projectGtcSearchParamsSchema.parse>> +export type ProjectGtcFileInput = z.infer<typeof projectGtcFileSchema>
\ No newline at end of file |
