feat: add dataroom manage page for business account

This commit is contained in:
Sosokker 2024-11-03 17:58:13 +07:00
parent d0d9c7a0f8
commit 3bc6159618
10 changed files with 673 additions and 3 deletions

View File

@ -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<AccessRequest[]>([]);
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 <p>Loading access requests...</p>;
if (error) return <p>Error loading access requests: {error.message}</p>;
return (
<>
<h3 className="text-lg font-medium mb-2">Manage Access Requests</h3>
<Separator className="my-2" />
<div className="overflow-y-auto max-h-60">
<Table>
<TableCaption>A list of access requests for the selected project/dataroom.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accessRequests.map((request) => (
<TableRow key={request.id}>
<TableCell>{request.user_id}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell className="text-right">
{request.status === "pending" ? (
<>
<Button
variant="outline"
className="bg-green-500 text-white mr-2"
onClick={() => handleStatusChange(request.id, "approve")}
>
Approve
</Button>
<Button
variant="outline"
className="bg-red-500 text-white"
onClick={() => handleStatusChange(request.id, "reject")}
>
Reject
</Button>
</>
) : request.status === "approve" ? (
<Button
variant="outline"
className="bg-red-500 text-white"
onClick={() => handleStatusChange(request.id, "pending")}
>
Revoke Access
</Button>
) : (
<Button
variant="outline"
className="bg-green-500 text-white"
onClick={() => handleStatusChange(request.id, "approve")}
>
Approve
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
);
}

View File

@ -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<Files | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(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<HTMLInputElement>) => {
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 <p>Loading files...</p>;
if (error) return <p>Error loading files: {error.message}</p>;
return (
<>
<h3 className="text-lg font-medium mb-2">Manage Files</h3>
<Separator className="my-2" />
<Input type="file" className="mb-2" onChange={handleFileChange} />
<Button className="mb-4" onClick={handleUploadFile} disabled={uploading}>
{uploading ? "Uploading..." : "Upload File"}
</Button>
{uploadError && <div className="text-red-500">{uploadError}</div>}
{deleteError && <div className="text-red-500">{deleteError}</div>}
<div className="overflow-y-auto max-h-60 mb-4">
<Table>
<TableCaption>A list of files in the selected data room.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>File Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files?.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium">
<Link href={file.file_url} rel="noopener noreferrer" target="_blank">
<p className="text-blue-600 dark:text-blue-400 hover:text-blue-800">
{getFileNameFromUrl(file.file_url)}
</p>
</Link>
</TableCell>
<TableCell>Uploaded</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-red-500" onClick={() => handleDeleteClick(file)}>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the file &quot;
{getFileNameFromUrl(file.file_url)}
&quot;.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setFileToDelete(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>Total Files</TableCell>
<TableCell className="text-right">{files?.length}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</>
);
}

View File

@ -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<string | null>(null);
const [projects, setProjects] = useState<any[]>([]);
const [selectedProject, setSelectedProject] = useState<any>(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 <p className="flex items-center justify-center h-screen">Loading...</p>;
}
return (
<div className="container max-w-screen-xl p-4">
<h2 className="text-xl font-semibold mb-4">Manage Data Room / Projects</h2>
<div className="mb-4">
<Select
onValueChange={(value) => {
const selected = projects.find((project) => project.id.toString() === value);
setSelectedProject(selected);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Projects</SelectLabel>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id.toString()}>
{project.project_name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedProject && (
<>
<div className="border border-border p-4 rounded-md my-3">
<FileManagement dataroomId={selectedProject.dataroom_id} />
</div>
<div className="border border-border p-4 rounded-md">
<AccessRequestsManagement dataroomId={selectedProject.dataroom_id} />
</div>
</>
)}
</div>
</div>
);
}

View File

@ -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<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,29 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "@/types/database.types";
export async function deleteFileFromDataRoom(
supabase: SupabaseClient<Database>,
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 };
}

View File

@ -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;
}

View File

@ -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);
};

View File

@ -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<Database>,
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) {

View File

@ -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,
};

Binary file not shown.