diff options
Diffstat (limited to 'lib/project-gtc/table')
| -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 |
7 files changed, 1446 insertions, 0 deletions
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 |
