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 59f501f..57dc0ac 100644 Binary files a/src/types/database.types.ts and b/src/types/database.types.ts differ