Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
BarChart3,
BarChartIcon,
XIcon,
PieChartIcon,
} from "lucide-react";
import { InviteMemberModal } from "@/components/InviteMemberModal";
import {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { usePathname } from "next/navigation";
import { TabSelect } from "@/components/TabSelect";
import { PageHeading } from "@/components/Typography";

interface OrganizationTabsProps {
organizationId: string;
organizationName?: string;
}

export function OrganizationTabs({
organizationId,
organizationName,
}: OrganizationTabsProps) {
const pathname = usePathname();

const tabs = [
{
id: "members",
label: "Members",
href: `/organization/${organizationId}`,
},
{
id: "stats",
label: "Analytics",
href: `/organization/${organizationId}/stats`,
},
];

// Determine selected tab based on pathname
const selected = pathname.includes("/stats") ? "stats" : "members";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use more precise pathname matching.

Using pathname.includes("/stats") is fragile because it will match any occurrence of "stats" in the pathname, potentially causing incorrect tab selection if "stats" appears in a query parameter or elsewhere in the URL.

Use a more precise check:

-  const selected = pathname.includes("/stats") ? "stats" : "members";
+  const selected = pathname.endsWith("/stats") ? "stats" : "members";

Or for even more precision:

-  const selected = pathname.includes("/stats") ? "stats" : "members";
+  const selected = pathname === `/organization/${organizationId}/stats` ? "stats" : "members";
🤖 Prompt for AI Agents
In apps/web/app/(app)/organization/[organizationId]/OrganizationTabs.tsx around
line 32, the current selected-tab logic uses pathname.includes("/stats") which
is too broad; change it to compare the pathname path portion exactly (e.g.,
strip query/hash and check endsWith or exact segment match) so only the actual
stats route selects the stats tab — for example, derive the raw path (remove
search/hash) and use path.endsWith("/stats") or split the path into segments and
compare the last segment === "stats".


return (
<div>
{organizationName && (
<PageHeading className="mb-2">{organizationName}</PageHeading>
)}
<div className="border-b border-neutral-200">
<TabSelect options={tabs} selected={selected} />
</div>
</div>
);
}
15 changes: 12 additions & 3 deletions apps/web/app/(app)/organization/[organizationId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Members } from "@/app/(app)/organization/[organizationId]/Members";
import { PageHeader } from "@/components/PageHeader";
import { OrganizationTabs } from "@/app/(app)/organization/[organizationId]/OrganizationTabs";
import { PageWrapper } from "@/components/PageWrapper";
import prisma from "@/utils/prisma";

export default async function MembersPage({
params,
Expand All @@ -9,11 +10,19 @@ export default async function MembersPage({
}) {
const { organizationId } = await params;

const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
});

return (
<PageWrapper>
<PageHeader title="Organization Members" />
<OrganizationTabs
organizationId={organizationId}
organizationName={organization?.name}
/>

<div className="mt-4">
<div className="mt-6">
<Members organizationId={organizationId} />
</div>
</PageWrapper>
Expand Down
229 changes: 229 additions & 0 deletions apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"use client";

import { useState, useMemo, useCallback } from "react";
import type { DateRange } from "react-day-picker";
import { subDays } from "date-fns/subDays";
import { Mail, Sparkles, Users } from "lucide-react";
import { LoadingContent } from "@/components/LoadingContent";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DatePickerWithRange } from "@/components/DatePickerWithRange";
import { useOrgStatsTotals } from "@/hooks/useOrgStatsTotals";
import { useOrgStatsEmailBuckets } from "@/hooks/useOrgStatsEmailBuckets";
import { useOrgStatsRulesBuckets } from "@/hooks/useOrgStatsRulesBuckets";

const selectOptions = [
{ label: "Last week", value: "7" },
{ label: "Last month", value: "30" },
{ label: "Last 3 months", value: "90" },
{ label: "All time", value: "0" },
];
const defaultSelected = selectOptions[1];

export function OrgStats({ organizationId }: { organizationId: string }) {
const [dateDropdown, setDateDropdown] = useState<string>(
defaultSelected.label,
);

const now = useMemo(() => new Date(), []);
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(now, Number.parseInt(defaultSelected.value)),
to: now,
});

const onSetDateDropdown = useCallback(
(option: { label: string; value: string }) => {
setDateDropdown(option.label);
},
[],
);

const options = useMemo(
() => ({
fromDate: dateRange?.from?.getTime(),
toDate: dateRange?.to?.getTime(),
}),
[dateRange],
);

const {
data: totalsData,
isLoading: totalsLoading,
error: totalsError,
} = useOrgStatsTotals(organizationId, options);

const {
data: emailBucketsData,
isLoading: emailBucketsLoading,
error: emailBucketsError,
} = useOrgStatsEmailBuckets(organizationId, options);

const {
data: rulesBucketsData,
isLoading: rulesBucketsLoading,
error: rulesBucketsError,
} = useOrgStatsRulesBuckets(organizationId, options);

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<DatePickerWithRange
dateRange={dateRange}
onSetDateRange={setDateRange}
selectOptions={selectOptions}
dateDropdown={dateDropdown}
onSetDateDropdown={onSetDateDropdown}
/>
</div>

<div className="space-y-6">
<LoadingContent
loading={totalsLoading}
error={totalsError}
loadingComponent={
<div className="grid gap-4 md:grid-cols-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
}
>
{totalsData && (
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Emails Received"
value={totalsData.totalEmails.toLocaleString()}
icon={<Mail className="h-4 w-4 text-muted-foreground" />}
/>
<StatCard
title="Rules Executed"
value={totalsData.totalRules.toLocaleString()}
icon={<Sparkles className="h-4 w-4 text-muted-foreground" />}
/>
<StatCard
title="Active Members"
value={totalsData.activeMembers.toLocaleString()}
icon={<Users className="h-4 w-4 text-muted-foreground" />}
/>
</div>
)}
</LoadingContent>

<div className="grid gap-4 md:grid-cols-2">
<LoadingContent
loading={emailBucketsLoading}
error={emailBucketsError}
loadingComponent={<Skeleton className="h-64" />}
>
{emailBucketsData && (
<BucketChart
title="Email Volume Distribution"
description="Number of users by emails received in selected period"
data={emailBucketsData}
emptyMessage="No email data available. Users need to load their stats first."
unit="emails"
/>
)}
</LoadingContent>

<LoadingContent
loading={rulesBucketsLoading}
error={rulesBucketsError}
loadingComponent={<Skeleton className="h-64" />}
>
{rulesBucketsData && (
<BucketChart
title="Automation Usage Distribution"
description="Number of users by rules executed in selected period"
data={rulesBucketsData}
emptyMessage="No automation data yet."
unit="rules"
/>
)}
</LoadingContent>
</div>
</div>
</div>
);
}

function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
</CardContent>
</Card>
);
}

function BucketChart({
title,
description,
data,
emptyMessage,
unit = "emails",
}: {
title: string;
description: string;
data: { label: string; userCount: number }[];
emptyMessage: string;
unit?: string;
}) {
const hasData = data.some((bucket) => bucket.userCount > 0);
const maxValue = Math.max(...data.map((d) => d.userCount), 1);

return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="flex h-40 items-center justify-center">
<p className="text-sm text-muted-foreground text-center">
{emptyMessage}
</p>
</div>
) : (
<div className="space-y-3">
{data.map((bucket) => (
<div key={bucket.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{bucket.label} {unit}
</span>
<span className="font-medium">
{bucket.userCount}{" "}
{bucket.userCount === 1 ? "user" : "users"}
</span>
</div>
<div className="h-2 w-full rounded-full bg-secondary">
<div
className="h-2 rounded-full bg-primary transition-all"
style={{
width: `${(bucket.userCount / maxValue) * 100}%`,
}}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
30 changes: 30 additions & 0 deletions apps/web/app/(app)/organization/[organizationId]/stats/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { PageWrapper } from "@/components/PageWrapper";
import { OrgStats } from "@/app/(app)/organization/[organizationId]/stats/OrgStats";
import { OrganizationTabs } from "@/app/(app)/organization/[organizationId]/OrganizationTabs";
import prisma from "@/utils/prisma";

export default async function OrgStatsPage({
params,
}: {
params: Promise<{ organizationId: string }>;
}) {
const { organizationId } = await params;

const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
});

return (
<PageWrapper>
<OrganizationTabs
organizationId={organizationId}
organizationName={organization?.name}
/>

<div className="mt-6">
<OrgStats organizationId={organizationId} />
</div>
</PageWrapper>
);
}
Loading
Loading