Skip to content

Commit b7ce7cc

Browse files
GenerQAQclaude
andcommitted
feat(dashboard): add Usage page to organization sidebar
Show current plan usage (Agent Tasks, Storage) with progress bars and billing period info under /org/[id]/usage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 60868e6 commit b7ce7cc

6 files changed

Lines changed: 336 additions & 1 deletion

File tree

dashboard/app/org/[id]/org-layout-client.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
44
import { AppSidebar, NavItem } from "@/components/app-sidebar";
5-
import { FolderKanban, Receipt, Settings, Users } from "lucide-react";
5+
import { BarChart3, FolderKanban, Receipt, Settings, Users } from "lucide-react";
66
import { encodeId } from "@/lib/id-codec";
77

88
interface OrgLayoutClientProps {
@@ -29,6 +29,12 @@ export function OrgLayoutClient({
2929
href: `/org/${organizationId}/team`,
3030
exactMatch: false,
3131
},
32+
{
33+
title: "Usage",
34+
icon: BarChart3,
35+
href: `/org/${organizationId}/usage`,
36+
exactMatch: false,
37+
},
3238
{
3339
title: "Billing",
3440
icon: Receipt,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Skeleton } from "@/components/ui/skeleton";
2+
import { Card, CardContent, CardHeader } from "@/components/ui/card";
3+
4+
export default function Loading() {
5+
return (
6+
<div className="container mx-auto py-8 px-4 max-w-6xl">
7+
<div className="flex flex-col gap-6">
8+
{/* Header Skeleton */}
9+
<div>
10+
<Skeleton className="h-8 w-48 mb-2" />
11+
<Skeleton className="h-4 w-96" />
12+
</div>
13+
14+
{/* Plan & Period Card Skeleton */}
15+
<Card>
16+
<CardContent className="pt-6">
17+
<div className="flex items-center justify-between">
18+
<Skeleton className="h-8 w-24" />
19+
<Skeleton className="h-4 w-48" />
20+
</div>
21+
</CardContent>
22+
</Card>
23+
24+
{/* Usage Cards Skeleton */}
25+
{[1, 2, 3, 4, 5].map((i) => (
26+
<Card key={i}>
27+
<CardHeader>
28+
<div className="flex items-center justify-between">
29+
<Skeleton className="h-5 w-40" />
30+
<Skeleton className="h-4 w-24" />
31+
</div>
32+
</CardHeader>
33+
<CardContent>
34+
<Skeleton className="h-2 w-full rounded-full" />
35+
</CardContent>
36+
</Card>
37+
))}
38+
</div>
39+
</div>
40+
);
41+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { notFound } from "next/navigation";
2+
import { UsagePageClient } from "./usage-page-client";
3+
import { getOrganizationDataWithPlan, getOrganizationUsage } from "@/lib/supabase";
4+
import { decodeId } from "@/lib/id-codec";
5+
6+
interface PageProps {
7+
params: Promise<{
8+
id: string;
9+
}>;
10+
}
11+
12+
export default async function UsagePage({ params }: PageProps) {
13+
const { id } = await params;
14+
const actualId = decodeId(id);
15+
16+
let orgData;
17+
try {
18+
orgData = await getOrganizationDataWithPlan(actualId);
19+
} catch {
20+
notFound();
21+
}
22+
23+
const { currentOrganization, allOrganizations } = orgData;
24+
25+
const usageData = await getOrganizationUsage(
26+
currentOrganization.id!,
27+
currentOrganization.plan || "free"
28+
);
29+
30+
return (
31+
<UsagePageClient
32+
currentOrganization={currentOrganization}
33+
allOrganizations={allOrganizations}
34+
usageData={usageData}
35+
/>
36+
);
37+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
5+
import { useTopNavStore } from "@/stores/top-nav";
6+
import { usePlanStore } from "@/stores/plan";
7+
import { Organization } from "@/types";
8+
import { OrganizationUsageData } from "@/lib/supabase/operations/organizations";
9+
import {
10+
Card,
11+
CardContent,
12+
CardDescription,
13+
CardHeader,
14+
CardTitle,
15+
} from "@/components/ui/card";
16+
import { Badge } from "@/components/ui/badge";
17+
import { PlanType } from "@/stores/plan";
18+
19+
interface UsagePageClientProps {
20+
currentOrganization: Organization;
21+
allOrganizations: Array<{ id: string; name: string; plan: PlanType }>;
22+
usageData: OrganizationUsageData;
23+
}
24+
25+
function formatStorage(bytes: number): string {
26+
if (bytes === 0) return "0 B";
27+
const units = ["B", "KB", "MB", "GB", "TB"];
28+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
29+
const value = bytes / Math.pow(1024, i);
30+
return `${value.toFixed(value < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
31+
}
32+
33+
function getProgressColor(percentage: number): string {
34+
if (percentage >= 90) return "bg-red-500";
35+
if (percentage >= 70) return "bg-amber-500";
36+
return "";
37+
}
38+
39+
interface UsageMetric {
40+
label: string;
41+
description: string;
42+
current: number;
43+
max: number;
44+
formatValue?: (v: number) => string;
45+
}
46+
47+
function UsageCard({ metric }: { metric: UsageMetric }) {
48+
const percentage = metric.max > 0 ? Math.min((metric.current / metric.max) * 100, 100) : 0;
49+
const format = metric.formatValue || ((v: number) => v.toLocaleString());
50+
const colorClass = getProgressColor(percentage);
51+
52+
return (
53+
<Card>
54+
<CardHeader className="pb-3">
55+
<div className="flex items-center justify-between">
56+
<div>
57+
<CardTitle className="text-base">{metric.label}</CardTitle>
58+
<CardDescription className="text-xs mt-0.5">
59+
{metric.description}
60+
</CardDescription>
61+
</div>
62+
<span className="text-sm text-muted-foreground tabular-nums">
63+
{format(metric.current)} / {metric.max > 0 ? format(metric.max) : "∞"}
64+
</span>
65+
</div>
66+
</CardHeader>
67+
<CardContent>
68+
<div className="relative h-2 w-full overflow-hidden rounded-full bg-muted">
69+
<div
70+
className={`h-full rounded-full transition-all ${colorClass || "bg-primary"}`}
71+
style={{ width: `${percentage}%` }}
72+
/>
73+
</div>
74+
{percentage >= 90 && metric.max > 0 && (
75+
<p className="text-xs text-red-500 mt-2">
76+
{percentage >= 100
77+
? "Quota exceeded. Upgrade your plan to continue."
78+
: "Approaching quota limit."}
79+
</p>
80+
)}
81+
</CardContent>
82+
</Card>
83+
);
84+
}
85+
86+
export function UsagePageClient({
87+
currentOrganization,
88+
allOrganizations,
89+
usageData,
90+
}: UsagePageClientProps) {
91+
const { initialize, setHasSidebar } = useTopNavStore();
92+
const { getPriceByProduct, getPlanDisplayName: getPlanDisplayNameFromPrice } =
93+
usePlanStore();
94+
95+
useEffect(() => {
96+
initialize({
97+
title: "",
98+
organization: currentOrganization,
99+
project: null,
100+
organizations: allOrganizations,
101+
projects: [],
102+
hasSidebar: true,
103+
});
104+
105+
return () => {
106+
setHasSidebar(false);
107+
};
108+
}, [currentOrganization, allOrganizations, initialize, setHasSidebar]);
109+
110+
const getPlanDisplayName = (plan: string | undefined) => {
111+
if (!plan || plan === "free") return "Free Plan";
112+
const priceInfo = getPriceByProduct(plan);
113+
if (priceInfo) {
114+
const displayName = getPlanDisplayNameFromPrice(priceInfo);
115+
return `${displayName} Plan`;
116+
}
117+
return `${plan.charAt(0).toUpperCase() + plan.slice(1)} Plan`;
118+
};
119+
120+
const { usage, limits, period_end } = usageData;
121+
122+
const metrics: UsageMetric[] = [
123+
{
124+
label: "Agent Tasks",
125+
description: "Total agent tasks created this billing period",
126+
current: usage.current_task,
127+
max: limits.max_task,
128+
},
129+
{
130+
label: "Storage",
131+
description: "Total storage used across all projects",
132+
current: usage.current_storage,
133+
max: limits.max_storage,
134+
formatValue: formatStorage,
135+
},
136+
];
137+
138+
return (
139+
<div className="container mx-auto py-8 px-4 max-w-6xl">
140+
<div className="flex flex-col gap-6">
141+
{/* Header */}
142+
<div>
143+
<h1 className="text-2xl font-semibold">Usage</h1>
144+
<p className="text-muted-foreground text-sm mt-1">
145+
Resource usage for the current billing period.
146+
</p>
147+
</div>
148+
149+
{/* Plan & Period */}
150+
<Card>
151+
<CardContent>
152+
<div className="flex items-center justify-between">
153+
<div className="flex items-center gap-3">
154+
<Badge variant="secondary" className="text-base px-4 py-1">
155+
{getPlanDisplayName(currentOrganization.plan)}
156+
</Badge>
157+
</div>
158+
{period_end && (
159+
<p className="text-sm text-muted-foreground">
160+
Current period ends{" "}
161+
<span className="font-medium text-foreground">
162+
{new Date(period_end).toLocaleDateString("en-US", {
163+
month: "short",
164+
day: "numeric",
165+
year: "numeric",
166+
})}
167+
</span>
168+
</p>
169+
)}
170+
</div>
171+
</CardContent>
172+
</Card>
173+
174+
{/* Usage Metrics */}
175+
{metrics.map((metric) => (
176+
<UsageCard key={metric.label} metric={metric} />
177+
))}
178+
</div>
179+
</div>
180+
);
181+
}

dashboard/lib/supabase/operations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export {
4242
addOrganizationMemberByEmail,
4343
removeOrganizationMember,
4444
getOrganizationDataWithPlan,
45+
getOrganizationUsage,
4546
type OrganizationMember,
47+
type OrganizationUsageData,
4648
} from "./organizations";
4749

4850
// Project operations (Server Actions)

dashboard/lib/supabase/operations/organizations.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,74 @@ export async function removeOrganizationMember(orgId: string, userId: string) {
318318
return { error: null };
319319
}
320320

321+
/**
322+
* Get organization usage and plan limits
323+
*/
324+
export interface OrganizationUsageData {
325+
usage: {
326+
current_task: number;
327+
current_skill: number;
328+
current_fast_skill_search: number;
329+
current_agentic_skill_search: number;
330+
current_storage: number;
331+
};
332+
limits: {
333+
max_task: number;
334+
max_skill: number;
335+
max_fast_skill_search: number;
336+
max_agentic_skill_search: number;
337+
max_storage: number;
338+
};
339+
period_end: string | null;
340+
}
341+
342+
export async function getOrganizationUsage(
343+
orgId: string,
344+
plan: string
345+
): Promise<OrganizationUsageData> {
346+
const supabase = await createClient();
347+
348+
const [usageResult, limitsResult, billingResult] = await Promise.all([
349+
supabase
350+
.from("organization_usage")
351+
.select("current_task, current_skill, current_fast_skill_search, current_agentic_skill_search, current_storage")
352+
.eq("organization_id", orgId)
353+
.single(),
354+
supabase
355+
.from("product_plans")
356+
.select("max_task, max_skill, max_fast_skill_search, max_agentic_skill_search, max_storage")
357+
.eq("plan", plan)
358+
.single(),
359+
supabase
360+
.from("organization_billing")
361+
.select("period_end")
362+
.eq("organization_id", orgId)
363+
.single(),
364+
]);
365+
366+
const usage = usageResult.data || {
367+
current_task: 0,
368+
current_skill: 0,
369+
current_fast_skill_search: 0,
370+
current_agentic_skill_search: 0,
371+
current_storage: 0,
372+
};
373+
374+
const limits = limitsResult.data || {
375+
max_task: 0,
376+
max_skill: 0,
377+
max_fast_skill_search: 0,
378+
max_agentic_skill_search: 0,
379+
max_storage: 0,
380+
};
381+
382+
return {
383+
usage,
384+
limits,
385+
period_end: billingResult.data?.period_end || null,
386+
};
387+
}
388+
321389
/**
322390
* Helper function to transform organization membership data
323391
*/

0 commit comments

Comments
 (0)