Skip to content

Commit

Permalink
feat(saas): Admin Organizations management
Browse files Browse the repository at this point in the history
  • Loading branch information
alifarooq9 committed May 4, 2024
1 parent ced5523 commit 8c885f2
Show file tree
Hide file tree
Showing 13 changed files with 491 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreHorizontalIcon } from "lucide-react";
import { toast } from "sonner";
import { type OrganizationsData } from "./columns";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { deleteOrgAdminMutation } from "@/server/actions/organization/mutations";

export function ColumnDropdown({ id }: OrganizationsData) {
const router = useRouter();

const { mutateAsync: deleteUserMutate, isPending: deleteUserIsPending } =
useMutation({
mutationFn: () => deleteOrgAdminMutation({ id }),
onSettled: () => {
router.refresh();
},
});

const deleteUser = () => {
toast.promise(async () => await deleteUserMutate(), {
loading: "Deleting user...",
success: "User deleted!",
error: (e) => {
console.log(e);
return "Failed to delete user.";
},
});
};

return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-screen max-w-[12rem]">
<DropdownMenuLabel>Actions</DropdownMenuLabel>

<DropdownMenuSeparator />

<DropdownMenuItem
onClick={async () => {
await navigator.clipboard.writeText(id);
toast("User ID copied to clipboard");
}}
>
Copy org ID
</DropdownMenuItem>

<DropdownMenuSeparator />

<DropdownMenuItem
disabled={deleteUserIsPending}
onClick={deleteUser}
className="text-red-600"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
"use client";

import { type ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import { type membersToOrganizations } from "@/server/db/schema";
import { ColumnDropdown } from "./column-dropdown";
import { Badge } from "@/components/ui/badge";
import { OrgDetails } from "@/app/(app)/admin/organizations/_components/org-details";

// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type OrganizationsData = {
id: string;
name: string | null;
email: string;
image: string | null;
owner: {
id: string;
name: string | null;
email: string;
image: string | null;
};
subscribed: boolean;
members: {
id: string;
name: string | null;
email: string;
image: string | null;
role: typeof membersToOrganizations.$inferSelect.role;
}[];
createdAt: Date;
};

export function getColumns(): ColumnDef<OrganizationsData>[] {
return columns;
}

export const columns: ColumnDef<OrganizationsData>[] = [
{
accessorKey: "name",
header: () => <span className="pl-2">Name</span>,
cell: ({ row }) => <OrgDetails {...row.original} />,
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "owner.email",
header: "Owner Email",
},
{
accessorKey: "subscribed",
header: "Subscribed",
cell: ({ row }) => (
<Badge variant={row.original.subscribed ? "success" : "info"}>
{row.original.subscribed ? "Yes" : "No"}
</Badge>
),
},
{
accessorKey: "createdAt",
header: "Created At",
cell: ({ row }) => (
<span className="text-muted-foreground">
{format(new Date(row.original.createdAt), "PP")}
</span>
),
},
{
id: "actions",
cell: ({ row }) => <ColumnDropdown {...row.original} />,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { type OrganizationsData } from "@/app/(app)/admin/organizations/_components/columns";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";

type OrgDetailsProps = OrganizationsData;

export function OrgDetails(props: OrgDetailsProps) {
return (
<Dialog>
<DialogTrigger asChild>
<span className="cursor-pointer pl-2 font-medium hover:underline">
{props.name}
</span>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-auto">
<DialogHeader>
<DialogTitle>Organization Details</DialogTitle>
<DialogDescription>
View the details of the organization.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={props.image ?? ""} />

<AvatarFallback className="text-xs">
{props?.name?.charAt(0).toUpperCase() ??
props.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="w-full truncate text-sm font-medium">
{props.name}
</p>
<p className="w-full truncate text-sm font-light text-muted-foreground">
{props.email}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={props.subscribed ? "success" : "info"}
className="w-fit"
>
{props.subscribed ? "Subscribed" : "Unsubscribed"}
</Badge>
</div>
<ScrollArea className="max-h-[300px] w-full">
<h3 className="text-sm font-semibold">Owner</h3>

<div className="mt-2 flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={props.owner.image ?? ""} />

<AvatarFallback className="text-xs">
{props.owner.name
?.charAt(0)
.toUpperCase() ??
props.owner.email
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="w-full truncate text-sm font-medium">
{props.owner.name}
</p>
<p className="w-full truncate text-sm font-light text-muted-foreground">
{props.owner.email}
</p>
</div>
</div>

<h3 className="mt-2 text-sm font-semibold">Members</h3>

<ul className="mt-2 grid gap-2">
{props.members.map((member) => (
<li
key={member.id}
className="flex items-center gap-3"
>
<Avatar className="h-8 w-8">
<AvatarImage src={member.image ?? ""} />

<AvatarFallback className="text-xs">
{member?.name
?.charAt(0)
.toUpperCase() ??
member.email
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="w-full truncate text-sm font-medium">
{member.name} - {member.role}
</p>
<p className="w-full truncate text-sm font-light text-muted-foreground">
{member.email}
</p>
</div>
</li>
))}
</ul>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { DataTable } from "@/app/(app)/_components/data-table";
import { type ColumnDef } from "@tanstack/react-table";
import React, { useMemo } from "react";
import { getColumns, type OrganizationsData } from "./columns";
import { useDataTable } from "@/hooks/use-data-table";
import type { DataTableSearchableColumn } from "@/types/data-table";
import { type getPaginatedOrgsQuery } from "@/server/actions/organization/queries";

/** @learn more about data-table at shadcn ui website @see https://ui.shadcn.com/docs/components/data-table */

type OrgsTableProps = {
orgsPromise: ReturnType<typeof getPaginatedOrgsQuery>;
};

const searchableColumns: DataTableSearchableColumn<OrganizationsData>[] = [
{ id: "email", placeholder: "Search email..." },
];

export function OrgsTable({ orgsPromise }: OrgsTableProps) {
const { data, pageCount, total } = React.use(orgsPromise);

const columns = useMemo<ColumnDef<OrganizationsData, unknown>[]>(
() => getColumns(),
[],
);

const organizationsData: OrganizationsData[] = data.map((org) => {
const members = org.members.map((mto) => {
return {
id: mto.id,
name: mto.name,
email: mto.email,
image: mto.image,
role: mto.role,
};
});

return {
id: org.id,
name: org.name,
email: org.email,
createdAt: org.createdAt,
image: org.image,
members: members,
owner: {
id: org.ownerId,
name: org.owner.name,
email: org.owner.email,
image: org.owner.image,
},
subscribed: org.subscriptions?.id ? true : false,
};
});

const { table } = useDataTable({
data: organizationsData,
columns,
pageCount,
searchableColumns,
});

return (
<DataTable
table={table}
columns={columns}
searchableColumns={searchableColumns}
totalRows={total}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const organizationsPageConfig = {
title: "Organizations",
description:
"View all organizations in your app.",
} as const;
38 changes: 38 additions & 0 deletions starterkits/saas/src/app/(app)/admin/organizations/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AppPageShell } from "@/app/(app)/_components/page-shell";
import { z } from "zod";
import type { SearchParams } from "@/types/data-table";
import { organizationsPageConfig } from "@/app/(app)/admin/organizations/_constants/page-config";
import { getPaginatedOrgsQuery } from "@/server/actions/organization/queries";
import { OrgsTable } from "@/app/(app)/admin/organizations/_components/orgs-table";

type UsersPageProps = {
searchParams: SearchParams;
};

const searchParamsSchema = z.object({
page: z.coerce.number().default(1),
per_page: z.coerce.number().default(10),
sort: z.string().optional(),
email: z.string().optional(),
name: z.string().optional(),
operator: z.string().optional(),
});

export default async function AdminOrganizationsPage({
searchParams,
}: UsersPageProps) {
const search = searchParamsSchema.parse(searchParams);

const orgsPromise = getPaginatedOrgsQuery(search);

return (
<AppPageShell
title={organizationsPageConfig.title}
description={organizationsPageConfig.description}
>
<div className="w-full">
<OrgsTable orgsPromise={orgsPromise} />
</div>
</AppPageShell>
);
}
Loading

0 comments on commit 8c885f2

Please sign in to comment.