From 3bc615961842b8b07d57530593eba0d384fc37ff Mon Sep 17 00:00:00 2001 From: Sosokker Date: Sun, 3 Nov 2024 17:58:13 +0700 Subject: [PATCH] feat: add dataroom manage page for business account --- .../manage/AccessRequestsManagement.tsx | 104 ++++++++++ src/app/dataroom/manage/FileManagement.tsx | 186 ++++++++++++++++++ src/app/dataroom/manage/page.tsx | 110 +++++++++++ src/components/ui/alert-dialog.tsx | 141 +++++++++++++ src/lib/data/bucket/deleteFile.ts | 29 +++ src/lib/data/bucket/uploadFile.ts | 54 +++++ src/lib/data/dataroomMutate.ts | 19 ++ src/lib/data/dataroomQuery.ts | 8 +- src/lib/data/projectQuery.ts | 25 ++- src/types/database.types.ts | Bin 36818 -> 61438 bytes 10 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 src/app/dataroom/manage/AccessRequestsManagement.tsx create mode 100644 src/app/dataroom/manage/FileManagement.tsx create mode 100644 src/app/dataroom/manage/page.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/lib/data/bucket/deleteFile.ts create mode 100644 src/lib/data/bucket/uploadFile.ts create mode 100644 src/lib/data/dataroomMutate.ts diff --git a/src/app/dataroom/manage/AccessRequestsManagement.tsx b/src/app/dataroom/manage/AccessRequestsManagement.tsx new file mode 100644 index 0000000..229f630 --- /dev/null +++ b/src/app/dataroom/manage/AccessRequestsManagement.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useState, useEffect } from "react"; +import { useQuery } from "@supabase-cache-helpers/postgrest-react-query"; +import { updateAccessRequestStatus } from "@/lib/data/dataroomMutate"; +import { getAccessRequests } from "@/lib/data/dataroomQuery"; +import { createSupabaseClient } from "@/lib/supabase/clientComponentClient"; + +interface AccessRequest { + id: number; + dataroom_id: number; + user_id: string; + status: "pending" | "approve" | "reject"; + requested_at: string; +} + +export default function AccessRequestsManagement({ dataroomId }: { dataroomId: number }) { + const supabase = createSupabaseClient(); + + const { data, error, isLoading } = useQuery(getAccessRequests(supabase, { dataroomId })); + const [accessRequests, setAccessRequests] = useState([]); + + useEffect(() => { + if (data) { + setAccessRequests(data); + } + }, [data]); + + const handleStatusChange = async (requestId: number, newStatus: "approve" | "reject" | "pending") => { + await updateAccessRequestStatus(supabase, requestId, newStatus); + setAccessRequests((prevRequests) => + prevRequests.map((request) => (request.id === requestId ? { ...request, status: newStatus } : request)) + ); + }; + + if (isLoading) return

Loading access requests...

; + if (error) return

Error loading access requests: {error.message}

; + + return ( + <> +

Manage Access Requests

+ +
+ + A list of access requests for the selected project/dataroom. + + + User + Status + Actions + + + + {accessRequests.map((request) => ( + + {request.user_id} + {request.status} + + {request.status === "pending" ? ( + <> + + + + ) : request.status === "approve" ? ( + + ) : ( + + )} + + + ))} + +
+
+ + ); +} diff --git a/src/app/dataroom/manage/FileManagement.tsx b/src/app/dataroom/manage/FileManagement.tsx new file mode 100644 index 0000000..2330bae --- /dev/null +++ b/src/app/dataroom/manage/FileManagement.tsx @@ -0,0 +1,186 @@ +"use client"; + +import React from "react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { uploadFileToDataRoom } from "@/lib/data/bucket/uploadFile"; +import { getFilesByDataroomId } from "@/lib/data/dataroomQuery"; +import { useQuery } from "@supabase-cache-helpers/postgrest-react-query"; +import { createSupabaseClient } from "@/lib/supabase/clientComponentClient"; +import Link from "next/link"; +import { deleteFileFromDataRoom } from "@/lib/data/bucket/deleteFile"; +import toast from "react-hot-toast"; + +interface FileManagementInterface { + dataroomId: number; +} + +interface Files { + id: number; + dataroom_id: number; + file_url: string; + file_type: { + id: number; + value: string; + }; + uploaded_at: string; +} + +export default function FileManagement({ dataroomId }: FileManagementInterface) { + const supabase = createSupabaseClient(); + const [fileToDelete, setFileToDelete] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [deleteError, setDeleteError] = useState(null); + + const { data: files, isLoading, error, refetch } = useQuery(getFilesByDataroomId(supabase, dataroomId)); + + function getFileNameFromUrl(fileUrl: string): string { + const fullFileName = fileUrl.split("/").pop() || ""; + return decodeURIComponent(fullFileName.split("?")[0]); + } + + const handleDeleteClick = (file: any) => { + setFileToDelete(file); + }; + + const handleDeleteConfirm = async () => { + if (!fileToDelete) return; + + try { + await deleteFileFromDataRoom( + supabase, + fileToDelete.dataroom_id, + getFileNameFromUrl(fileToDelete.file_url), + fileToDelete.id + ); + setFileToDelete(null); + refetch(); + toast.success("Delete successfully!"); + } catch (error) { + toast.error("Error occur while deleting file!"); + setDeleteError("Error occur while deleting file!"); + } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files) { + setSelectedFile(event.target.files[0]); + } + }; + + const handleUploadFile = async () => { + if (!selectedFile) return; + + setUploading(true); + setUploadError(null); + + try { + await uploadFileToDataRoom(supabase, selectedFile, dataroomId); + refetch(); + toast.success("Upload successfully!"); + } catch (error) { + toast.error("Error occur while uploading!"); + setUploadError("Error occur while uploading!"); + } finally { + setUploading(false); + setSelectedFile(null); + } + }; + + if (isLoading) return

Loading files...

; + if (error) return

Error loading files: {error.message}

; + + return ( + <> +

Manage Files

+ + + + {uploadError &&
{uploadError}
} + {deleteError &&
{deleteError}
} + +
+ + A list of files in the selected data room. + + + File Name + Status + Actions + + + + {files?.map((file) => ( + + + +

+ {getFileNameFromUrl(file.file_url)} +

+ +
+ Uploaded + + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the file " + {getFileNameFromUrl(file.file_url)} + ". + + + + setFileToDelete(null)}>Cancel + Continue + + + + +
+ ))} +
+ + + Total Files + {files?.length} + + +
+
+ + ); +} diff --git a/src/app/dataroom/manage/page.tsx b/src/app/dataroom/manage/page.tsx new file mode 100644 index 0000000..227fd81 --- /dev/null +++ b/src/app/dataroom/manage/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import FileManagement from "./FileManagement"; +import AccessRequestsManagement from "./AccessRequestsManagement"; +import { createSupabaseClient } from "@/lib/supabase/clientComponentClient"; +import { getProjectByUserId } from "@/lib/data/projectQuery"; +import { getUserRole } from "@/lib/data/userQuery"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export default function ManageDataroomPage() { + const supabase = createSupabaseClient(); + const router = useRouter(); + + const [userRole, setUserRole] = useState(null); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUserData = async () => { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + router.push("/"); + return; + } + + const { data: roleData, error: roleError } = await getUserRole(supabase, user.id); + if (roleError) { + toast.error("Error loading user role."); + router.push("/"); + return; + } + setUserRole(roleData.role); + + const { data: projectData, error: projectError } = await getProjectByUserId(supabase, user.id); + if (projectError) { + toast.error("Error loading projects."); + router.push("/"); + return; + } + setProjects(projectData); + setLoading(false); + }; + + fetchUserData(); + }, [supabase, router]); + + useEffect(() => { + if (userRole && userRole !== "business") { + router.push("/"); + } + }, [userRole, router]); + + if (loading) { + return

Loading...

; + } + + return ( +
+

Manage Data Room / Projects

+ +
+ + + {selectedProject && ( + <> +
+ +
+
+ +
+ + )} +
+
+ ); +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..25e7b47 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/lib/data/bucket/deleteFile.ts b/src/lib/data/bucket/deleteFile.ts new file mode 100644 index 0000000..b953d36 --- /dev/null +++ b/src/lib/data/bucket/deleteFile.ts @@ -0,0 +1,29 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Database } from "@/types/database.types"; + +export async function deleteFileFromDataRoom( + supabase: SupabaseClient, + dataroomId: number, + fileName: string, + dataroomMaterialId: number +) { + const filePath = `${dataroomId}/${fileName}`; + + if (!filePath) { + throw new Error("Invalid filepath: Unable to extract file path for deletion."); + } + + const { error: storageError } = await supabase.storage.from("dataroom-material").remove([filePath]); + + if (storageError) { + throw new Error(`Error deleting file from storage: ${storageError.message}`); + } + + const { error: dbError } = await supabase.from("dataroom_material").delete().eq("id", dataroomMaterialId); + + if (dbError) { + throw new Error(`Error deleting file from database: ${dbError.message}`); + } + + return { success: true }; +} diff --git a/src/lib/data/bucket/uploadFile.ts b/src/lib/data/bucket/uploadFile.ts new file mode 100644 index 0000000..a149290 --- /dev/null +++ b/src/lib/data/bucket/uploadFile.ts @@ -0,0 +1,54 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +export async function uploadFileToDataRoom(supabase: SupabaseClient, file: File, dataRoomId: number) { + const allowedExtensions = ["pdf", "docx", "xlsx", "pptx", "txt"]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + + if (!fileExtension || !allowedExtensions.includes(fileExtension)) { + throw new Error("Invalid file format. Only pdf, docx, xlsx, pptx, and txt are allowed."); + } + const { data: fileTypeData, error: fileTypeError } = await supabase + .from("material_file_type") + .select("id") + .eq("value", fileExtension) + .single(); + + if (fileTypeError || !fileTypeData) { + throw new Error("File type not supported or not found in material_file_type table."); + } + + const fileTypeId = fileTypeData.id; + + const fileName = `${dataRoomId}/${file.name}`; + const { data: uploadData, error: uploadError } = await supabase.storage + .from("dataroom-material") + .upload(fileName, file, { + upsert: true, + contentType: file.type, + }); + + if (uploadError) { + throw uploadError; + } + const { data: signedUrlData, error: signedUrlError } = await supabase.storage + .from("dataroom-material") + .createSignedUrl(fileName, 2628000); + + if (signedUrlError) { + throw signedUrlError; + } + + const { error: insertError } = await supabase.from("dataroom_material").insert([ + { + dataroom_id: dataRoomId, + file_url: signedUrlData.signedUrl, + file_type_id: fileTypeId, + }, + ]); + + if (insertError) { + throw insertError; + } + + return uploadData; +} diff --git a/src/lib/data/dataroomMutate.ts b/src/lib/data/dataroomMutate.ts new file mode 100644 index 0000000..3361e79 --- /dev/null +++ b/src/lib/data/dataroomMutate.ts @@ -0,0 +1,19 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +export const requestAccessToDataRoom = (client: SupabaseClient, dataRoomId: number, userId: number) => { + return client.from("access_request").insert([ + { + dataroom_id: dataRoomId, + user_id: userId, + status: "pending", + }, + ]); +}; + +export const updateAccessRequestStatus = ( + client: SupabaseClient, + requestId: number, + status: "approve" | "reject" | "pending" +) => { + return client.from("access_request").update({ status: status }).eq("id", requestId); +}; diff --git a/src/lib/data/dataroomQuery.ts b/src/lib/data/dataroomQuery.ts index bba9e23..a8a4f1c 100644 --- a/src/lib/data/dataroomQuery.ts +++ b/src/lib/data/dataroomQuery.ts @@ -44,10 +44,14 @@ export const getDataRoomsByProjectId = (client: SupabaseClient, projectId: numbe .eq("project_id", projectId); }; -export const getAccessRequests = (client: SupabaseClient, filters: { dataroomId?: number; userId?: string }) => { +export const getAccessRequests = ( + client: SupabaseClient, + filters: { dataroomId?: number; userId?: string } +) => { let query = client.from("access_request").select( ` id, + dataroom_id, user_id, status, requested_at @@ -55,7 +59,7 @@ export const getAccessRequests = (client: SupabaseClient, filters: { dataroomId? ); if (filters.dataroomId !== undefined) { - query = query.eq("data_room_id", filters.dataroomId); + query = query.eq("dataroom_id", filters.dataroomId); } if (filters.userId !== undefined) { diff --git a/src/lib/data/projectQuery.ts b/src/lib/data/projectQuery.ts index 6e0ae76..0f4f273 100644 --- a/src/lib/data/projectQuery.ts +++ b/src/lib/data/projectQuery.ts @@ -222,4 +222,27 @@ const getProjectByBusinessId = (client: SupabaseClient, businessIds: string[]) = .in("business_id", businessIds); }; -export { getProjectData, getProjectDataQuery, getTopProjects, searchProjectsQuery, getProjectByBusinessId }; +const getProjectByUserId = (client: SupabaseClient, userId: string) => { + return client + .from("project") + .select( + ` + id, + project_name, + business_id:business!inner ( + user_id + ), + dataroom_id + ` + ) + .eq("business.user_id", userId); +}; + +export { + getProjectData, + getProjectDataQuery, + getTopProjects, + searchProjectsQuery, + getProjectByBusinessId, + getProjectByUserId, +}; diff --git a/src/types/database.types.ts b/src/types/database.types.ts index 59f501f68c4f39dc73f42b23d646c9e9f1a10453..57dc0aca9d3c6b02fd9df922302f6a28d29832a6 100644 GIT binary patch literal 61438 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