mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # src/components/wrappers/dashboard/Organization/organization-combobox.tsx
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {currentUser} from "@/auth/current-user";
|
||||
import {LoggedInDropdown} from "@/components/wrappers/dashboard/LoggedInDropdown/LoggedInDropdown";
|
||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||
import {ChevronUp} from "lucide-react";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
|
||||
const user = await currentUser()
|
||||
// if (!user) {
|
||||
// return <SignInButton/>
|
||||
// }
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<LoggedInDropdown>
|
||||
<SidebarMenuButton>
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
|
||||
{user.image ? (
|
||||
<AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`}/>
|
||||
) : null}
|
||||
</Avatar>
|
||||
<span>{user.name}</span>
|
||||
<ChevronUp className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
</LoggedInDropdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import {PropsWithChildren} from "react";
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||
import {signOutAction} from "@/features/auth/auth.action";
|
||||
import {redirect} from "next/navigation";
|
||||
import {CircleUser, LogOut, ShieldHalf} from "lucide-react";
|
||||
|
||||
export type LoggedInDropdownProps = PropsWithChildren<{}>
|
||||
|
||||
export const LoggedInDropdown = (props: LoggedInDropdownProps) => {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{props.children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
redirect("/dashboard/profile")
|
||||
}}>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<CircleUser size={16}/>
|
||||
<span>Account</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
redirect("/dashboard/admin")
|
||||
}}>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<ShieldHalf size={16}/>
|
||||
<span>Administration Panel</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
signOutAction()
|
||||
}}>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<LogOut size={16}/>
|
||||
<span>Log out</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
|
||||
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {OrganizationSchema} from "@/components/wrappers/dashboard/Organization/organization.schema";
|
||||
|
||||
export type createOrganizationModalProps = {
|
||||
children: any
|
||||
}
|
||||
|
||||
|
||||
export function CreateOrganizationModal(props: createOrganizationModalProps) {
|
||||
|
||||
const {children} = props;
|
||||
|
||||
const form = useZodForm({
|
||||
schema: OrganizationSchema,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OrganizationSchema) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-[425px] w-full">
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new organization</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="sm:max-w-[375px] w-full">
|
||||
<Form form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
</DialogContent>
|
||||
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import {ComboBox} from "@/components/wrappers/common/combobox";
|
||||
import {Organization} from "@prisma/client";
|
||||
import {useStore} from "@/state-management/store";
|
||||
import {getCurrentOrganizationSlug, setCurrentOrganizationSlug} from "@/features/dashboard/organization-cookie";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
|
||||
export type organizationComboBoxProps = {
|
||||
organizations: Organization[]
|
||||
defaultOrganization: Organization
|
||||
}
|
||||
|
||||
|
||||
export function OrganizationCombobox(props: organizationComboBoxProps) {
|
||||
|
||||
// const {organizationId, moveToAnotherOrganization} = useStore((state) => state);
|
||||
|
||||
const [organizationSlug, setOrganizationSlug] = useState<string>()
|
||||
|
||||
const {organizations, defaultOrganization} = props
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentOrganizationSlug().then(slug => {
|
||||
|
||||
if (slug == "") {
|
||||
setOrganizationSlug(defaultOrganization.slug)
|
||||
setCurrentOrganizationSlug(defaultOrganization.slug)
|
||||
} else {
|
||||
setOrganizationSlug(slug)
|
||||
const organization = organizations.find(organization => organization.slug === slug)
|
||||
if (!organization) {
|
||||
setOrganizationSlug(defaultOrganization.slug)
|
||||
setCurrentOrganizationSlug(defaultOrganization.slug)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}, [organizationSlug])
|
||||
|
||||
const values = organizations.map(organization => {
|
||||
return ({
|
||||
value: organization.slug,
|
||||
label: organization.name,
|
||||
})
|
||||
})
|
||||
|
||||
const onValueChange = (slug: string) => {
|
||||
if (organizationSlug !== slug) {
|
||||
setOrganizationSlug(slug)
|
||||
setCurrentOrganizationSlug(slug)
|
||||
}
|
||||
}
|
||||
const { state, isMobile } = useSidebar();
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{state === 'expanded' && (
|
||||
<ComboBox
|
||||
sideBar
|
||||
values={values}
|
||||
defaultValue={organizationSlug}
|
||||
onValueChange={onValueChange}/>
|
||||
)}
|
||||
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
export const OrganizationSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {User} from "@prisma/client";
|
||||
import {UploadIcon} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {uploadImageAction} from "@/features/upload/public/upload.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {prisma} from "@/prisma";
|
||||
import {updateImageUserAction} from "@/components/wrappers/dashboard/Profile/Avatar/avatar.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useSession} from "next-auth/react";
|
||||
|
||||
export type AvatarWithUploadProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
|
||||
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
const user = props.user
|
||||
const router = useRouter();
|
||||
const { data: session, update } = useSession();
|
||||
|
||||
|
||||
const submitImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
const uploadImage = await uploadImageAction(formData)
|
||||
const data = uploadImage?.data?.data
|
||||
|
||||
if (uploadImage?.serverError || !data) {
|
||||
console.log(uploadImage?.serverError);
|
||||
toast.error(uploadImage?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateUser = await updateImageUserAction(data.url)
|
||||
const dataUser = updateUser?.data?.data
|
||||
|
||||
if (updateUser?.serverError || !dataUser) {
|
||||
console.log(updateUser?.serverError);
|
||||
toast.error(updateUser?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession = {
|
||||
...session,
|
||||
user: {
|
||||
...session?.user,
|
||||
image: data.url
|
||||
},
|
||||
};
|
||||
|
||||
await update(newSession);
|
||||
toast.success("Successfully uploaded user image!");
|
||||
router.refresh()
|
||||
|
||||
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.includes("image")) {
|
||||
toast.error("File not an image")
|
||||
return;
|
||||
}
|
||||
submitImage.mutate(file)
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative " >
|
||||
<Avatar className="size-14 mr-3 ">
|
||||
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
|
||||
{user.image ? (
|
||||
<AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`}/>
|
||||
) : null}
|
||||
</Avatar>
|
||||
<div
|
||||
onClick={() => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
// @ts-ignore
|
||||
fileInput.onchange = handleImageUpload;
|
||||
fileInput.click();
|
||||
}}
|
||||
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full size-14"
|
||||
>
|
||||
<UploadIcon className="w-8 h-8 text-primary"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use server"
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {prisma} from "@/prisma";
|
||||
|
||||
|
||||
export const updateImageUserAction = userAction
|
||||
.schema(z.string())
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: {
|
||||
image: parsedInput,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
data: user,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {signOutAction} from "@/features/auth/auth.action";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/ButtonWithConfirm/ButtonWithConfirm";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/Profile/ButtonDeleteAccount/delete-account.action";
|
||||
import {Trash2} from "lucide-react";
|
||||
|
||||
export type ButtonDeleteAccountProps = {
|
||||
text? : string
|
||||
}
|
||||
|
||||
export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteUserAction(""),
|
||||
onSuccess: async () => {
|
||||
await signOutAction();
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
text={props.text ? props.text : ""}
|
||||
onClick={() => {
|
||||
mutation.mutate()
|
||||
}}
|
||||
variant={"destructive"}
|
||||
isPending={mutation.isPending}
|
||||
className="gap-2"
|
||||
icon={<Trash2/>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"use server"
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {prisma} from "@/prisma";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {EmailFormSchema} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
|
||||
|
||||
|
||||
export const deleteUserAction = userAction
|
||||
.schema(z.string())
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
const userId = parsedInput.length > 0 ? parsedInput : ctx.user.id;
|
||||
|
||||
const uuid = uuidv4()
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
email: `${uuid}@portabase.com`,
|
||||
name: `${uuid}`,
|
||||
deleted: true
|
||||
}
|
||||
})
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where: {
|
||||
userId: userId,
|
||||
}
|
||||
})
|
||||
if (account) {
|
||||
await prisma.account.delete({
|
||||
where: {
|
||||
id: account.id
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
return {
|
||||
data: user,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {UserSchema, UserType} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.schema";
|
||||
import {toast} from "sonner";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.action";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export type userFormProps = {
|
||||
defaultValues?: UserType;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const UserForm = (props: userFormProps) => {
|
||||
|
||||
const isCreate = !Boolean(props.defaultValues)
|
||||
|
||||
const form = useZodForm({
|
||||
schema: UserSchema,
|
||||
defaultValues: props.defaultValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { data: session, update } = useSession();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: UserType) => {
|
||||
console.log("values", values)
|
||||
console.log(props.userId)
|
||||
const updateUser = await updateUserAction({
|
||||
id: props.userId ?? "-",
|
||||
data: values
|
||||
})
|
||||
|
||||
const data = updateUser?.data?.data
|
||||
if (updateUser?.serverError || !data) {
|
||||
console.log(updateUser?.serverError);
|
||||
toast.error(updateUser?.serverError);
|
||||
return;
|
||||
}
|
||||
console.log("email:", values.email)
|
||||
|
||||
const newSession = {
|
||||
...session,
|
||||
user: {
|
||||
...session?.user,
|
||||
name: values.name,
|
||||
email: values.email
|
||||
},
|
||||
};
|
||||
|
||||
const updateSession = await update(newSession);
|
||||
console.log(updateSession);
|
||||
toast.success(`Success updating user informations`);
|
||||
router.push(`/dashboard/profile`);
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your informations
|
||||
</CardDescription>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Your Name"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={'exemple@portabase.com'} disabled {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button>
|
||||
{isCreate ? `` : `Save`}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use server"
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {prisma} from "@/prisma";
|
||||
import {UserSchema} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.schema";
|
||||
|
||||
export const updateUserAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: UserSchema,
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: parsedInput.id,
|
||||
},
|
||||
data: parsedInput.data,
|
||||
})
|
||||
return {
|
||||
data: updatedUser,
|
||||
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserType = z.infer<typeof UserSchema>;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/ButtonWithConfirm/ButtonWithConfirm";
|
||||
import {Trash2} from "lucide-react";
|
||||
|
||||
export type ButtonDeleteProjectProps = {
|
||||
text? : string
|
||||
}
|
||||
|
||||
export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
|
||||
|
||||
// const mutation = useMutation({
|
||||
// mutationFn: () => deleteUserAction(""),
|
||||
// onSuccess: async () => {
|
||||
// await signOutAction();
|
||||
// },
|
||||
// })
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
text={props.text ? props.text : ""}
|
||||
onClick={() => {
|
||||
// mutation.mutate()
|
||||
console.log("ok")
|
||||
}}
|
||||
variant={"destructive"}
|
||||
// isPending={mutation.isPending}
|
||||
className="gap-2"
|
||||
icon={<Trash2/>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {Database} from "@prisma/client";
|
||||
|
||||
export type DatabaseKpiPro = {
|
||||
successRate: any,
|
||||
database: Database,
|
||||
totalBackups: number
|
||||
}
|
||||
|
||||
|
||||
export const DatabaseKpi = (props: DatabaseKpiPro) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Backups
|
||||
</CardHeader>
|
||||
<CardContent>{props.totalBackups}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Success rate
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{props.successRate ?? "Unavailable for now."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Last contact
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formatDateLastContact(props.database.lastContact)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
|
||||
import {backupColumns} from "@/features/backup/columns";
|
||||
import {restoreColumns} from "@/features/restore/columns";
|
||||
import {Backup, Database, Restoration} from "@prisma/client";
|
||||
import {useEffect} from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {eventUpdate} from "@/types/events";
|
||||
|
||||
export type DatabaseTabsProps = {
|
||||
backups: Backup[]
|
||||
restorations: Restoration[]
|
||||
isAlreadyRestore: boolean
|
||||
database: Database
|
||||
}
|
||||
|
||||
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource('/api/events');
|
||||
|
||||
eventSource.addEventListener('modification', (event) => {
|
||||
const data: eventUpdate = JSON.parse(event.data)
|
||||
if (data.update) {
|
||||
console.log("update", data.update)
|
||||
router.refresh()
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tabs className="flex flex-col flex-1" defaultValue="backup">
|
||||
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="backup">Backup</TabsTrigger>
|
||||
<TabsTrigger value="restore">Restoration</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="h-full justify-between" value="backup">
|
||||
<DataTableWithPagination columns={backupColumns} data={props.backups}
|
||||
extendedProps={props.isAlreadyRestore}/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="h-full justify-between" value="restore">
|
||||
<DataTableWithPagination columns={restoreColumns} data={props.restorations}/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
export type projectCardProps = {
|
||||
data: any
|
||||
}
|
||||
|
||||
export const ProjectCard = (props: projectCardProps) => {
|
||||
|
||||
const {data: project} = props;
|
||||
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/projects/${project.id}`}>
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="">
|
||||
<CardHeader>{project.name}</CardHeader>
|
||||
<CardContent>
|
||||
{project.databases.length} databases
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image"
|
||||
import {Database} from "@prisma/client";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {ConnectionCircle} from "@/components/wrappers/connection-circle";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
|
||||
export type projectDatabaseCardProps = {
|
||||
data: Database,
|
||||
extendedProps: any
|
||||
}
|
||||
|
||||
export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
|
||||
|
||||
const {data: database, extendedProps: extendedProps} = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
|
||||
<DatabaseCard data={database}/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export type databaseCardProps = {
|
||||
data: Database,
|
||||
}
|
||||
|
||||
export const DatabaseCard = (props: databaseCardProps) => {
|
||||
|
||||
const {data: database} = props;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Image
|
||||
src="/PostgreSQL.png"
|
||||
alt="Database type Icon"
|
||||
width={60}
|
||||
height={60}
|
||||
className="object-cover ml-4"
|
||||
/>
|
||||
<div>
|
||||
<CardHeader>Name : {database.name}</CardHeader>
|
||||
<CardContent>
|
||||
Last contact: {formatDateLastContact(database.lastContact)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 mr-3">
|
||||
<ConnectionCircle date={database.lastContact}/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {z} from "zod";
|
||||
import Database from "@prisma/client"
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
databases: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type ProjectType = z.infer<typeof ProjectSchema>;
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {ProjectSchema, ProjectType} from "@/components/wrappers/dashboard/Projects/ProjectsForm/ProjectForm.schema";
|
||||
import {
|
||||
createProjectAction,
|
||||
updateProjectAction
|
||||
} from "@/components/wrappers/dashboard/Projects/ProjectsForm/project-form.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Database, Organization, Projects} from "@prisma/client"
|
||||
import {MultiSelect} from "@/components/wrappers/MultiSelect/MultiSelect";
|
||||
import {toast} from "sonner";
|
||||
|
||||
|
||||
export type projectFormProps = {
|
||||
defaultValues?: ProjectType;
|
||||
databases: Database[],
|
||||
organization: Organization,
|
||||
projectId?: string;
|
||||
|
||||
}
|
||||
|
||||
export const ProjectForm = (props: projectFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(props.defaultValues)
|
||||
|
||||
|
||||
const formatDatabasesList = (databases: Database[]) => {
|
||||
return databases.map(database => ({
|
||||
value: database.id,
|
||||
label: `${database.name} (${database.generatedId}) | ${database.agent.name}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatDefaultDatabases = (databases: ProjectType['databases']): string[] => {
|
||||
return databases.map(database => database.id);
|
||||
};
|
||||
|
||||
const formattedDefaultValues = {
|
||||
...props.defaultValues,
|
||||
databases: !isCreate ? formatDefaultDatabases(props.defaultValues?.databases) : []
|
||||
}
|
||||
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ProjectSchema,
|
||||
defaultValues: formattedDefaultValues,
|
||||
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ProjectType) => {
|
||||
console.log(values)
|
||||
const project: Projects = isCreate ? await createProjectAction({
|
||||
data: values,
|
||||
organizationId: props.organization.id
|
||||
}) : await updateProjectAction({
|
||||
data: values,
|
||||
organizationId: props.organization.id,
|
||||
projectId: props.projectId
|
||||
});
|
||||
console.log(project)
|
||||
|
||||
if (project.data.success) {
|
||||
toast.success(project.data.actionSuccess.message);
|
||||
router.push(`/dashboard/projects/${project.data.value.id}`);
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.success(project.data.actionError.message);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Project 1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="project-1" {...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replaceAll(" ", "-").toLowerCase()
|
||||
field.onChange(value)
|
||||
}}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databases"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Databases</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
<MultiSelect
|
||||
options={formatDatabasesList(props.databases)}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ?? []}
|
||||
placeholder="Select databases"
|
||||
variant="inverted"
|
||||
animation={2}
|
||||
// maxCount={100}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Select databases you want to add to this project</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button>
|
||||
{isCreate ? `Create Project` : `Update Project`}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use server"
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {prisma} from "@/prisma";
|
||||
import {ProjectSchema} from "@/components/wrappers/dashboard/Projects/ProjectsForm/ProjectForm.schema";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {Projects} from "@prisma/client";
|
||||
|
||||
|
||||
export const createProjectAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
data: ProjectSchema,
|
||||
organizationId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Projects>> => {
|
||||
try {
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: parsedInput.data.name,
|
||||
slug: parsedInput.data.slug,
|
||||
organizationId: parsedInput.organizationId,
|
||||
}
|
||||
})
|
||||
|
||||
for (const db of parsedInput.data.databases) {
|
||||
|
||||
await prisma.database.update({
|
||||
where: {
|
||||
id: db,
|
||||
},
|
||||
data:{
|
||||
projectId: project.id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: project,
|
||||
actionSuccess: {
|
||||
message: "ProjectsForm has been successfully created.",
|
||||
messageParams: {projectName: parsedInput.data.name},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create ProjectsForm.",
|
||||
status: 500, // Optional: Use a meaningful status code
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {projectName: parsedInput.data.name},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
export const updateProjectAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
data: ProjectSchema,
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Projects>> => {
|
||||
try {
|
||||
const newDatabaseList = parsedInput.data.databases
|
||||
const project = await prisma.project.findFirst({
|
||||
where:{
|
||||
id: parsedInput.projectId,
|
||||
},
|
||||
include: {
|
||||
databases : {}
|
||||
}
|
||||
})
|
||||
const existingItemIds = project.databases.map((db) => db.id);
|
||||
|
||||
const databasesToAdd = newDatabaseList.filter(
|
||||
(id) => !existingItemIds.includes(id)
|
||||
);
|
||||
const databasesToRemove = existingItemIds.filter(
|
||||
(id) => !newDatabaseList.includes(id)
|
||||
);
|
||||
|
||||
console.log(databasesToAdd);
|
||||
console.log(databasesToRemove);
|
||||
|
||||
if (databasesToAdd.length > 0) {
|
||||
await prisma.database.updateMany({
|
||||
where: {
|
||||
id: { in: databasesToAdd },
|
||||
},
|
||||
data: {
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (databasesToRemove.length > 0) {
|
||||
await prisma.database.updateMany({
|
||||
where: {
|
||||
id: { in: databasesToRemove },
|
||||
},
|
||||
data: {
|
||||
projectId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedProject = await prisma.project.update({
|
||||
where:{
|
||||
id: parsedInput.projectId
|
||||
},
|
||||
data:{
|
||||
name: parsedInput.data.name,
|
||||
slug: parsedInput.data.slug,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: updatedProject,
|
||||
actionSuccess: {
|
||||
message: "Project has been successfully updated.",
|
||||
messageParams: {projectName: parsedInput.data.name},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update project.",
|
||||
status: 500, // Optional: Use a meaningful status code
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {projectName: parsedInput.data.name},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
|
||||
import {usersColumns} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/columns-users";
|
||||
import {User, Settings} from "@prisma/client";
|
||||
import {backupColumns} from "@/features/backup/columns";
|
||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/AdminEmailTab/SettingsEmailTab";
|
||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/AdminStorageTab/SettingsStorageTab";
|
||||
import {SettingsUsersTab} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/SettingsUsersTab";
|
||||
|
||||
|
||||
export type SettingsTabsProps = {
|
||||
currentUser: User;
|
||||
users: User[];
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export const SettingsTabs = (props: SettingsTabsProps) => {
|
||||
|
||||
const {currentUser, users} = props;
|
||||
|
||||
return (
|
||||
|
||||
// <Tabs defaultValue="informations" >
|
||||
// <TabsList className="w-full">
|
||||
// <TabsTrigger className="w-full " value="informations">Info</TabsTrigger>
|
||||
// <TabsTrigger className="w-full " value="users">Users</TabsTrigger>
|
||||
// <TabsTrigger className="w-full " value="email">Email</TabsTrigger>
|
||||
// <TabsTrigger className="w-full " value="storage">Storage</TabsTrigger>
|
||||
// </TabsList>
|
||||
// <TabsContent value="informations">
|
||||
// <div className="flex flex-1 flex-col gap-4 py-4">
|
||||
// <div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
// <div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
// <div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
// <div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
// </div>
|
||||
// <div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min"/>
|
||||
// </div>
|
||||
// </TabsContent>
|
||||
// <TabsContent value="users" >
|
||||
<SettingsUsersTab currentUser={currentUser} users={users}/>
|
||||
// </TabsContent>
|
||||
// <TabsContent value="email">
|
||||
// <SettingsEmailTab settings={props.settings}/>
|
||||
// </TabsContent>
|
||||
// <TabsContent value="storage">
|
||||
// <SettingsStorageTab settings={props.settings}/>
|
||||
// </TabsContent>
|
||||
// </Tabs>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {User} from "@prisma/client";
|
||||
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
|
||||
import {usersColumns} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/columns-users";
|
||||
import {UsersDataTable} from "@/components/wrappers/dashboard/admin/admin-user-table";
|
||||
|
||||
|
||||
export type SettingsUsersTabProps = {
|
||||
currentUser: User;
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export const SettingsUsersTab = (props: SettingsUsersTabProps) => {
|
||||
|
||||
const {currentUser, users} = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<div className="flex gap-4 h-fit justify-between">
|
||||
<h1>List of organization's users</h1>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<DataTableWithPagination
|
||||
columns={usersColumns}
|
||||
data={users}
|
||||
DataTable={UsersDataTable}
|
||||
dataTableProps={{currentUser}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import {ColumnDef} from "@tanstack/react-table"
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {User} from "@prisma/client";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/Profile/ButtonDeleteAccount/delete-account.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
|
||||
|
||||
export const usersColumns: ColumnDef<User>[] = [
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({row}) => {
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<string>(row.getValue("role"))
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}),
|
||||
onSuccess: () => {
|
||||
toast.success(`User updated successfully.`);
|
||||
router.refresh()
|
||||
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(`An error occurred while updating user information.`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
const nextRole = role === "admin" ? "pending"
|
||||
: role === "pending" ? "user"
|
||||
: "admin";
|
||||
setRole(nextRole);
|
||||
await updateMutation.mutateAsync()
|
||||
};
|
||||
|
||||
return <Badge
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleUpdateRole()}
|
||||
variant="outline">{role}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name"
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email"
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: ({row}) => {
|
||||
return new Date(row.getValue("createdAt")).toLocaleString("fr-FR");
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "authMethod",
|
||||
header: "Method",
|
||||
cell: ({row}) => {
|
||||
return <Badge variant="outline">{row.getValue("authMethod")}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
id: "actions",
|
||||
cell: ({ row, table }) => {
|
||||
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteUserAction(row.original.id),
|
||||
onSuccess: async () => {
|
||||
toast.success('User deleted successfully.');
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text=""
|
||||
icon={<Trash2 color="red" size={15} />}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="icon"
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem} from "@/components/ui/sidebar";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {ChartArea, Layers, Settings, ShieldHalf} from "lucide-react";
|
||||
import {usePathname} from "next/navigation";
|
||||
|
||||
export type SidebarMenuCustomProps = {}
|
||||
|
||||
export const SidebarMenuCustom = (props: SidebarMenuCustomProps) => {
|
||||
|
||||
const BASE_URL = "/dashboard";
|
||||
const pathname = usePathname();
|
||||
// Menu items.
|
||||
const items = [
|
||||
{
|
||||
title: "Projects",
|
||||
url: "projects",
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
title: "Agents",
|
||||
url: "agents",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{
|
||||
title: "Statistics",
|
||||
url: "statistics",
|
||||
icon: ChartArea,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "settings",
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const currentUrl = pathname;
|
||||
const currentItem = items.find((item) => `${BASE_URL}/${item.url}` === currentUrl);
|
||||
if (currentItem) {
|
||||
setActiveItem(currentItem.title);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const [activeItem, setActiveItem] = useState(items[0].title);
|
||||
const handleItemClick = (title: string) => {
|
||||
setActiveItem(title);
|
||||
console.log(title)
|
||||
}
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link
|
||||
className={cn(buttonVariants({
|
||||
size: "lg",
|
||||
variant: activeItem === item.title ? "secondary" : 'ghost'
|
||||
}), "justify-start p-0")}
|
||||
href={`${BASE_URL}/${item.url}`}
|
||||
onClick={() => handleItemClick(item.title)}
|
||||
>
|
||||
<item.icon/>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
className={`peer-data-[active=true]/menu-button:opacity-100 ${activeItem === item.title ? 'active' : ''}`}/>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// export type SidebarMenuItemCustomProps = {
|
||||
// title: string
|
||||
// url: string
|
||||
// icon: Element
|
||||
// }
|
||||
//
|
||||
//
|
||||
// export const SidebarMenuItemCustom = (props: SidebarMenuItemCustomProps) => {
|
||||
//
|
||||
// const {title, url, icon} = props;
|
||||
//
|
||||
// const BASE_URL = "/dashboard";
|
||||
// const pathname = usePathname();
|
||||
//
|
||||
// const [activeItem, setActiveItem] = useState(title);
|
||||
//
|
||||
// return (
|
||||
// <SidebarMenuItem key={title}>
|
||||
// <SidebarMenuButton asChild>
|
||||
// <Link
|
||||
// className={cn(buttonVariants({
|
||||
// size: "lg",
|
||||
// variant: activeItem === title ? "secondary" : 'ghost'
|
||||
// }), "justify-start p-0")}
|
||||
// href={`${BASE_URL}/${url}`}
|
||||
// onClick={() => handleItemClick(item.title)}
|
||||
// >
|
||||
// <icon/>
|
||||
// <span>{title}</span>
|
||||
// </Link>
|
||||
// </SidebarMenuButton>
|
||||
// <SidebarMenuAction
|
||||
// className={`peer-data-[active=true]/menu-button:opacity-100 ${activeItem === title ? 'active' : ''}`}/>
|
||||
// </SidebarMenuItem>
|
||||
// )
|
||||
// }
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/LoggedInButton/LoggedInButton";
|
||||
import {SidebarMenuCustom} from "@/components/wrappers/dashboard/SideBar/SideBarMenu/SideBarMenu";
|
||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/Organization/organization-combobox";
|
||||
import {prisma} from "@/prisma";
|
||||
import {requiredCurrentUser} from "@/auth/current-user";
|
||||
import {SideBarLogo} from "@/components/wrappers/Dashboard/SideBar/SideBarLogo/SideBarLogo";
|
||||
import {SideBarFooterCredit} from "@/components/wrappers/Dashboard/SideBar/SideBarFooterCredit/SideBarFooterCredit";
|
||||
|
||||
export async function AppSidebar() {
|
||||
|
||||
const user = await requiredCurrentUser()
|
||||
|
||||
const organizations = await prisma.organization.findMany({
|
||||
where: {
|
||||
users: {
|
||||
some: {
|
||||
userId: user.id
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const defaultOrganization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
slug: "default"
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SideBarLogo/>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<OrganizationCombobox
|
||||
organizations={organizations}
|
||||
defaultOrganization={defaultOrganization}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenuCustom/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<LoggedInButton/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SideBarFooterCredit/>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {currentUser, requiredCurrentUser} from "@/auth/current-user";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
|
||||
export type UserAvatarProps = {}
|
||||
|
||||
export const UserAvatar = async () => {
|
||||
|
||||
const user = await currentUser()
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
|
||||
{user.image ? (
|
||||
<AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`}/>
|
||||
) : null}
|
||||
</Avatar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
|
||||
import {
|
||||
EmailFormSchema,
|
||||
EmailFormType
|
||||
} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
|
||||
import Link from "next/link";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/PaswordInput/password-input";
|
||||
import {
|
||||
updateEmailSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
export type EmailFormProps = {
|
||||
defaultValues?: EmailFormType;
|
||||
}
|
||||
|
||||
export const EmailForm = (props: EmailFormProps) => {
|
||||
|
||||
const isCreate = !Boolean(props.defaultValues)
|
||||
|
||||
const form = useZodForm({
|
||||
schema: EmailFormSchema,
|
||||
defaultValues: props.defaultValues,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: EmailFormType) => {
|
||||
const updateEmailSettings = await updateEmailSettingsAction({name: "system", data: values})
|
||||
const data = updateEmailSettings?.data?.data
|
||||
if (updateEmailSettings?.serverError || !data) {
|
||||
console.log(updateEmailSettings?.serverError);
|
||||
toast.error(updateEmailSettings?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Success updating email informations`);
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
<Form form={form}
|
||||
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpFrom"
|
||||
defaultValue=""
|
||||
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"exemple@portabase.com"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"The email from where the email will be send"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpHost"
|
||||
defaultValue=""
|
||||
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server Host *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"ssl0.ovh.net"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Your email server host"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
defaultValue=""
|
||||
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server Port *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"465"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Your email server port (send)"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPassword"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Password" {...field}/>
|
||||
</FormControl>
|
||||
<FormDescription>{"Your email server password"}</FormDescription>
|
||||
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpUser"
|
||||
defaultValue=""
|
||||
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"exemple@portabase.com"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"The email server user"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
|
||||
<Button>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server"
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {prisma} from "@/prisma";
|
||||
import {EmailFormSchema} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
|
||||
|
||||
|
||||
export const updateEmailSettingsAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
data: EmailFormSchema,
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
|
||||
console.log("parsedInput", parsedInput.data)
|
||||
|
||||
const updatedSettings = await prisma.settings.update({
|
||||
where: {
|
||||
name: parsedInput.name,
|
||||
},
|
||||
data: parsedInput.data,
|
||||
})
|
||||
|
||||
return {
|
||||
data: updatedSettings,
|
||||
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const EmailFormSchema = z.object({
|
||||
smtpPassword: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpPort: z.string(),
|
||||
smtpUser: z.string(),
|
||||
});
|
||||
|
||||
export type EmailFormType = z.infer<typeof EmailFormSchema>;
|
||||
@@ -0,0 +1,61 @@
|
||||
import {EmailForm} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/EmailForm";
|
||||
import {Settings} from "@prisma/client";
|
||||
import {Send} from "lucide-react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {sendEmail} from "@/utils/email-helper";
|
||||
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
|
||||
import {render} from "@react-email/render";
|
||||
import {toast} from "sonner";
|
||||
import HelloEmail from "../../../../../../emails/HelloEmail";
|
||||
|
||||
|
||||
|
||||
export type SettingsEmailTabProps = {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const email = await sendEmail({
|
||||
to: props.settings.smtpUser,
|
||||
subject: "Portabase",
|
||||
html: await render(TestEmailSettings(),{})
|
||||
});
|
||||
if(email.response){
|
||||
toast.success("Test Email Successfully sent !");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
const handleSendMailTest = async () => {
|
||||
await mutation.mutateAsync()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<div className="flex gap-4 h-fit justify-between">
|
||||
<h1>Settings for Portabase email setup</h1>
|
||||
{props.settings.smtpFrom && (
|
||||
<ButtonWithLoading
|
||||
isPending={mutation.isPending}
|
||||
onClick={async () => {
|
||||
await handleSendMailTest()
|
||||
}}
|
||||
icon={<Send/>}
|
||||
text="Send email test"
|
||||
size="default"
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings : null}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Info, Send, ShieldCheck} from "lucide-react";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/StorageS3Form";
|
||||
import {useState} from "react";
|
||||
import {Settings} from "@prisma/client";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
|
||||
import {toast} from "sonner";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
updateStorageSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
|
||||
|
||||
|
||||
export type SettingsStorageTabProps = {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
const router = useRouter()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await checkConnexionToS3()
|
||||
if(result.error){
|
||||
toast.error("An error occured during the connexion !")
|
||||
}else{
|
||||
toast.success("Connexion succeed!")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3": "local"}}),
|
||||
onSuccess: () => {
|
||||
toast.success(`Settings updated successfully.`);
|
||||
router.refresh()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(`An error occurred while updating settings information.`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const HandleSwitchStorage = async () => {
|
||||
setIsSwitched(!isSwitched);
|
||||
await updateMutation.mutateAsync()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<h1>Settings for Portabase storage</h1>
|
||||
<Alert className="mt-3">
|
||||
<Info className="h-4 w-4"/>
|
||||
<AlertTitle>Informations</AlertTitle>
|
||||
<AlertDescription>
|
||||
Actually you can only store you data in one place : s3 compatible or in local.
|
||||
For exemple you cannot choose to store images in one place and .dump files in another.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col h-full py-4 ">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="storage-mode">Storage Mode (Local/s3 compatible)</Label>
|
||||
<Switch
|
||||
checked={isSwitched}
|
||||
onCheckedChange={async () => {
|
||||
await HandleSwitchStorage()
|
||||
}}
|
||||
id="storage-mode"/>
|
||||
</div>
|
||||
<div>
|
||||
<ButtonWithLoading
|
||||
size={"default"}
|
||||
disabled={!isSwitched}
|
||||
isPending={mutation.isPending}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync()
|
||||
}}
|
||||
icon={<ShieldCheck />}
|
||||
text="Test connexion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSwitched && (
|
||||
<div className="mt-5">
|
||||
<StorageS3Form defaultValues={props.settings.s3EndPointUrl ? props.settings : null}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
|
||||
import {
|
||||
S3FormSchema,
|
||||
S3FormType
|
||||
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {
|
||||
updateS3SettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
|
||||
|
||||
export type S3FormProps = {
|
||||
defaultValues?: S3FormType;
|
||||
}
|
||||
|
||||
export const StorageS3Form = (props: S3FormProps) => {
|
||||
const form = useZodForm({
|
||||
schema: S3FormSchema,
|
||||
defaultValues: props.defaultValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: S3FormType) => {
|
||||
console.log(values)
|
||||
const updateS3Settings = await updateS3SettingsAction({name: "system", data: values})
|
||||
const data = updateS3Settings?.data?.data
|
||||
if (updateS3Settings?.serverError || !data) {
|
||||
console.log(updateS3Settings?.serverError);
|
||||
toast.error(updateS3Settings?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Success updating storage informations`);
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Form form={form}
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="s3EndPointUrl"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint Url *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"s3.eu-west-3.amazonaws.com"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Your s3 compatible url"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="s3AccessKeyId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"The access key token"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Add your access key"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="s3SecretAccessKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"The secret key token"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"Add your secret key"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="S3BucketName"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"my-bucket"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{"The bucket name where you want to store your data"}</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
|
||||
<Button>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use server"
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {prisma} from "@/prisma";
|
||||
import {
|
||||
S3FormSchema,
|
||||
StorageSwitchSchema
|
||||
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema";
|
||||
|
||||
|
||||
export const updateS3SettingsAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
data: S3FormSchema,
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
const updatedSettings = await prisma.settings.update({
|
||||
where: {
|
||||
name: parsedInput.name,
|
||||
},
|
||||
data: parsedInput.data,
|
||||
})
|
||||
return {
|
||||
data: updatedSettings,
|
||||
}
|
||||
})
|
||||
|
||||
export const updateStorageSettingsAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
data: StorageSwitchSchema,
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
const updatedSettings = await prisma.settings.update({
|
||||
where: {
|
||||
name: parsedInput.name,
|
||||
},
|
||||
data: parsedInput.data,
|
||||
})
|
||||
return {
|
||||
data: updatedSettings,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const S3FormSchema = z.object({
|
||||
s3EndPointUrl: z.string(),
|
||||
s3AccessKeyId: z.string(),
|
||||
s3SecretAccessKey: z.string(),
|
||||
S3BucketName: z.string(),
|
||||
});
|
||||
|
||||
export type S3FormType = z.infer<typeof S3FormSchema>;
|
||||
|
||||
|
||||
export const StorageSwitchSchema = z.object({
|
||||
storage: z.string(),
|
||||
})
|
||||
|
||||
export type StorageType= z.infer<typeof StorageSwitchSchema>;
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import {User, Settings} from "@prisma/client";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/AdminEmailTab/SettingsEmailTab";
|
||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/AdminStorageTab/SettingsStorageTab";
|
||||
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-table";
|
||||
|
||||
|
||||
export type AdminTabsProps = {
|
||||
currentUser: User;
|
||||
users: User[];
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export const AdminTabs = (props: AdminTabsProps) => {
|
||||
|
||||
const {currentUser, users, settings} = props;
|
||||
|
||||
return (
|
||||
|
||||
<Tabs defaultValue="users">
|
||||
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full " value="users">Users</TabsTrigger>
|
||||
<TabsTrigger className="w-full " value="email">Email</TabsTrigger>
|
||||
<TabsTrigger className="w-full " value="storage">Storage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<AdminUsersTable currentUser={currentUser} users={users}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="email">
|
||||
<SettingsEmailTab settings={settings}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="storage">
|
||||
<SettingsStorageTab settings={settings}/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {flexRender, Row, RowData} from "@tanstack/react-table";
|
||||
|
||||
import {User} from "@prisma/client";
|
||||
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
|
||||
import {usersColumns} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/columns-users";
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type AdminUsersTableProps = {
|
||||
currentUser: User;
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export const AdminUsersTable = (props: AdminUsersTableProps) => {
|
||||
|
||||
const {currentUser, users} = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<div className="flex gap-4 h-fit justify-between">
|
||||
<h1>List of Portabase's users</h1>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<DataTableWithPagination
|
||||
columns={usersColumns}
|
||||
data={users}
|
||||
DataTable={UsersDataTable}
|
||||
dataTableProps={{currentUser}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export type usersDataTableProps = {
|
||||
currentUser: User;
|
||||
table: any,
|
||||
}
|
||||
|
||||
export const UsersDataTable = ({currentUser, table}: usersDataTableProps) => {
|
||||
|
||||
return (
|
||||
<div className="rounded-md border w-full ">
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row: Row<User>) => (
|
||||
<TableRow
|
||||
className={cn(row.original.id === currentUser.id ? "opacity-40 pointer-events-none" : "")}
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {ValueIcon} from "@radix-ui/react-icons";
|
||||
import {Circle} from "lucide-react";
|
||||
import {ConnectionCircle} from "@/components/wrappers/connection-circle";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
|
||||
export type agentCardProps = {
|
||||
data: any
|
||||
}
|
||||
|
||||
export const AgentCard = (props: agentCardProps) => {
|
||||
|
||||
const {data: agent} = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/agents/${agent.id}`}>
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="">
|
||||
<CardHeader>{agent.name}</CardHeader>
|
||||
<CardContent>
|
||||
Last contact : {formatDateLastContact(agent.lastContact)}
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="mt-3 mr-3">
|
||||
<ConnectionCircle date={agent.lastContact}/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {AgentSchema, AgentType} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
|
||||
import {toast} from "sonner";
|
||||
import {createAgentAction, updateAgentAction} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.action";
|
||||
|
||||
export type agentFormProps = {
|
||||
defaultValues?: AgentType;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export const AgentForm = (props: agentFormProps) => {
|
||||
|
||||
const isCreate = !Boolean(props.defaultValues)
|
||||
// const defaultValues = isCreate ? {slug: ""} : props.defaultValues
|
||||
|
||||
const form = useZodForm({
|
||||
schema: AgentSchema,
|
||||
defaultValues: props.defaultValues,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: AgentType) => {
|
||||
console.log("values", values)
|
||||
|
||||
const createAgent = isCreate ? await createAgentAction(values) : await updateAgentAction({
|
||||
id: props.agentId ?? "-",
|
||||
data: values
|
||||
});
|
||||
|
||||
const data = createAgent?.data?.data
|
||||
if (createAgent?.serverError || !data) {
|
||||
console.log(createAgent?.serverError);
|
||||
toast.error(createAgent?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Success`);
|
||||
router.push(`/dashboard/agents/${data.id}`);
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Form form={form}
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Agent 1" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your agent project name</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
|
||||
name="slug"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value ?? ""}
|
||||
placeholder="agent-1" {...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replaceAll(" ", "-").toLowerCase()
|
||||
field.onChange(value)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>The slug is used in the url of the agent</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
|
||||
name="description"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='This agent is for the client exemple.com' {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormDescription>Enter your project agent description</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button>
|
||||
{isCreate ? `Create agent` : `Save agent`}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server"
|
||||
import {ActionError, userAction} from "@/safe-actions";
|
||||
import {prisma} from "@/prisma";
|
||||
import {AgentSchema} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
||||
const slugExists = await prisma.agent.count({
|
||||
where: {
|
||||
slug: slug,
|
||||
id: agentId ? {
|
||||
not: agentId
|
||||
} : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(slugExists)
|
||||
if (slugExists) {
|
||||
throw new ActionError("Slug already exists");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const createAgentAction = userAction
|
||||
.schema(AgentSchema)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
// Verify if slug already exist
|
||||
await verifySlugUniqueness(parsedInput.slug);
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
...parsedInput
|
||||
}
|
||||
})
|
||||
|
||||
// await sendEmailIfUserCreatedFirstForm(ctx.user)
|
||||
|
||||
return {
|
||||
data: agent,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const updateAgentAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: AgentSchema,
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
await verifySlugUniqueness(parsedInput.data.slug, parsedInput.id);
|
||||
|
||||
console.log("parsedInput", parsedInput.data)
|
||||
|
||||
const updatedAgent = await prisma.agent.update({
|
||||
where: {
|
||||
id: parsedInput.id,
|
||||
},
|
||||
data: parsedInput.data,
|
||||
})
|
||||
|
||||
return {
|
||||
data: updatedAgent,
|
||||
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const AgentSchema = z.object({
|
||||
name: z.string(),
|
||||
slug: z.string().regex(/^[a-zA-Z0-9_-]*$/).min(5).max(25),
|
||||
description: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type AgentType = z.infer<typeof AgentSchema>;
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
import {CodeSnippet} from "@/components/wrappers/CodeSnippet/CodeSnippet";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {Copy} from "lucide-react";
|
||||
import {PropsWithChildren, useState} from "react";
|
||||
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
|
||||
import {Agent} from "@prisma/client";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
export type agentRegistrationDialogProps = PropsWithChildren<{
|
||||
agent: Agent
|
||||
}>
|
||||
|
||||
|
||||
export function AgentModalKey(props: agentRegistrationDialogProps) {
|
||||
|
||||
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
|
||||
const code = `EDGE_KEY = ${edge_key}`;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{props.children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agent Edge Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="sm:max-w-[375px] w-full">
|
||||
<CodeSnippet
|
||||
code={code}
|
||||
// className="w-full overflow-x-auto break-words"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<CopyButton value={code}/>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server"
|
||||
|
||||
import {z} from "zod";
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {prisma} from "@/prisma";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {Backup} from "@prisma/client";
|
||||
|
||||
|
||||
export const backupButtonAction = userAction
|
||||
.schema(z.string())
|
||||
.action(async ({ parsedInput, ctx }): Promise<ServerActionResult<Backup>> => {
|
||||
try {
|
||||
const backup = await prisma.backup.create({
|
||||
data: {
|
||||
databaseId: parsedInput,
|
||||
status: "waiting",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: backup,
|
||||
actionSuccess: {
|
||||
message: "Backup has been successfully created.",
|
||||
messageParams: { databaseId: parsedInput },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create backup.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { databaseId: parsedInput },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
|
||||
|
||||
export type BackupButtonProps = {
|
||||
databaseId: string
|
||||
disable: boolean
|
||||
}
|
||||
|
||||
export const BackupButton = (props: BackupButtonProps) => {
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (databaseId: string) => {
|
||||
const backup = await backupButtonAction(databaseId)
|
||||
console.log(backup)
|
||||
if (backup.data.success) {
|
||||
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(backup.serverError || "Failed to create backup.");
|
||||
}
|
||||
}
|
||||
})
|
||||
const HandleAction = async () => {
|
||||
await mutation.mutateAsync(props.databaseId)
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonWithLoading
|
||||
disabled={props.disable}
|
||||
text="Backup"
|
||||
isPending={mutation.isPending}
|
||||
size={"default"}
|
||||
onClick={async () => {
|
||||
await HandleAction()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {isValidCronPart} from "@/utils/cron";
|
||||
|
||||
export const AdvancedCronSelect = ({
|
||||
id,
|
||||
label,
|
||||
options,
|
||||
type,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
type: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}) => {
|
||||
const [isAdvanced, setIsAdvanced] = useState(false);
|
||||
const [customValue, setCustomValue] = useState(defaultValue || value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (customValue.trim() === "") {
|
||||
setIsAdvanced(false);
|
||||
} else if (!isValidCronPart(type, customValue)) {
|
||||
setError("Invalid cron part value.");
|
||||
} else {
|
||||
setError(null);
|
||||
onValueChange(customValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
<Label htmlFor={id} className="text-left">{label}</Label>
|
||||
{!isAdvanced ? (
|
||||
<Select
|
||||
id={id}
|
||||
className="col-span-4"
|
||||
value={defaultValue}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === "advanced") {
|
||||
setIsAdvanced(true);
|
||||
} else {
|
||||
setCustomValue(value);
|
||||
onValueChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{value}
|
||||
{/*{options.includes(value) ? value : "Custom value"}*/}
|
||||
</SelectValue>
|
||||
{/*<SelectValue placeholder="Select value" />*/}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt: string) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
className="col-span-4"
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCustomValue(newValue);
|
||||
if (isValidCronPart(type, newValue)) {
|
||||
setError(null);
|
||||
} else {
|
||||
setError("Invalid cron part value.");
|
||||
}
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
placeholder="e.g., *, 1-5, */5"
|
||||
/>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500 col-span-4">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
import {Clock9} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription, DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {CronInput} from "@/components/wrappers/dashboard/database/CronButton/CronInput";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {useState} from "react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {
|
||||
updateStorageSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Database} from "@prisma/client";
|
||||
import {
|
||||
updateBackupPolicyAction,
|
||||
updateDatabaseBackupPolicyAction
|
||||
} from "@/components/wrappers/dashboard/database/CronButton/cron.action";
|
||||
|
||||
|
||||
export type CronButtonProps = {
|
||||
database: Database
|
||||
}
|
||||
|
||||
export const CronButton = (props: CronButtonProps) => {
|
||||
const router = useRouter();
|
||||
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
|
||||
|
||||
const updateDatabaseBackupPolicy = useMutation({
|
||||
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: props.database.id, backupPolicy:value}),
|
||||
onSuccess: () => {
|
||||
toast.success(`Method updated successfully.`);
|
||||
router.refresh()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(`An error occurred while updating backup method.`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const handleTypeChange = async (state: boolean) => {
|
||||
setIsSwitched(state);
|
||||
if(state == false) {
|
||||
await updateDatabaseBackupPolicy.mutateAsync("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
{...props}
|
||||
|
||||
>
|
||||
<Clock9/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup method</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your settings for the backup method
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Separator/>
|
||||
|
||||
<h1>
|
||||
Select your backup method
|
||||
</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label>Manual / Automatic </Label>
|
||||
<Switch
|
||||
checked={isSwitched}
|
||||
onCheckedChange={async () => {
|
||||
await handleTypeChange(!isSwitched)
|
||||
}}
|
||||
id="type-mode"/>
|
||||
</div>
|
||||
{isSwitched ?
|
||||
<CronInput database={props.database}/>
|
||||
:null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AdvancedCronSelect } from "./AdvancedCronSelect";
|
||||
import {
|
||||
updateBackupPolicyAction,
|
||||
updateDatabaseBackupPolicyAction
|
||||
} from "@/components/wrappers/dashboard/database/CronButton/cron.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useState} from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {Database} from "@prisma/client";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
export type CronInputProps = {
|
||||
database : Database
|
||||
}
|
||||
|
||||
|
||||
export const CronInput = ({ database }: CronInputProps) => {
|
||||
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
|
||||
const router = useRouter();
|
||||
|
||||
const updateBackupPolicy = useMutation({
|
||||
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({ databaseId: database.id, backupPolicy: value }),
|
||||
onSuccess: () => {
|
||||
toast.success(`Cron updated successfully.`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(`An error occurred while updating cron value.`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangeCron = (type: string, value: string) => {
|
||||
const cronParts = cron.split(" ");
|
||||
const indexMap = { minute: 0, hour: 1, "day-of-month": 2, month: 3, "day-of-week": 4 };
|
||||
cronParts[indexMap[type]] = value;
|
||||
setCron(cronParts.join(" "));
|
||||
};
|
||||
|
||||
const handleUpdateCron = async (cron: string) => {
|
||||
await updateBackupPolicy.mutateAsync(cron);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Configure your cron schedule</h1>
|
||||
<AdvancedCronSelect
|
||||
id="minute"
|
||||
label="Minute"
|
||||
options={Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))}
|
||||
type="minute"
|
||||
value={cron.split(" ")[0]}
|
||||
defaultValue={cron.split(" ")[0]}
|
||||
onValueChange={(value) => handleChangeCron("minute", value)}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="hour"
|
||||
label="Hour"
|
||||
options={Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))}
|
||||
type="hour"
|
||||
value={cron.split(" ")[1]}
|
||||
defaultValue={cron.split(" ")[1]}
|
||||
onValueChange={(value) => handleChangeCron("hour", value)}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="day-of-month"
|
||||
label="Day of Month"
|
||||
options={Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0"))}
|
||||
type="day-of-month"
|
||||
value={cron.split(" ")[2]}
|
||||
defaultValue={cron.split(" ")[2]}
|
||||
onValueChange={(value) => handleChangeCron("day-of-month", value)}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="month"
|
||||
label="Month"
|
||||
options={["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]}
|
||||
type="month"
|
||||
value={cron.split(" ")[3]}
|
||||
defaultValue={cron.split(" ")[3]}
|
||||
onValueChange={(value) => handleChangeCron("month", value)}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="day-of-week"
|
||||
label="Day of Week"
|
||||
options={["0", "1", "2", "3", "4", "5", "6"]}
|
||||
type="day-of-week"
|
||||
value={cron.split(" ")[4]}
|
||||
defaultValue={cron.split(" ")[4]}
|
||||
onValueChange={(value) => handleChangeCron("day-of-week", value)}
|
||||
/>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">Cron Expression</div>
|
||||
<div className="font-mono text-muted-foreground">{cron}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This cron expression determines when the job will run.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setCron("* * * * *");
|
||||
await handleUpdateCron("* * * * *");
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleUpdateCron(cron);
|
||||
}}
|
||||
>
|
||||
Save cron
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server"
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {prisma} from "@/prisma";
|
||||
|
||||
|
||||
export const updateDatabaseBackupPolicyAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
databaseId: z.string(),
|
||||
backupPolicy: z.string(),
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
const cronPolicy = parsedInput.backupPolicy == "" ? null : parsedInput.backupPolicy
|
||||
|
||||
const updatedDatabase = await prisma.database.update({
|
||||
where: {
|
||||
id: parsedInput.databaseId,
|
||||
},
|
||||
data: {
|
||||
backupPolicy: cronPolicy,
|
||||
}
|
||||
})
|
||||
return {
|
||||
data: updatedDatabase,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form"
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {DatabaseSchema, DatabaseType} from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema";
|
||||
import {updateDatabaseAction} from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type DatabaseFormProps = {
|
||||
defaultValues?: DatabaseType;
|
||||
databaseId?: string;
|
||||
}
|
||||
|
||||
export const DatabaseForm = (props: DatabaseFormProps) => {
|
||||
|
||||
const {defaultValues, databaseId} = props;
|
||||
|
||||
const isCreate = !Boolean(defaultValues)
|
||||
|
||||
const form = useZodForm({
|
||||
schema: DatabaseSchema,
|
||||
defaultValues: {...defaultValues},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: DatabaseType) => {
|
||||
|
||||
const database = await updateDatabaseAction({id: databaseId, data: values});
|
||||
|
||||
if (database.serverError) {
|
||||
console.error(database?.serverError);
|
||||
toast.error(database?.serverError);
|
||||
return;
|
||||
}
|
||||
console.log(database)
|
||||
toast.success(`Database settings successfully updated!`);
|
||||
|
||||
router.back()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Form form={form}
|
||||
className="flex flex-col gap-4 mt-3"
|
||||
onSubmit={async (values) => {
|
||||
console.log("sssssss")
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled placeholder="Database 1" {...field}/>
|
||||
</FormControl>
|
||||
<FormDescription>Your database project name setup in agent</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dbms"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database type</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled placeholder="PostgreSQL" {...field}/>
|
||||
</FormControl>
|
||||
<FormDescription>Your database project name setup in agent</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Prod database for project 1" {...field}/>
|
||||
</FormControl>
|
||||
<FormDescription>Add a short description about this database</FormDescription>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button>
|
||||
{isCreate ? `Create database` : `Save database`}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use server"
|
||||
|
||||
import {z} from "zod";
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {prisma} from "@/prisma";
|
||||
import {DatabaseSchema} from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema";
|
||||
|
||||
|
||||
export const updateDatabaseAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: DatabaseSchema,
|
||||
}
|
||||
)
|
||||
)
|
||||
.action(async ({parsedInput, ctx}) => {
|
||||
return prisma.database.update({
|
||||
where: {
|
||||
id: parsedInput.id,
|
||||
},
|
||||
data: parsedInput.data,
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import {z} from "zod";
|
||||
|
||||
const cronRegex = /^(\d{1,2}|\*|(\d{1,2}-\d{1,2})|(\d{1,2}\/\d{1,2}))\s+(\d{1,2}|\*|(\d{1,2}-\d{1,2})|(\d{1,2}\/\d{1,2}))\s+(\d{1,2}|\*|(\d{1,2}-\d{1,2})|(\d{1,2}\/\d{1,2}))\s+(\d{1,7}|\*|(\d{1,7}-\d{1,7})|(\d{1,7}\/\d{1,7}))$/;
|
||||
|
||||
|
||||
export const DatabaseSchema = z.object({
|
||||
name: z.string().readonly(),
|
||||
description: z.string().optional(),
|
||||
dbms: z.string().readonly(),
|
||||
});
|
||||
|
||||
export type DatabaseType = z.infer<typeof DatabaseSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import {usePathname} from "next/navigation";
|
||||
|
||||
export type EditButtonProps = {}
|
||||
|
||||
export const EditButton = (props: EditButtonProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return(
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
href={`${pathname}/edit`}
|
||||
>
|
||||
<GearIcon className="w-7 h-7" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
|
||||
import {DateTimePicker} from "@/components/wrappers/daytime-picker";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {RestoreSchema} from "@/components/wrappers/dashboard/database/restore-form.schema";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {ComboBox, ComboBoxFormItem} from "@/components/wrappers/common/combobox";
|
||||
import {Label} from "@/components/ui/label";
|
||||
|
||||
export type restoreFormProps = {
|
||||
databaseToRestore: any
|
||||
databases: any[]
|
||||
backups: any[]
|
||||
}
|
||||
|
||||
|
||||
export const RestoreForm = (props: restoreFormProps) => {
|
||||
|
||||
const {databaseToRestore, databases, backups} = props
|
||||
|
||||
console.log("bacups", backups)
|
||||
console.log("bacups", databaseToRestore)
|
||||
|
||||
const backupLocations = [
|
||||
{
|
||||
value: "remote-file",
|
||||
label: "Remote File",
|
||||
},
|
||||
{
|
||||
value: "desktop-file",
|
||||
label: "Desktop File",
|
||||
},
|
||||
]
|
||||
|
||||
const executionModes = [
|
||||
{
|
||||
value: "immediate",
|
||||
label: "Immediate",
|
||||
},
|
||||
{
|
||||
value: "scheduled",
|
||||
label: "Scheduled",
|
||||
},
|
||||
]
|
||||
|
||||
const [selectedDatabaseId, setSelectedDatabaseId] = useState(databaseToRestore.id)
|
||||
|
||||
|
||||
const filteredBackups = backups.filter(backup => backup.databaseId == selectedDatabaseId)
|
||||
|
||||
const defaultValues = {
|
||||
executionMode: "immediate",
|
||||
backupLocation: "remote-file",
|
||||
}
|
||||
|
||||
const form = useZodForm({
|
||||
schema: RestoreSchema,
|
||||
defaultValues: defaultValues
|
||||
});
|
||||
|
||||
const values = form.getValues()
|
||||
console.log("values", values)
|
||||
|
||||
|
||||
const mutation = useMutation({})
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={async (values) => {
|
||||
console.log(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupLocation"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backup location</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{backupLocations.map((backupLocation, key) =>
|
||||
<SelectItem key={key} value={backupLocation.value}>
|
||||
{backupLocation.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{values.backupLocation == "remote-file" ?
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Label>Database</Label>
|
||||
<ComboBox
|
||||
values={databases.map(database =>
|
||||
({"value": database.id, "label": database.name})
|
||||
)}
|
||||
onValueChange={setSelectedDatabaseId}
|
||||
defaultValue={selectedDatabaseId}
|
||||
searchField
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteBackup"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Remote backup</FormLabel>
|
||||
<ComboBoxFormItem
|
||||
values={filteredBackups.map(backup => ({
|
||||
"value": backup.id,
|
||||
"label": backup.createdAt.toString()
|
||||
})
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
: null}
|
||||
|
||||
{values.backupLocation == "desktop-file" ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="uploadedBackupFile"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>File</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="uploadedBackupFile" type="file" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/> : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="executionMode"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Execution mode</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{executionModes.map((executionMode, key) =>
|
||||
<SelectItem key={key} value={executionMode.value}>
|
||||
{executionMode.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{values.executionMode == "scheduled" ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledDatetime"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scheduled date</FormLabel>
|
||||
<FormControl>
|
||||
<DateTimePicker value={field.value} onChange={field.onChange}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/> : null}
|
||||
|
||||
<Button type="submit">Launch restore</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {z} from "zod";
|
||||
|
||||
const ImmediateExecutionSchema = z.object({
|
||||
executionMode: z.literal('immediate'),
|
||||
});
|
||||
|
||||
const ScheduledExecutionSchema = z.object({
|
||||
executionMode: z.literal('scheduled'),
|
||||
scheduledDatetime: z.date(),
|
||||
});
|
||||
|
||||
const CommonBackupSchema = z.union([ImmediateExecutionSchema, ScheduledExecutionSchema]);
|
||||
|
||||
const RemoteBackupSchema = z.object({
|
||||
backupLocation: z.literal('remote-file'),
|
||||
}).merge(CommonBackupSchema);
|
||||
|
||||
const DesktopBackupSchema = z.object({
|
||||
backupLocation: z.literal('desktop-file'),
|
||||
uploadedBackupFile: z.instanceof(File),
|
||||
}).merge(CommonBackupSchema);
|
||||
|
||||
export const RestoreSchema = z.union([RemoteBackupSchema, DesktopBackupSchema]);
|
||||
export type RestoreType = z.infer<typeof RestoreSchema>;
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart";
|
||||
import {CartesianGrid, Line, LineChart, XAxis, YAxis} from "recharts";
|
||||
import {humanReadableDate} from "@/utils/date-formatting";
|
||||
|
||||
const data = [
|
||||
{date: "2024-12-01", count: 1},
|
||||
{date: "2024-12-02", count: 2},
|
||||
{date: "2024-12-03", count: 4},
|
||||
{date: "2024-12-04", count: 8},
|
||||
{date: "2024-12-05", count: 9},
|
||||
{date: "2024-12-06", count: 9},
|
||||
{date: "2024-12-07", count: 10},
|
||||
{date: "2024-12-08", count: 13},
|
||||
{date: "2024-12-09", count: 15},
|
||||
{date: "2024-12-10", count: 18},
|
||||
|
||||
]
|
||||
|
||||
|
||||
type Data = {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
export type evolutionLineChartProps = {
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
export function EvolutionLineChart(props: evolutionLineChartProps) {
|
||||
|
||||
const {data} = props
|
||||
console.log("aaa data", data)
|
||||
|
||||
|
||||
// Process data to calculate cumulative count
|
||||
const cumulativeData = data.reduce((acc, backup) => {
|
||||
const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
|
||||
|
||||
// Increment count for the current date or initialize it
|
||||
if (acc.length && acc[acc.length - 1].date === date) {
|
||||
acc[acc.length - 1].count += 1;
|
||||
} else {
|
||||
const lastCount = acc.length ? acc[acc.length - 1].count : 0;
|
||||
acc.push({date, count: lastCount + 1});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as { date: string; count: number }[]);
|
||||
|
||||
|
||||
const chartConfig = {
|
||||
date: {
|
||||
label: "Date",
|
||||
color: "#2563eb",
|
||||
},
|
||||
count: {
|
||||
label: "Number of backups",
|
||||
color: "#60a5fa",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false}/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => humanReadableDate(Date(value)).split(' ')[0]}
|
||||
/>
|
||||
<YAxis/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel/>}
|
||||
/>
|
||||
<Line
|
||||
dataKey="count"
|
||||
type="linear"
|
||||
stroke="var(--color-desktop)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart";
|
||||
import {CartesianGrid, Line, LineChart, XAxis, YAxis} from "recharts";
|
||||
|
||||
type Data = {
|
||||
createdAt: Date;
|
||||
status: "success" | "failed";
|
||||
_count: { id: number };
|
||||
}
|
||||
|
||||
export type percentageLineChartProps = {
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
|
||||
export function PercentageLineChart(props: percentageLineChartProps) {
|
||||
|
||||
const {data} = props
|
||||
|
||||
const chartConfig = {
|
||||
date: {
|
||||
label: "Date",
|
||||
color: "#2563eb",
|
||||
},
|
||||
successRate: {
|
||||
label: "Success Rate",
|
||||
color: "#60a5fa",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const dailyStats = data.reduce((acc, backup) => {
|
||||
const date = backup.createdAt.toISOString().split("T")[0]; // Format YYYY-MM-DD
|
||||
const status = backup.status;
|
||||
|
||||
if (!acc[date]) {
|
||||
acc[date] = {success: 0, failed: 0, total: 0};
|
||||
}
|
||||
|
||||
acc[date][status === "success" ? "success" : "failed"] += backup._count.id;
|
||||
acc[date].total += backup._count.id;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, { success: number; failed: number; total: number }>);
|
||||
|
||||
// Format data for the chart
|
||||
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
|
||||
date,
|
||||
successRate: (stats.success / stats.total) * 100,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={formattedData}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date"/>
|
||||
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`}/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent/>}
|
||||
cursor={false}
|
||||
defaultIndex={1}
|
||||
formatter={(value, name) => (
|
||||
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
|
||||
{chartConfig[name as keyof typeof chartConfig]?.label ||
|
||||
name}
|
||||
<div
|
||||
className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
|
||||
{value}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2}/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user