-
Notifications
You must be signed in to change notification settings - Fork 61
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(saas): Admin Organizations management
- Loading branch information
1 parent
ced5523
commit 8c885f2
Showing
13 changed files
with
491 additions
and
21 deletions.
There are no files selected for viewing
75 changes: 75 additions & 0 deletions
75
starterkits/saas/src/app/(app)/admin/organizations/_components/column-dropdown.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
76 changes: 76 additions & 0 deletions
76
starterkits/saas/src/app/(app)/admin/organizations/_components/columns.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} />, | ||
}, | ||
]; |
120 changes: 120 additions & 0 deletions
120
starterkits/saas/src/app/(app)/admin/organizations/_components/org-details.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
72 changes: 72 additions & 0 deletions
72
starterkits/saas/src/app/(app)/admin/organizations/_components/orgs-table.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
); | ||
} |
5 changes: 5 additions & 0 deletions
5
starterkits/saas/src/app/(app)/admin/organizations/_constants/page-config.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
38
starterkits/saas/src/app/(app)/admin/organizations/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.