feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
230
apps/web/src/modules/organization/account-switcher.tsx
Normal file
230
apps/web/src/modules/organization/account-switcher.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { PricingPlanType } from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
} from "@turbostarter/ui-web/popover";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { useCustomer } from "~/modules/billing/hooks/use-customer";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { CreateOrganizationModal } from "~/modules/organization/create-organization";
|
||||
|
||||
import { useActiveOrganization } from "./hooks/use-active-organization";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export const AccountSwitcher = memo<AccountSwitcherProps>(({ user }) => {
|
||||
const { t } = useTranslation(["common", "auth", "organization"]);
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);
|
||||
|
||||
const { data: customer } = useCustomer();
|
||||
const { data: organizations } = authClient.useListOrganizations();
|
||||
const { activeOrganization } = useActiveOrganization();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
{activeOrganization ? (
|
||||
<>
|
||||
<AvatarImage
|
||||
src={activeOrganization.logo ?? undefined}
|
||||
alt={activeOrganization.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-sm uppercase">
|
||||
{activeOrganization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AvatarImage
|
||||
src={user.image ?? undefined}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">
|
||||
{activeOrganization
|
||||
? activeOrganization.name
|
||||
: t("account.personal")}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs capitalize">
|
||||
{(customer?.plan ?? PricingPlanType.FREE).toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Icons.ChevronsUpDown className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
className="w-(--radix-popover-trigger-width) min-w-60 rounded-lg p-0"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command
|
||||
defaultValue={activeOrganization?.slug ?? "personal-account"}
|
||||
>
|
||||
<CommandInput placeholder={t("search.label")} autoFocus />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="personal-account"
|
||||
className="p-2"
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<TurboLink href={pathsConfig.dashboard.user.index}>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage
|
||||
src={user.image ?? undefined}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="size-3.5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{t("account.personal")}
|
||||
|
||||
{!activeOrganization && (
|
||||
<Icons.Check className="ml-auto" />
|
||||
)}
|
||||
</TurboLink>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
{organizations && organizations.length > 0 && (
|
||||
<>
|
||||
<CommandGroup
|
||||
heading={`${t("organizations")} (${organizations.length})`}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<CommandItem
|
||||
value={organization.slug}
|
||||
key={organization.id}
|
||||
className="p-2"
|
||||
asChild
|
||||
onSelect={() => {
|
||||
router.replace(
|
||||
pathsConfig.dashboard.organization(
|
||||
organization.slug,
|
||||
).index,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TurboLink
|
||||
href={
|
||||
pathsConfig.dashboard.organization(
|
||||
organization.slug,
|
||||
).index
|
||||
}
|
||||
className="leading-tight"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage
|
||||
src={organization.logo ?? undefined}
|
||||
alt={organization.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-sm uppercase">
|
||||
{organization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{organization.name}
|
||||
|
||||
{activeOrganization?.slug ===
|
||||
organization.slug && (
|
||||
<Icons.Check className="ml-auto" />
|
||||
)}
|
||||
</TurboLink>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CommandGroup forceMount>
|
||||
<CommandItem
|
||||
className="p-2"
|
||||
onSelect={() => setCreateOrganizationOpen(true)}
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<Icons.Plus className="size-4" />
|
||||
</div>
|
||||
{t("create.cta")}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</Popover>
|
||||
|
||||
<CreateOrganizationModal
|
||||
open={createOrganizationOpen}
|
||||
onOpenChange={setCreateOrganizationOpen}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
});
|
||||
|
||||
AccountSwitcher.displayName = "AccountSwitcher";
|
||||
126
apps/web/src/modules/organization/create-organization.tsx
Normal file
126
apps/web/src/modules/organization/create-organization.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { createOrganizationSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
FormField,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalFooter,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalDescription,
|
||||
ModalTitle,
|
||||
ModalClose,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { organization } from "./lib/api";
|
||||
|
||||
import type { CreateOrganizationPayload } from "@turbostarter/auth";
|
||||
|
||||
export const CreateOrganizationModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(createOrganizationSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const getSlug = useMutation(organization.mutations.getSlug);
|
||||
const create = useMutation({
|
||||
...organization.mutations.create,
|
||||
onSuccess: (_, variables) => {
|
||||
router.replace(pathsConfig.dashboard.organization(variables.slug).index);
|
||||
},
|
||||
});
|
||||
|
||||
const createOrganization = async (data: CreateOrganizationPayload) => {
|
||||
const { slug } = await getSlug.mutateAsync({
|
||||
query: data,
|
||||
});
|
||||
|
||||
await create.mutateAsync({
|
||||
...data,
|
||||
slug,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("create.title")}</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("create.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(createOrganization)}
|
||||
className="md:space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="px-4 py-2 md:px-0 md:py-0">
|
||||
<FormLabel>{t("common:name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{t("create.info")}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("create.cta")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
286
apps/web/src/modules/organization/home/charts/area.tsx
Normal file
286
apps/web/src/modules/organization/home/charts/area.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart as RechartsAreaChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@turbostarter/ui-web/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
];
|
||||
|
||||
export const AreaChart = () => {
|
||||
const { t, i18n } = useTranslation(["common", "dashboard"]);
|
||||
const [timeRange, setTimeRange] = React.useState("90d");
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const referenceDate = new Date("2024-06-30");
|
||||
let daysToSubtract = 90;
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30;
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7;
|
||||
}
|
||||
const startDate = new Date(referenceDate);
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract);
|
||||
return date >= startDate;
|
||||
});
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: t("desktop"),
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
mobile: {
|
||||
label: t("mobile"),
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "90d",
|
||||
label: t("lastMonths", { count: 3 }),
|
||||
},
|
||||
{
|
||||
value: "30d",
|
||||
label: t("lastMonths", { count: 1 }),
|
||||
},
|
||||
{
|
||||
value: "7d",
|
||||
label: t("lastDays", { count: 7 }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
|
||||
<div className="grid flex-1 gap-0.5 text-center sm:text-left">
|
||||
<CardTitle className="text-xl">{t("chart.area")}</CardTitle>
|
||||
<CardDescription>{t("chart.showing")}</CardDescription>
|
||||
</div>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="w-[160px] rounded-lg sm:ml-auto"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
options.find((option) => option.value === timeRange)?.label
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="rounded-lg"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<RechartsAreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value: string) => {
|
||||
return new Date(value).toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</RechartsAreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
124
apps/web/src/modules/organization/home/charts/bar.tsx
Normal file
124
apps/web/src/modules/organization/home/charts/bar.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
CartesianGrid,
|
||||
Rectangle,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@turbostarter/ui-web/chart";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
|
||||
const chartData = [
|
||||
{ browser: "chrome", visitors: 187, fill: "var(--chart-1)" },
|
||||
{ browser: "safari", visitors: 200, fill: "var(--chart-2)" },
|
||||
{ browser: "firefox", visitors: 275, fill: "var(--chart-3)" },
|
||||
{ browser: "edge", visitors: 173, fill: "var(--chart-4)" },
|
||||
{ browser: "other", visitors: 90, fill: "var(--chart-5)" },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
chrome: {
|
||||
label: "Chrome",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
safari: {
|
||||
label: "Safari",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
firefox: {
|
||||
label: "Firefox",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
edge: {
|
||||
label: "Edge",
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
other: {
|
||||
label: "Opera",
|
||||
color: "var(--chart-5)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function BarChart() {
|
||||
const { t } = useTranslation(["common", "dashboard"]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-0.5">
|
||||
<CardTitle className="text-xl">{t("chart.bar")}</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={{
|
||||
visitors: {
|
||||
label: t("visitors"),
|
||||
},
|
||||
...chartConfig,
|
||||
}}
|
||||
>
|
||||
<RechartsBarChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="browser"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
chartConfig[value as keyof typeof chartConfig].label
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="visitors"
|
||||
strokeWidth={2}
|
||||
radius={8}
|
||||
activeIndex={2}
|
||||
activeBar={({ ...props }) => {
|
||||
return (
|
||||
<Rectangle
|
||||
{...props}
|
||||
fillOpacity={0.8}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
stroke={props.payload.fill}
|
||||
strokeDasharray={4}
|
||||
strokeDashoffset={4}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
{t("chart.trending")} <Icons.TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground leading-none">
|
||||
{t("chart.showing")}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
224
apps/web/src/modules/organization/home/charts/line.tsx
Normal file
224
apps/web/src/modules/organization/home/charts/line.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@turbostarter/ui-web/chart";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
];
|
||||
|
||||
export const LineChart = () => {
|
||||
const { t, i18n } = useTranslation(["common", "dashboard"]);
|
||||
const [activeChart, setActiveChart] = React.useState<"desktop" | "mobile">(
|
||||
"desktop",
|
||||
);
|
||||
|
||||
const chartConfig = {
|
||||
views: {
|
||||
label: t("views"),
|
||||
},
|
||||
desktop: {
|
||||
label: "desktop",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
mobile: {
|
||||
label: "mobile",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const total = {
|
||||
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
|
||||
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-0.5 px-6 py-5 sm:py-6">
|
||||
<CardTitle className="text-xl">{t("chart.line")}</CardTitle>
|
||||
<CardDescription>{t("chart.showing")}</CardDescription>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{(["desktop", "mobile"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
data-active={activeChart === key}
|
||||
className="data-[active=true]:bg-muted/50 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
|
||||
onClick={() => setActiveChart(key)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">{t(key)}</span>
|
||||
<span className="text-lg leading-none font-bold sm:text-3xl">
|
||||
{total[key].toLocaleString(i18n.language)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<RechartsLineChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
labelFormatter={(value: string) => {
|
||||
return new Date(value).toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey={activeChart}
|
||||
type="monotone"
|
||||
stroke={chartConfig[activeChart].color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
188
apps/web/src/modules/organization/home/charts/pie.tsx
Normal file
188
apps/web/src/modules/organization/home/charts/pie.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import * as React from "react";
|
||||
import { Label, Pie, PieChart as RechartsPieChart, Sector } from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartStyle,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@turbostarter/ui-web/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
import type { PieSectorDataItem } from "recharts/types/polar/Pie";
|
||||
|
||||
const desktopData = [
|
||||
{ month: "january", desktop: 186, fill: "var(--chart-1)" },
|
||||
{ month: "february", desktop: 305, fill: "var(--chart-2)" },
|
||||
{ month: "march", desktop: 237, fill: "var(--chart-3)" },
|
||||
{ month: "april", desktop: 173, fill: "var(--chart-4)" },
|
||||
{ month: "may", desktop: 209, fill: "var(--chart-5)" },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
january: {
|
||||
label: dayjs().month(0).format("MMMM"),
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
february: {
|
||||
label: dayjs().month(1).format("MMMM"),
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
march: {
|
||||
label: dayjs().month(2).format("MMMM"),
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
april: {
|
||||
label: dayjs().month(3).format("MMMM"),
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
may: {
|
||||
label: dayjs().month(4).format("MMMM"),
|
||||
color: "var(--chart-5)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function PieChart() {
|
||||
const { t, i18n } = useTranslation(["common", "dashboard"]);
|
||||
const id = "pie-interactive";
|
||||
const [activeMonth, setActiveMonth] = React.useState(
|
||||
desktopData[0]?.month ?? "january",
|
||||
);
|
||||
|
||||
const activeIndex = React.useMemo(
|
||||
() => desktopData.findIndex((item) => item.month === activeMonth),
|
||||
[activeMonth],
|
||||
);
|
||||
const months = desktopData.map((item) => item.month);
|
||||
|
||||
return (
|
||||
<Card data-chart={id} className="flex flex-col">
|
||||
<ChartStyle id={id} config={chartConfig} />
|
||||
<CardHeader className="flex-row items-start space-y-0 pb-0">
|
||||
<div className="grid gap-0.5">
|
||||
<CardTitle className="text-xl">{t("chart.pie")}</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</div>
|
||||
<Select value={activeMonth} onValueChange={setActiveMonth}>
|
||||
<SelectTrigger
|
||||
className="ml-auto w-[130px] rounded-lg pl-3"
|
||||
aria-label={t("selectMonth")}
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder={t("selectMonth")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="rounded-xl">
|
||||
{months.map((key) => {
|
||||
const config = chartConfig[key as keyof typeof chartConfig];
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="rounded-lg [&_span]:flex"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="flex h-3 w-3 shrink-0 rounded-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color" in config ? config.color : undefined,
|
||||
}}
|
||||
/>
|
||||
{config.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 justify-center pb-0">
|
||||
<ChartContainer
|
||||
id={id}
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square w-full max-w-[300px]"
|
||||
>
|
||||
<RechartsPieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
/>
|
||||
<Pie
|
||||
data={desktopData}
|
||||
dataKey="desktop"
|
||||
nameKey="month"
|
||||
innerRadius={60}
|
||||
strokeWidth={5}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={({
|
||||
outerRadius = 0,
|
||||
...props
|
||||
}: PieSectorDataItem) => (
|
||||
<g>
|
||||
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||
<Sector
|
||||
{...props}
|
||||
outerRadius={outerRadius + 25}
|
||||
innerRadius={outerRadius + 12}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
const data = desktopData[activeIndex];
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-3xl font-bold"
|
||||
>
|
||||
{data.desktop.toLocaleString(i18n.language)}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy ?? 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{t("visitors")}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</RechartsPieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/modules/organization/home/charts/radar.tsx
Normal file
141
apps/web/src/modules/organization/home/charts/radar.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart as RechartsRadarChart,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@turbostarter/ui-web/chart";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
|
||||
const chartData = [
|
||||
{ month: dayjs().month(0).format("MMMM"), desktop: 186, mobile: 80 },
|
||||
{ month: dayjs().month(1).format("MMMM"), desktop: 305, mobile: 200 },
|
||||
{ month: dayjs().month(2).format("MMMM"), desktop: 237, mobile: 120 },
|
||||
{ month: dayjs().month(3).format("MMMM"), desktop: 73, mobile: 190 },
|
||||
{ month: dayjs().month(4).format("MMMM"), desktop: 209, mobile: 130 },
|
||||
{ month: dayjs().month(5).format("MMMM"), desktop: 214, mobile: 140 },
|
||||
];
|
||||
|
||||
export function RadarChart() {
|
||||
const { t } = useTranslation(["common", "dashboard"]);
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: t("desktop"),
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
mobile: {
|
||||
label: t("mobile"),
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="items-center space-y-0.5 pb-4">
|
||||
<CardTitle className="text-xl">{t("chart.radar")}</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<RechartsRadarChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
}}
|
||||
>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
/>
|
||||
<PolarAngleAxis
|
||||
dataKey="month"
|
||||
tick={({
|
||||
x,
|
||||
y,
|
||||
textAnchor,
|
||||
index,
|
||||
...props
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
textAnchor: "start" | "end" | "middle" | "inherit" | undefined;
|
||||
index: number;
|
||||
props: unknown;
|
||||
}) => {
|
||||
const data = chartData[index]!;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={index === 0 ? y - 10 : y}
|
||||
textAnchor={textAnchor}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
{...props}
|
||||
>
|
||||
<tspan className="fill-muted-foreground">
|
||||
{data.desktop}
|
||||
</tspan>
|
||||
<tspan className="fill-muted-foreground">/</tspan>
|
||||
<tspan className="fill-muted-foreground">
|
||||
{data.mobile}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={x}
|
||||
dy={"1rem"}
|
||||
fontSize={12}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{data.month}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<PolarGrid />
|
||||
<Radar
|
||||
dataKey="desktop"
|
||||
fill={chartConfig.desktop.color}
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
<Radar dataKey="mobile" fill={chartConfig.mobile.color} />
|
||||
</RechartsRadarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 leading-none font-medium">
|
||||
{t("chart.trending")} <TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2 leading-none">
|
||||
{t("chart.period")}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/modules/organization/home/charts/radial.tsx
Normal file
87
apps/web/src/modules/organization/home/charts/radial.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { PolarGrid, RadialBar, RadialBarChart } from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@turbostarter/ui-web/chart";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
|
||||
const chartData = [
|
||||
{ browser: "chrome", visitors: 275, fill: "var(--chart-1)" },
|
||||
{ browser: "safari", visitors: 200, fill: "var(--chart-2)" },
|
||||
{ browser: "firefox", visitors: 187, fill: "var(--chart-3)" },
|
||||
{ browser: "edge", visitors: 173, fill: "var(--chart-4)" },
|
||||
{ browser: "opera", visitors: 90, fill: "var(--chart-5)" },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
chrome: {
|
||||
label: "Chrome",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
safari: {
|
||||
label: "Safari",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
firefox: {
|
||||
label: "Firefox",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
edge: {
|
||||
label: "Edge",
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
opera: {
|
||||
label: "Opera",
|
||||
color: "var(--chart-5)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function RadialChart() {
|
||||
const { t } = useTranslation(["common", "dashboard"]);
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="items-center space-y-0.5 pb-0">
|
||||
<CardTitle className="text-xl">{t("chart.radial")}</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<RadialBarChart data={chartData} innerRadius={30} outerRadius={100}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel nameKey="browser" />}
|
||||
/>
|
||||
<PolarGrid gridType="circle" />
|
||||
<RadialBar dataKey="visitors" />
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 leading-none font-medium">
|
||||
{t("chart.trending")} <Icons.TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground leading-none">
|
||||
{t("chart.showing")}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
107
apps/web/src/modules/organization/home/charts/shape.tsx
Normal file
107
apps/web/src/modules/organization/home/charts/shape.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import {
|
||||
Label,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import { ChartContainer } from "@turbostarter/ui-web/chart";
|
||||
|
||||
import type { ChartConfig } from "@turbostarter/ui-web/chart";
|
||||
|
||||
const chartData = [
|
||||
{ browser: "safari", visitors: 1260, fill: "var(--chart-2)" },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
safari: {
|
||||
label: "Safari",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function ShapeChart() {
|
||||
const { t, i18n } = useTranslation(["common", "dashboard"]);
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="items-center space-y-0.5 pb-0">
|
||||
<CardTitle className="text-xl">{t("chart.shape")}</CardTitle>
|
||||
<CardDescription>{t("chart.period")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={chartData}
|
||||
endAngle={100}
|
||||
innerRadius={80}
|
||||
outerRadius={140}
|
||||
>
|
||||
<PolarGrid
|
||||
gridType="circle"
|
||||
radialLines={false}
|
||||
stroke="none"
|
||||
className="first:fill-muted last:fill-background"
|
||||
polarRadius={[86, 74]}
|
||||
/>
|
||||
<RadialBar dataKey="visitors" background />
|
||||
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
const data = chartData[0];
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-4xl font-bold"
|
||||
>
|
||||
{data.visitors.toLocaleString(i18n.language)}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy ?? 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{t("visitors")}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 leading-none font-medium">
|
||||
{t("chart.trending")} <TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { organization } from "../lib/api";
|
||||
|
||||
import type { MemberRole } from "@turbostarter/auth";
|
||||
|
||||
const DEBOUNCE_SET_ACTIVE_MS = 1000;
|
||||
|
||||
export const useActiveOrganization = () => {
|
||||
const session = authClient.useSession();
|
||||
const member = authClient.useActiveMember();
|
||||
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const slug = params.organization?.toString();
|
||||
|
||||
const allowRefetch = useMemo(
|
||||
() => !!(slug ?? pathname.startsWith(pathsConfig.dashboard.user.index)),
|
||||
[pathname, slug],
|
||||
);
|
||||
|
||||
const { data: activeOrganization, isLoading } = useQuery({
|
||||
...organization.queries.get({ slug: slug ?? "" }),
|
||||
enabled: !!slug,
|
||||
});
|
||||
|
||||
const setActiveOrganization = useMutation({
|
||||
...organization.mutations.setActive,
|
||||
onSuccess: async () => {
|
||||
await session.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const debouncedSetActiveOrganization = useDebounceCallback(
|
||||
setActiveOrganization.mutate,
|
||||
DEBOUNCE_SET_ACTIVE_MS,
|
||||
);
|
||||
|
||||
const activeMember = useMemo(() => {
|
||||
const data =
|
||||
member.data ??
|
||||
activeOrganization?.members.find(
|
||||
(member) => member.userId === session.data?.user.id,
|
||||
);
|
||||
return data ? { ...data, role: data.role as MemberRole } : null;
|
||||
}, [member.data, activeOrganization, session.data]);
|
||||
|
||||
const activeOrganizationId = activeOrganization?.id ?? null;
|
||||
const memberOrganizationId = member.data?.organizationId ?? null;
|
||||
const sessionActiveOrganizationId =
|
||||
session.data?.session.activeOrganizationId ?? null;
|
||||
|
||||
const shouldRefetchMember = useMemo(() => {
|
||||
return (
|
||||
!member.isPending &&
|
||||
!member.isRefetching &&
|
||||
memberOrganizationId !== activeOrganizationId &&
|
||||
allowRefetch
|
||||
);
|
||||
}, [
|
||||
member.isPending,
|
||||
member.isRefetching,
|
||||
memberOrganizationId,
|
||||
activeOrganizationId,
|
||||
allowRefetch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRefetchMember) {
|
||||
void member.refetch();
|
||||
}
|
||||
}, [member, shouldRefetchMember]);
|
||||
|
||||
const shouldUpdateActiveOrganization = useMemo(() => {
|
||||
return (
|
||||
!session.isPending &&
|
||||
!!session.data &&
|
||||
!(isLoading && !activeOrganizationId) &&
|
||||
sessionActiveOrganizationId !== activeOrganizationId &&
|
||||
allowRefetch
|
||||
);
|
||||
}, [
|
||||
session.isPending,
|
||||
session.data,
|
||||
isLoading,
|
||||
activeOrganizationId,
|
||||
sessionActiveOrganizationId,
|
||||
allowRefetch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUpdateActiveOrganization) {
|
||||
debouncedSetActiveOrganization({
|
||||
organizationId: activeOrganizationId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
shouldUpdateActiveOrganization,
|
||||
activeOrganizationId,
|
||||
debouncedSetActiveOrganization,
|
||||
]);
|
||||
|
||||
return {
|
||||
activeOrganization,
|
||||
activeMember,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAllRolesAtOrBelow,
|
||||
InvitationStatus,
|
||||
MemberRole,
|
||||
} from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Avatar, AvatarFallback } from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useActiveOrganization } from "../../hooks/use-active-organization";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
const Actions = ({ invitation }: { invitation: Invitation }) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const resendInvitation = useMutation({
|
||||
...organization.mutations.invitations.resend,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.invitations.getAll({
|
||||
id: invitation.organizationId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("invitations.resend.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const cancelInvitation = useMutation({
|
||||
...organization.mutations.invitations.cancel,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.invitations.getAll({
|
||||
id: invitation.organizationId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("invitations.cancel.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const hasInvitePermission =
|
||||
authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
invitation: ["create"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
}) &&
|
||||
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
|
||||
invitation.role,
|
||||
);
|
||||
|
||||
const hasCancelPermission =
|
||||
authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
invitation: ["cancel"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
}) &&
|
||||
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
|
||||
invitation.role,
|
||||
);
|
||||
|
||||
const groups = [
|
||||
hasInvitePermission
|
||||
? [
|
||||
(() => {
|
||||
const isPending =
|
||||
resendInvitation.isPending &&
|
||||
resendInvitation.variables.email === invitation.email &&
|
||||
resendInvitation.variables.organizationId ===
|
||||
invitation.organizationId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => resendInvitation.mutate(invitation)}
|
||||
disabled={isPending}
|
||||
key={`resend-${invitation.id}`}
|
||||
>
|
||||
{t("resend")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})(),
|
||||
]
|
||||
: null,
|
||||
hasCancelPermission
|
||||
? [
|
||||
(() => {
|
||||
const isPending =
|
||||
cancelInvitation.isPending &&
|
||||
cancelInvitation.variables.invitationId === invitation.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
cancelInvitation.mutate({
|
||||
invitationId: invitation.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`cancel-${invitation.id}`}
|
||||
>
|
||||
{t("cancel")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})(),
|
||||
]
|
||||
: null,
|
||||
].filter((group) => group?.filter(Boolean).length);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={groups.length <= 0}>
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{groups.flatMap((group, idx, array) =>
|
||||
idx < array.length - 1
|
||||
? [group, <DropdownMenuSeparator key={`sep-${idx}`} />]
|
||||
: [group],
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<Invitation>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "email",
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("email")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{row.original.email}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="secondary">{t(row.original.status)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(InvitationStatus).map((status) => ({
|
||||
label: t(status),
|
||||
value: status,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "expiresAt",
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("expiresAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("ml-auto text-right", {
|
||||
"text-destructive": dayjs(row.original.expiresAt).isBefore(
|
||||
dayjs(),
|
||||
),
|
||||
})}
|
||||
>
|
||||
{new Date(row.original.expiresAt).toLocaleString(i18n.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("expiresAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<Actions invitation={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { InvitationStatus } from "@turbostarter/auth";
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface InvitationsDataTableProps {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export const InvitationsDataTable = ({
|
||||
organizationId,
|
||||
}: InvitationsDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "expiresAt",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
columnFilters: [
|
||||
{
|
||||
id: "status",
|
||||
value: [InvitationStatus.PENDING],
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
organization.queries.invitations.getAll({
|
||||
id: organizationId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={5}
|
||||
filterCount={4}
|
||||
cellWidths={["20rem", "5rem", "5rem", "10rem", "2.5rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface InvitationEmailMismatchProps {
|
||||
readonly invitationId: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
export const InvitationEmailMismatch = async ({
|
||||
invitationId,
|
||||
email,
|
||||
}: InvitationEmailMismatchProps) => {
|
||||
const { t } = await getTranslation({ ns: ["organization"] });
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("invitationId", invitationId);
|
||||
searchParams.set("email", email);
|
||||
searchParams.set(
|
||||
"redirectTo",
|
||||
`${pathsConfig.auth.join}?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("invitations.emailMismatch.title")}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="invitations.emailMismatch.description"
|
||||
ns="organization"
|
||||
values={{ email }}
|
||||
components={{ bold: <strong /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<TurboLink
|
||||
href={`${pathsConfig.auth.login}?${searchParams.toString()}`}
|
||||
className={buttonVariants({ size: "lg" })}
|
||||
>
|
||||
{t("invitations.emailMismatch.cta", { email })}
|
||||
</TurboLink>
|
||||
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className={buttonVariants({ variant: "outline", size: "lg" })}
|
||||
>
|
||||
{t("invitations.emailMismatch.skip")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
export const InvitationExpired = async () => {
|
||||
const { t } = await getTranslation({ ns: ["organization"] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("invitations.expired.title")}
|
||||
description={t("invitations.expired.description")}
|
||||
/>
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "lg",
|
||||
})}
|
||||
>
|
||||
{t("invitations.expired.cta")}
|
||||
</TurboLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Card } from "@turbostarter/ui-web/card";
|
||||
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
interface InvitationSummaryCardProps {
|
||||
readonly invitation: Invitation;
|
||||
readonly organization: {
|
||||
slug: string | null;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const InvitationSummaryCard = ({
|
||||
invitation,
|
||||
organization,
|
||||
}: InvitationSummaryCardProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Card className="flex w-full items-center gap-4 p-4">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={organization.logo ?? undefined}
|
||||
alt={organization.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-xl uppercase">
|
||||
{organization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex w-full min-w-0 flex-col text-sm">
|
||||
<span className="truncate font-medium">{organization.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("expires")} {dayjs(invitation.expiresAt).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{t(invitation.role)}
|
||||
</Badge>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
119
apps/web/src/modules/organization/invitations/invitation.tsx
Normal file
119
apps/web/src/modules/organization/invitations/invitation.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
import { InvitationSummaryCard } from "./invitation-summary-card";
|
||||
|
||||
import type { Invitation as InvitationType } from "@turbostarter/auth";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface InvitationProps {
|
||||
readonly invitation: InvitationType & {
|
||||
inviterEmail: string;
|
||||
};
|
||||
readonly organization: {
|
||||
slug: string | null;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const Invitation = (props: InvitationProps) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const router = useRouter();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const acceptInvitation = useMutation({
|
||||
...organization.mutations.invitations.accept,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
router.replace(
|
||||
pathsConfig.dashboard.organization(props.organization.slug ?? "").index,
|
||||
);
|
||||
},
|
||||
});
|
||||
const rejectInvitation = useMutation({
|
||||
...organization.mutations.invitations.reject,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("invitations.invitation.title", {
|
||||
organizationName: props.organization.name,
|
||||
})}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="invitations.invitation.description"
|
||||
ns="organization"
|
||||
values={{
|
||||
inviterEmail: props.invitation.inviterEmail,
|
||||
organizationName: props.organization.name,
|
||||
}}
|
||||
components={{ bold: <strong /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InvitationSummaryCard
|
||||
invitation={props.invitation}
|
||||
organization={props.organization}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
onClick={() =>
|
||||
rejectInvitation.mutate({ invitationId: props.invitation.id })
|
||||
}
|
||||
>
|
||||
{rejectInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.X />
|
||||
)}
|
||||
{t("reject")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
acceptInvitation.mutate({ invitationId: props.invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{acceptInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.Check />
|
||||
)}
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className="text-muted-foreground hover:text-primary self-center text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("invitations.invitation.skip")}
|
||||
</TurboLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { InvitationStatus } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@turbostarter/ui-web/alert";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
import { InvitationSummaryCard } from "../invitation-summary-card";
|
||||
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const UserOrganizationInvitationsBanner = () => {
|
||||
const { t } = useTranslation(["organization", "common"]);
|
||||
|
||||
const { data } = useQuery(user.queries.invitations.getAll);
|
||||
const pendingInvitations = data?.filter(
|
||||
(invitation) =>
|
||||
invitation.status === InvitationStatus.PENDING &&
|
||||
dayjs(invitation.expiresAt).isAfter(dayjs()),
|
||||
);
|
||||
|
||||
if (!pendingInvitations?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserOrganizationInvitationsListModal invitations={pendingInvitations}>
|
||||
<button className="ring-offset-background focus-visible:ring-ring w-full cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none">
|
||||
<Alert
|
||||
variant="primary"
|
||||
className="hover:bg-primary/10 flex flex-wrap items-center justify-between gap-4 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-y-0.5">
|
||||
<AlertTitle>
|
||||
{t("invitations.user.banner.title", {
|
||||
count: pendingInvitations.length,
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("invitations.user.banner.description")}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
</button>
|
||||
</UserOrganizationInvitationsListModal>
|
||||
);
|
||||
};
|
||||
|
||||
const UserOrganizationInvitationsListModalItem = ({
|
||||
invitation,
|
||||
onSuccess,
|
||||
}: {
|
||||
invitation: Invitation;
|
||||
onSuccess?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch } = authClient.useListOrganizations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...organization.queries.get({ id: invitation.organizationId }),
|
||||
queryFn: () =>
|
||||
handle(api.organizations[":id"].$get)({
|
||||
param: { id: invitation.organizationId },
|
||||
}),
|
||||
});
|
||||
|
||||
const acceptInvitation = useMutation({
|
||||
...organization.mutations.invitations.accept,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
toast.success(
|
||||
t("invitations.accept.success", "", {
|
||||
organization: data?.organization?.name ?? "",
|
||||
}),
|
||||
);
|
||||
await refetch();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
const rejectInvitation = useMutation({
|
||||
...organization.mutations.invitations.reject,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
toast.success(
|
||||
t("invitations.reject.success", "", {
|
||||
organization: data?.organization?.name ?? "",
|
||||
}),
|
||||
);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-19 w-full" />;
|
||||
}
|
||||
|
||||
if (!data?.organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-stretch gap-2">
|
||||
<InvitationSummaryCard
|
||||
invitation={invitation}
|
||||
organization={data.organization}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
rejectInvitation.mutate({ invitationId: invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{rejectInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.X />
|
||||
)}
|
||||
{t("reject")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
acceptInvitation.mutate({ invitationId: invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{acceptInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.Check />
|
||||
)}
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserOrganizationInvitationsListModal = ({
|
||||
children,
|
||||
invitations,
|
||||
}: {
|
||||
invitations: Invitation[];
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation("organization");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("invitations.user.list.title")}</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("invitations.user.list.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<ul className="flex flex-col gap-4">
|
||||
{invitations.map((invitation) => (
|
||||
<UserOrganizationInvitationsListModalItem
|
||||
key={invitation.id}
|
||||
invitation={invitation}
|
||||
{...(invitations.length === 1
|
||||
? { onSuccess: () => setOpen(false) }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
183
apps/web/src/modules/organization/lib/api.ts
Normal file
183
apps/web/src/modules/organization/lib/api.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
getInvitationsResponseSchema,
|
||||
getMembersResponseSchema,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import type { InferRequestType } from "hono/client";
|
||||
|
||||
const KEY = "organizations";
|
||||
|
||||
const queries = {
|
||||
get: (params: { slug: string } | { id: string }) => ({
|
||||
queryKey: [KEY, params],
|
||||
queryFn: () =>
|
||||
authClient.organization.getFullOrganization({
|
||||
query:
|
||||
"id" in params
|
||||
? { organizationId: params.id }
|
||||
: { organizationSlug: params.slug },
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
members: {
|
||||
getIsOnlyOwner: ({ id }: { id: string }) => ({
|
||||
queryKey: [...queries.get({ id }).queryKey, "members", "is-only-owner"],
|
||||
queryFn: () =>
|
||||
handle(api.organizations[":id"].members["is-only-owner"].$get)({
|
||||
param: { id },
|
||||
}),
|
||||
}),
|
||||
getAll: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.organizations)[":id"]["members"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [...queries.get({ id }).queryKey, "members", query],
|
||||
queryFn: () =>
|
||||
handle(api.organizations[":id"].members.$get, {
|
||||
schema: getMembersResponseSchema,
|
||||
})({
|
||||
query,
|
||||
param: {
|
||||
id,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
invitations: {
|
||||
getAll: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.organizations)[":id"]["invitations"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [...queries.get({ id }).queryKey, "invitations", query],
|
||||
queryFn: () =>
|
||||
handle(api.organizations[":id"].invitations.$get, {
|
||||
schema: getInvitationsResponseSchema,
|
||||
})({
|
||||
query,
|
||||
param: {
|
||||
id,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
getSlug: {
|
||||
mutationKey: [KEY, "slug"],
|
||||
mutationFn: (data: InferRequestType<typeof api.organizations.slug.$get>) =>
|
||||
handle(api.organizations.slug.$get)(data),
|
||||
},
|
||||
setActive: {
|
||||
mutationKey: [KEY, "active"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.setActive>[0],
|
||||
) => authClient.organization.setActive(params),
|
||||
},
|
||||
create: {
|
||||
mutationKey: [KEY, "create"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.create>[0],
|
||||
) =>
|
||||
authClient.organization.create({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
update: {
|
||||
mutationKey: [KEY, "update"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.update>[0],
|
||||
) => authClient.organization.update(params),
|
||||
},
|
||||
delete: {
|
||||
mutationKey: [KEY, "delete"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.delete>[0],
|
||||
) => authClient.organization.delete(params),
|
||||
},
|
||||
leave: {
|
||||
mutationKey: [KEY, "members", "leave"],
|
||||
mutationFn: (params: Parameters<typeof authClient.organization.leave>[0]) =>
|
||||
authClient.organization.leave(params),
|
||||
},
|
||||
members: {
|
||||
remove: {
|
||||
mutationKey: [KEY, "members", "remove"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.removeMember>[0],
|
||||
) => authClient.organization.removeMember(params),
|
||||
},
|
||||
invite: {
|
||||
mutationKey: [KEY, "members", "invite"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.inviteMember>[0],
|
||||
) =>
|
||||
authClient.organization.inviteMember(params, {
|
||||
headers: {
|
||||
"x-url": new URL(pathsConfig.auth.join, appConfig.url).toString(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
updateRole: {
|
||||
mutationKey: [KEY, "members", "update-role"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.updateMemberRole>[0],
|
||||
) => authClient.organization.updateMemberRole(params),
|
||||
},
|
||||
},
|
||||
invitations: {
|
||||
accept: {
|
||||
mutationKey: [KEY, "invitations", "accept"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.acceptInvitation>[0],
|
||||
) => authClient.organization.acceptInvitation(params),
|
||||
},
|
||||
reject: {
|
||||
mutationKey: [KEY, "invitations", "reject"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.rejectInvitation>[0],
|
||||
) => authClient.organization.rejectInvitation(params),
|
||||
},
|
||||
cancel: {
|
||||
mutationKey: [KEY, "invitations", "cancel"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.cancelInvitation>[0],
|
||||
) => authClient.organization.cancelInvitation(params),
|
||||
},
|
||||
resend: {
|
||||
mutationKey: [KEY, "invitations", "resend"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.organization.inviteMember>[0],
|
||||
) =>
|
||||
authClient.organization.inviteMember(
|
||||
{
|
||||
...params,
|
||||
resend: true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-url": new URL(pathsConfig.auth.join, appConfig.url).toString(),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const organization = {
|
||||
queries,
|
||||
mutations,
|
||||
} as const;
|
||||
293
apps/web/src/modules/organization/members/data-table/columns.tsx
Normal file
293
apps/web/src/modules/organization/members/data-table/columns.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAllRolesAtOrBelow, MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useActiveOrganization } from "../../hooks/use-active-organization";
|
||||
import { UpdateMemberRoleModal } from "../update-member-role";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { Member } from "@turbostarter/auth";
|
||||
|
||||
const Actions = ({ member }: { member: Member }) => {
|
||||
const { t } = useTranslation(["common", "auth", "organization"]);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const { refetch } = authClient.useListOrganizations();
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const me = member.userId === session?.user.id;
|
||||
|
||||
const { data: isOnlyOwner } = useQuery({
|
||||
...organization.queries.members.getIsOnlyOwner({
|
||||
id: member.organizationId,
|
||||
}),
|
||||
enabled: me,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const hasDeletePermission =
|
||||
authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
member: ["delete"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
}) &&
|
||||
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
|
||||
member.role,
|
||||
) &&
|
||||
!(me && isOnlyOwner?.status);
|
||||
|
||||
const hasUpdatePermission =
|
||||
authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
member: ["update"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
}) &&
|
||||
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
|
||||
member.role,
|
||||
) &&
|
||||
!(me && isOnlyOwner?.status);
|
||||
|
||||
const removeMember = useMutation({
|
||||
...organization.mutations.members.remove,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.members.getAll({
|
||||
id: member.organizationId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("members.remove.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const leaveOrganization = useMutation({
|
||||
...organization.mutations.leave,
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
toast.success(t("leave.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const groups = [
|
||||
hasUpdatePermission
|
||||
? [
|
||||
<UpdateMemberRoleModal
|
||||
member={member}
|
||||
key={`update-role-${member.id}`}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
{t("updateRole")}
|
||||
</DropdownMenuItem>
|
||||
</UpdateMemberRoleModal>,
|
||||
]
|
||||
: null,
|
||||
[
|
||||
hasDeletePermission
|
||||
? me
|
||||
? (() => {
|
||||
const isPending =
|
||||
leaveOrganization.isPending &&
|
||||
leaveOrganization.variables.organizationId ===
|
||||
member.organizationId;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
leaveOrganization.mutate({
|
||||
organizationId: member.organizationId,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`leave-${member.id}`}
|
||||
>
|
||||
{t("common:leave")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
const isPending =
|
||||
removeMember.isPending &&
|
||||
removeMember.variables.memberIdOrEmail === member.id &&
|
||||
removeMember.variables.organizationId === member.organizationId;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
removeMember.mutate({
|
||||
memberIdOrEmail: member.id,
|
||||
organizationId: member.organizationId,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`remove-${member.id}`}
|
||||
>
|
||||
{t("common:remove")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})()
|
||||
: null,
|
||||
],
|
||||
].filter((group) => group?.filter(Boolean).length);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={groups.length <= 0}>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{groups.flatMap((group, idx, array) =>
|
||||
idx < array.length - 1
|
||||
? [group, <DropdownMenuSeparator key={`sep-${idx}`} />]
|
||||
: [group],
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<Member>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "q",
|
||||
accessorKey: "q",
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "user.name",
|
||||
accessorKey: "user.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("name")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.user.image ?? undefined}
|
||||
alt={row.original.user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{row.original.user.name}
|
||||
</span>
|
||||
{row.original.userId === session?.user.id && (
|
||||
<Badge variant="outline">{t("you")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "user.email",
|
||||
accessorKey: "user.email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("email")} />
|
||||
),
|
||||
meta: {
|
||||
label: t("email"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("joinedAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{new Date(row.original.createdAt).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("joinedAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<Actions member={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface MembersDataTableProps {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export const MembersDataTable = ({ organizationId }: MembersDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "user.name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
columnVisibility: {
|
||||
q: false,
|
||||
},
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
organization.queries.members.getAll({
|
||||
id: organizationId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={5}
|
||||
filterCount={3}
|
||||
cellWidths={["20rem", "10rem", "7rem", "4rem", "2.5rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
241
apps/web/src/modules/organization/members/invite-member.tsx
Normal file
241
apps/web/src/modules/organization/members/invite-member.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
getAllRolesAtOrBelow,
|
||||
inviteMemberSchema,
|
||||
MemberRole,
|
||||
} from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardDescription,
|
||||
SettingsCardContent,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { useActiveOrganization } from "~/modules/organization/hooks/use-active-organization";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
interface InviteMemberProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const InviteMember = ({ organizationId }: InviteMemberProps) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const schema = z.object({
|
||||
invites: z.array(inviteMemberSchema).min(1),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(schema),
|
||||
defaultValues: {
|
||||
invites: [{ email: "", role: MemberRole.MEMBER }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const inviteMember = useMutation(organization.mutations.members.invite);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const results = await Promise.allSettled(
|
||||
data.invites.map((invite) =>
|
||||
inviteMember.mutateAsync({ ...invite, organizationId }),
|
||||
),
|
||||
);
|
||||
|
||||
const failedInvites = results
|
||||
.map((result, idx) =>
|
||||
result.status === "rejected" ? data.invites[idx] : null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const successCount = data.invites.length - failedInvites.length;
|
||||
if (successCount > 0) {
|
||||
toast.success(t("members.invite.success", { count: successCount }));
|
||||
}
|
||||
|
||||
if (failedInvites.length > 0) {
|
||||
form.reset({ invites: failedInvites });
|
||||
} else {
|
||||
form.reset(undefined, { keepDefaultValues: true });
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.invitations.getAll({ id: organizationId }),
|
||||
);
|
||||
};
|
||||
|
||||
const hasInvitePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
invitation: ["create"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard disabled={!hasInvitePermission}>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("members.invite.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("members.invite.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2">
|
||||
<SettingsCardContent className="flex w-full flex-col gap-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invites.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel className={cn({ "sr-only": index > 0 })}>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
disabled={
|
||||
!hasInvitePermission || form.formState.isSubmitting
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invites.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel className={cn({ "sr-only": index > 0 })}>
|
||||
{t("role")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={
|
||||
!hasInvitePermission ||
|
||||
form.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("member")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAllRolesAtOrBelow(
|
||||
activeMember?.role ?? MemberRole.MEMBER,
|
||||
).map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{fields.length > 1 && (
|
||||
<div
|
||||
className={cn({
|
||||
"translate-y-[1.35rem]": !index,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="gap-2"
|
||||
disabled={
|
||||
!hasInvitePermission || form.formState.isSubmitting
|
||||
}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Icons.Trash className="size-4" />
|
||||
<span className="sr-only"> {t("remove")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ email: "", role: MemberRole.MEMBER })}
|
||||
className="mt-2 w-fit gap-2"
|
||||
disabled={!hasInvitePermission || form.formState.isSubmitting}
|
||||
>
|
||||
<Icons.Plus className="size-4" /> {t("addMore")}
|
||||
</Button>
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{hasInvitePermission ? (
|
||||
<>
|
||||
{t("members.invite.info")}
|
||||
<Button disabled={form.formState.isSubmitting} size="sm">
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("invite")
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
t("members.invite.missingPermission")
|
||||
)}
|
||||
</SettingsCardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
163
apps/web/src/modules/organization/members/update-member-role.tsx
Normal file
163
apps/web/src/modules/organization/members/update-member-role.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAllRolesAtOrBelow,
|
||||
MemberRole,
|
||||
updateMemberSchema,
|
||||
} from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalTitle,
|
||||
ModalHeader,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalClose,
|
||||
ModalFooter,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useActiveOrganization } from "../hooks/use-active-organization";
|
||||
|
||||
import type { Member } from "@turbostarter/auth";
|
||||
|
||||
interface UpdateMemberRoleModalProps {
|
||||
readonly member: Member;
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const UpdateMemberRoleModal = ({
|
||||
member,
|
||||
children,
|
||||
}: UpdateMemberRoleModalProps) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updateMemberSchema.pick({ role: true })),
|
||||
defaultValues: {
|
||||
role: member.role,
|
||||
},
|
||||
});
|
||||
|
||||
const updateMemberRole = useMutation({
|
||||
...organization.mutations.members.updateRole,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.members.getAll({ id: member.organizationId }),
|
||||
);
|
||||
toast.success(t("members.update.role.success"));
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("members.update.role.title", {
|
||||
name: member.user.name,
|
||||
})}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("members.update.role.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
updateMemberRole.mutateAsync({
|
||||
memberId: member.id,
|
||||
role: data.role ?? MemberRole.MEMBER,
|
||||
}),
|
||||
)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="px-4 md:px-0">
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("role")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAllRolesAtOrBelow(
|
||||
activeMember?.role ?? MemberRole.MEMBER,
|
||||
).map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("members.update.role.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("update")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
81
apps/web/src/modules/organization/organization-picker.tsx
Normal file
81
apps/web/src/modules/organization/organization-picker.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { CreateOrganizationModal } from "./create-organization";
|
||||
|
||||
export const OrganizationPicker = () => {
|
||||
const { data: organizations, isPending } = authClient.useListOrganizations();
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
return (
|
||||
<nav className="@container/picker w-full">
|
||||
<ul
|
||||
className="grid grid-cols-1 gap-4 @lg/picker:grid-cols-2 @2xl/picker:grid-cols-3"
|
||||
aria-busy={isPending}
|
||||
aria-live="polite"
|
||||
>
|
||||
{isPending &&
|
||||
Array.from({ length: 2 }).map((_, index) => (
|
||||
<li key={`skeleton-${index}`} aria-hidden="true">
|
||||
<Skeleton className="h-36" />
|
||||
</li>
|
||||
))}
|
||||
|
||||
{organizations?.map((organization) => (
|
||||
<li key={organization.id}>
|
||||
<TurboLink
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"text-muted-foreground h-36 w-full items-center justify-between gap-3 px-6 py-4 has-[>svg]:px-6",
|
||||
)}
|
||||
href={pathsConfig.dashboard.organization(organization.slug).index}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-3">
|
||||
<Avatar className="size-16">
|
||||
<AvatarImage src={organization.logo ?? undefined} alt="" />
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-xl uppercase">
|
||||
{organization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-full truncate text-base">
|
||||
{organization.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Icons.ChevronRight className="mt-2 -mr-1 size-5 self-start" />
|
||||
</TurboLink>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<CreateOrganizationModal>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-muted-foreground h-36 w-full flex-col gap-2 border-dashed"
|
||||
aria-label={t("create.cta")}
|
||||
>
|
||||
<Icons.Plus className="size-8" strokeWidth={1.5} />
|
||||
<span className="text-base">{t("create.cta")}</span>
|
||||
</Button>
|
||||
</CreateOrganizationModal>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useActiveOrganization } from "../hooks/use-active-organization";
|
||||
|
||||
export const DeleteOrganization = ({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation("organization");
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const { data: activeOrganization } = useQuery(
|
||||
organization.queries.get({ id: organizationId }),
|
||||
);
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasDeletePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
organization: ["delete"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard variant="destructive" disabled={!hasDeletePermission}>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("delete.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("delete.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{hasDeletePermission ? (
|
||||
<ConfirmModal organizationId={activeOrganization.id}>
|
||||
<Button size="sm" className="ml-auto" variant="destructive">
|
||||
{t("delete.cta")}
|
||||
</Button>
|
||||
</ConfirmModal>
|
||||
) : (
|
||||
t("delete.missingPermission")
|
||||
)}
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
children,
|
||||
organizationId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const router = useRouter();
|
||||
|
||||
const deleteOrganization = useMutation({
|
||||
...organization.mutations.delete,
|
||||
onSuccess: () => {
|
||||
toast.success(t("delete.success"));
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteOrganization.mutate({ organizationId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("delete.title")}</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("delete.disclaimer")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
disabled={deleteOrganization.isPending}
|
||||
>
|
||||
{deleteOrganization.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("common:delete")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
128
apps/web/src/modules/organization/settings/edit-logo.tsx
Normal file
128
apps/web/src/modules/organization/settings/edit-logo.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import {
|
||||
AvatarForm,
|
||||
AvatarFormErrorMessage,
|
||||
AvatarFormInput,
|
||||
AvatarFormPreview,
|
||||
AvatarFormRemoveButton,
|
||||
} from "~/modules/common/avatar-form";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useActiveOrganization } from "../hooks/use-active-organization";
|
||||
|
||||
export const EditLogo = ({ organizationId }: { organizationId: string }) => {
|
||||
const { t } = useTranslation(["common", "validation", "organization"]);
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: activeOrganization } = useQuery(
|
||||
organization.queries.get({ id: organizationId }),
|
||||
);
|
||||
|
||||
const updateOrganization = useMutation({
|
||||
...organization.mutations.update,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({ id: organizationId }),
|
||||
),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasUpdatePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
organization: ["update"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard disabled={!hasUpdatePermission}>
|
||||
<SettingsCardHeader className="block">
|
||||
<AvatarForm
|
||||
id={activeOrganization.id}
|
||||
image={activeOrganization.logo}
|
||||
update={(image) =>
|
||||
updateOrganization.mutateAsync({
|
||||
data: { logo: image ?? "" },
|
||||
organizationId: activeOrganization.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="relative float-right">
|
||||
<AvatarFormInput
|
||||
onUpload={async () => {
|
||||
toast.success(t("logo.update.success"));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({ id: activeOrganization.id }),
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({ slug: activeOrganization.slug }),
|
||||
),
|
||||
]);
|
||||
}}
|
||||
disabled={!hasUpdatePermission}
|
||||
>
|
||||
<AvatarFormPreview
|
||||
fallback={
|
||||
<span className="text-muted-foreground text-3xl uppercase">
|
||||
{activeOrganization.name.charAt(0)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</AvatarFormInput>
|
||||
|
||||
{hasUpdatePermission && (
|
||||
<AvatarFormRemoveButton
|
||||
onRemove={async () => {
|
||||
toast.success(t("logo.remove.success"));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({ id: activeOrganization.id }),
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({
|
||||
slug: activeOrganization.slug,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<SettingsCardTitle>{t("common:logo")}</SettingsCardTitle>
|
||||
<SettingsCardDescription className="py-1.5 whitespace-pre-line">
|
||||
{t("logo.description")}
|
||||
</SettingsCardDescription>
|
||||
|
||||
<AvatarFormErrorMessage className="mt-1" />
|
||||
</AvatarForm>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{hasUpdatePermission ? t("logo.info") : t("logo.missingPermission")}
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
142
apps/web/src/modules/organization/settings/edit-name.tsx
Normal file
142
apps/web/src/modules/organization/settings/edit-name.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MemberRole, updateOrganizationSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { useActiveOrganization } from "../hooks/use-active-organization";
|
||||
|
||||
export const EditName = ({ organizationId }: { organizationId: string }) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: activeOrganization } = useQuery(
|
||||
organization.queries.get({ id: organizationId }),
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(
|
||||
updateOrganizationSchema.pick({ name: true }),
|
||||
),
|
||||
defaultValues: {
|
||||
name: activeOrganization?.name,
|
||||
},
|
||||
});
|
||||
|
||||
const updateOrganization = useMutation({
|
||||
...organization.mutations.update,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({ id: organizationId }),
|
||||
),
|
||||
...(activeOrganization
|
||||
? [
|
||||
queryClient.invalidateQueries(
|
||||
organization.queries.get({ slug: activeOrganization.slug }),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
toast.success(t("name.edit.success"));
|
||||
},
|
||||
});
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasUpdatePermission = authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
organization: ["update"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard disabled={!hasUpdatePermission}>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("common:name")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("name.edit.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
updateOrganization.mutateAsync({ data, organizationId }),
|
||||
),
|
||||
)}
|
||||
>
|
||||
<SettingsCardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={
|
||||
!hasUpdatePermission || form.formState.isSubmitting
|
||||
}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{hasUpdatePermission ? (
|
||||
<>
|
||||
{t("name.edit.info")}
|
||||
<Button size="sm" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("common:save")
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
t("name.edit.missingPermission")
|
||||
)}
|
||||
</SettingsCardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
export const LeaveOrganization = ({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
const { data: activeOrganization } = useQuery(
|
||||
organization.queries.get({ id: organizationId }),
|
||||
);
|
||||
|
||||
const { data: isOnlyOwner } = useQuery({
|
||||
...organization.queries.members.getIsOnlyOwner({ id: organizationId }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const canLeave = !isOnlyOwner?.status;
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard variant="destructive" disabled={!canLeave}>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("leave.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("leave.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{canLeave ? (
|
||||
<ConfirmLeaveModal organizationId={activeOrganization.id}>
|
||||
<Button size="sm" className="ml-auto" variant="destructive">
|
||||
{t("leave.cta")}
|
||||
</Button>
|
||||
</ConfirmLeaveModal>
|
||||
) : (
|
||||
t("leave.cannotLeaveAsOnlyOwner")
|
||||
)}
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmLeaveModal = ({
|
||||
children,
|
||||
organizationId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const router = useRouter();
|
||||
const { refetch } = authClient.useListOrganizations();
|
||||
|
||||
const leaveOrganization = useMutation({
|
||||
...organization.mutations.leave,
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
toast.success(t("leave.success"));
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
},
|
||||
});
|
||||
|
||||
const handleLeave = () => {
|
||||
leaveOrganization.mutate({ organizationId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("leave.title")}</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("leave.disclaimer")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
onClick={handleLeave}
|
||||
variant="destructive"
|
||||
disabled={leaveOrganization.isPending}
|
||||
>
|
||||
{leaveOrganization.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("common:leave")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user