first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { adminOrganizationsQueryKey } from "@saas/admin/lib/api";
|
||||
import { getAdminPath } from "@saas/admin/lib/links";
|
||||
import { InviteMemberForm } from "@saas/organizations/components/InviteMemberForm";
|
||||
import { OrganizationMembersBlock } from "@saas/organizations/components/OrganizationMembersBlock";
|
||||
import {
|
||||
fullOrganizationQueryKey,
|
||||
useCreateOrganizationMutation,
|
||||
useFullOrganizationQuery,
|
||||
useUpdateOrganizationMutation,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const organizationFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type OrganizationFormValues = z.infer<typeof organizationFormSchema>;
|
||||
|
||||
export function OrganizationForm({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: organization } = useFullOrganizationQuery(organizationId);
|
||||
|
||||
const updateOrganizationMutation = useUpdateOrganizationMutation();
|
||||
const createOrganizationMutation = useCreateOrganizationMutation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<OrganizationFormValues>({
|
||||
resolver: zodResolver(organizationFormSchema),
|
||||
defaultValues: {
|
||||
name: organization?.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
try {
|
||||
const newOrganization = organization
|
||||
? await updateOrganizationMutation.mutateAsync({
|
||||
id: organization.id,
|
||||
name,
|
||||
updateSlug: organization.name !== name,
|
||||
})
|
||||
: await createOrganizationMutation.mutateAsync({
|
||||
name,
|
||||
});
|
||||
|
||||
if (!newOrganization) {
|
||||
throw new Error("Could not save organization");
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
fullOrganizationQueryKey(organizationId),
|
||||
newOrganization,
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: adminOrganizationsQueryKey,
|
||||
});
|
||||
|
||||
toast.success(t("admin.organizations.form.notifications.success"));
|
||||
|
||||
if (!organization) {
|
||||
router.replace(
|
||||
getAdminPath(`/organizations/${newOrganization.id}`),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("admin.organizations.form.notifications.error"));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{organization
|
||||
? t("admin.organizations.form.updateTitle")
|
||||
: t("admin.organizations.form.createTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="grid grid-cols-1 gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("admin.organizations.form.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={
|
||||
updateOrganizationMutation.isPending ||
|
||||
createOrganizationMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("admin.organizations.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{organization && (
|
||||
<>
|
||||
<OrganizationMembersBlock
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
<InviteMemberForm organizationId={organization.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import {
|
||||
adminOrganizationsQueryKey,
|
||||
useAdminOrganizationsQuery,
|
||||
} from "@saas/admin/lib/api";
|
||||
import { getAdminPath } from "@saas/admin/lib/links";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { Pagination } from "@saas/shared/components/Pagination";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Card } from "@ui/components/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import { EditIcon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { withQuery } from "ufo";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export function OrganizationList() {
|
||||
const t = useTranslations();
|
||||
const { confirm } = useConfirmationAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentPage, setCurrentPage] = useQueryState(
|
||||
"currentPage",
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useQueryState(
|
||||
"query",
|
||||
parseAsString.withDefault(""),
|
||||
);
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounceValue(
|
||||
searchTerm,
|
||||
300,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
|
||||
const getPathWithBackToParemeter = (path: string) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
return withQuery(path, {
|
||||
backTo: `${window.location.pathname}${searchParams.size ? `?${searchParams.toString()}` : ""}`,
|
||||
});
|
||||
};
|
||||
|
||||
const getOrganizationEditPath = (id: string) => {
|
||||
return getPathWithBackToParemeter(getAdminPath(`/organizations/${id}`));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, [searchTerm]);
|
||||
|
||||
const { data, isLoading } = useAdminOrganizationsQuery({
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
currentPage,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
const deleteOrganization = async (id: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
const { error } = await authClient.organization.delete({
|
||||
organizationId: id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loading: t("admin.organizations.deleteOrganization.deleting"),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: adminOrganizationsQueryKey,
|
||||
});
|
||||
return t("admin.organizations.deleteOrganization.deleted");
|
||||
},
|
||||
error: t("admin.organizations.deleteOrganization.notDeleted"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<typeof data>["organizations"][number]
|
||||
>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "",
|
||||
accessorFn: (row) => row.name,
|
||||
cell: ({
|
||||
row: {
|
||||
original: { id, name, logo, membersCount },
|
||||
},
|
||||
}) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<OrganizationLogo name={name} logoUrl={logo} />
|
||||
<div className="leading-tight">
|
||||
<Link
|
||||
href={getOrganizationEditPath(id)}
|
||||
className="block font-bold"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
<small>
|
||||
{t("admin.organizations.membersCount", {
|
||||
count: membersCount,
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "",
|
||||
cell: ({
|
||||
row: {
|
||||
original: { id },
|
||||
},
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={getOrganizationEditPath(id)}
|
||||
className="flex items-center"
|
||||
>
|
||||
<EditIcon className="mr-2 size-4" />
|
||||
{t("admin.organizations.edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
confirm({
|
||||
title: t(
|
||||
"admin.organizations.confirmDelete.title",
|
||||
),
|
||||
message: t(
|
||||
"admin.organizations.confirmDelete.message",
|
||||
),
|
||||
confirmLabel: t(
|
||||
"admin.organizations.confirmDelete.confirm",
|
||||
),
|
||||
destructive: true,
|
||||
onConfirm: () =>
|
||||
deleteOrganization(id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex items-center text-destructive hover:text-destructive">
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
{t("admin.organizations.delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const organizations = useMemo(
|
||||
() => data?.organizations ?? [],
|
||||
[data?.organizations],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: organizations,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
manualPagination: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-6">
|
||||
<h2 className="font-semibold text-2xl">
|
||||
{t("admin.organizations.title")}
|
||||
</h2>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={getAdminPath("/organizations/new")}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
{t("admin.organizations.create")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.organizations.search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() && "selected"
|
||||
}
|
||||
className="group"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-2 group-first:rounded-t-md group-last:rounded-b-md"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="mr-2 size-4 text-primary" />
|
||||
{t("admin.organizations.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<p>No results.</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!!data?.total && data.total > ITEMS_PER_PAGE && (
|
||||
<Pagination
|
||||
className="mt-4"
|
||||
totalItems={data.total}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={currentPage}
|
||||
onChangeCurrentPage={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue