Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -127,7 +127,7 @@ export default function OrganizationAppSidebar({
icon: ChartColumnIncreasing,
url: `/organizations/${organizationId}/usage-details?view=ai-usage`,
},
...(hasOwnerLevelAccess && (currentOrg?.childOrganizations.length ?? 0) > 0
...(hasOwnerLevelAccess && currentOrg?.parent_organization_id === null
? [
{
title: 'Sub-organizations',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,44 +117,50 @@ export function OverviewSection({
)}
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Sub-organization</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Members</TableHead>
<TableHead className="text-right">Seats</TableHead>
<TableHead className="text-right">Credit balance</TableHead>
<TableHead className="text-right">Spend (30 days)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.children.map(child => (
<TableRow key={child.id}>
<TableCell className="font-medium">
<OrganizationLink id={child.id} name={child.name} />
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{child.plan}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{child.memberCount}</TableCell>
<TableCell className="text-right tabular-nums">
{child.requireSeats
? `${child.seatCount.used} / ${child.seatCount.total}`
: 'Not required'}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMicrodollars(child.balanceMicrodollars)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMicrodollars(spendByOrganization.get(child.id) ?? 0)}
</TableCell>
{data.children.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No sub-organizations yet. Create one to start managing it separately.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Sub-organization</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Members</TableHead>
<TableHead className="text-right">Seats</TableHead>
<TableHead className="text-right">Credit balance</TableHead>
<TableHead className="text-right">Spend (30 days)</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{data.children.map(child => (
<TableRow key={child.id}>
<TableCell className="font-medium">
<OrganizationLink id={child.id} name={child.name} />
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{child.plan}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{child.memberCount}</TableCell>
<TableCell className="text-right tabular-nums">
{child.requireSeats
? `${child.seatCount.used} / ${child.seatCount.total}`
: 'Not required'}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMicrodollars(child.balanceMicrodollars)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMicrodollars(spendByOrganization.get(child.id) ?? 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react';

import { redirect } from 'next/navigation';
import { AuthorizedSubOrganizationsLayout } from './layout';

jest.mock('next/navigation', () => ({
redirect: jest.fn(),
}));

describe('AuthorizedSubOrganizationsLayout', () => {
test('allows a parent organization without existing children', () => {
const children: ReactNode = 'sub-organizations content';

expect(
AuthorizedSubOrganizationsLayout({
organizationId: '4d2f6bf9-9a5e-4614-8e5e-39e68d747acd',
parentOrganizationId: null,
children,
})
).toBe(children);
expect(redirect).not.toHaveBeenCalled();
});

test('redirects child organizations', () => {
expect(
AuthorizedSubOrganizationsLayout({
organizationId: '4d2f6bf9-9a5e-4614-8e5e-39e68d747acd',
parentOrganizationId: 'fdd1dc02-8a2d-4d8d-a24d-51b7cf7f5b8e',
children: 'sub-organizations content',
})
).toBe('sub-organizations content');

expect(redirect).toHaveBeenCalledWith('/organizations/4d2f6bf9-9a5e-4614-8e5e-39e68d747acd');
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { ReactNode } from 'react';
import { and, eq, isNull } from 'drizzle-orm';
import { redirect } from 'next/navigation';

import { organizations } from '@kilocode/db/schema';
import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations';
import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout';
import { db } from '@/lib/drizzle';

async function AuthorizedSubOrganizationsLayout({
export function AuthorizedSubOrganizationsLayout({
organizationId,
parentOrganizationId,
children,
Expand All @@ -18,18 +15,6 @@ async function AuthorizedSubOrganizationsLayout({
}) {
if (parentOrganizationId !== null) redirect(`/organizations/${organizationId}`);

const [child] = await db
.select({ id: organizations.id })
.from(organizations)
.where(
and(
eq(organizations.parent_organization_id, organizationId),
isNull(organizations.deleted_at)
)
)
.limit(1);
if (!child) redirect(`/organizations/${organizationId}`);

return children;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

import { OrganizationChildOrganizationsCardView } from './OrganizationChildOrganizationsCard';

const organizationId = '4d2f6bf9-9a5e-4614-8e5e-39e68d747acd';

describe('OrganizationChildOrganizationsCard', () => {
test('shows a setup path when the organization has no children', () => {
const html = renderToStaticMarkup(
React.createElement(OrganizationChildOrganizationsCardView, {
organizationId,
childOrganizations: [],
isLoading: false,
})
);

expect(html).toContain('Create sub-organizations to manage teams');
expect(html).toContain('Set up sub-organizations');
expect(html).toContain(`/organizations/${organizationId}/sub-organizations`);
});

test('keeps the existing child summary and management path', () => {
const html = renderToStaticMarkup(
React.createElement(OrganizationChildOrganizationsCardView, {
organizationId,
childOrganizations: [{ id: 'fdd1dc02-8a2d-4d8d-a24d-51b7cf7f5b8e', name: 'Child One' }],
isLoading: false,
})
);

expect(html).toContain('1 sub-organization belongs to this organization');
expect(html).toContain('Child One');
expect(html).toContain('Manage sub-organizations');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';

import Link from 'next/link';
import { useState, type FormEvent } from 'react';
// React must be in scope for the classic JSX runtime used by the jest transform.
import React, { useState, type FormEvent } from 'react';
import { Building2, ChevronRight, Loader2, Plus } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button, type ButtonProps } from '@/components/ui/button';
Expand Down Expand Up @@ -109,12 +110,25 @@ export function CreateSubOrganizationButton({
}

export function OrganizationChildOrganizationsCard({ organizationId }: Props) {
const { data: children } = useOrganizationChildren(organizationId);
const { data: children = [], isLoading } = useOrganizationChildren(organizationId);

if (!children || children.length === 0) {
return null;
}
return (
Comment thread
jrf0110 marked this conversation as resolved.
<OrganizationChildOrganizationsCardView
organizationId={organizationId}
childOrganizations={children}
isLoading={isLoading}
/>
);
}

export function OrganizationChildOrganizationsCardView({
organizationId,
childOrganizations: children,
isLoading,
}: Props & {
childOrganizations: { id: string; name: string }[];
isLoading: boolean;
}) {
const visibleChildren = children.slice(0, 5);
const remainingCount = Math.max(0, children.length - visibleChildren.length);

Expand All @@ -126,36 +140,43 @@ export function OrganizationChildOrganizationsCard({ organizationId }: Props) {
Sub-organizations
</CardTitle>
<CardDescription>
{children.length} sub-organization{children.length === 1 ? '' : 's'} belong to this
organization
{isLoading
? 'Loading sub-organizations...'
: children.length > 0
? `${children.length} sub-organization${children.length === 1 ? ' belongs' : 's belong'} to this organization`
: 'Create sub-organizations to manage teams, usage, credits, models, and permissions separately.'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{visibleChildren.map(child => (
<Link
key={child.id}
prefetch={false}
href={`/organizations/${encodeURIComponent(child.id)}`}
className="hover:bg-surface-hover focus-visible:ring-ring -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
>
<span className="truncate font-medium" title={child.name}>
{child.name}
</span>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
</Link>
))}
</div>
<CardLinkFooter
href={`/organizations/${encodeURIComponent(organizationId)}/sub-organizations`}
className="flex items-center gap-2"
>
Manage Sub-Organizations
<span className="ml-auto flex items-center gap-2">
{remainingCount > 0 && `${remainingCount} more`}
<ChevronRight className="size-4" />
</span>
</CardLinkFooter>
{visibleChildren.length > 0 && (
<div className="space-y-2">
{visibleChildren.map(child => (
<Link
key={child.id}
prefetch={false}
href={`/organizations/${encodeURIComponent(child.id)}`}
className="hover:bg-surface-hover focus-visible:ring-ring -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
>
<span className="truncate font-medium" title={child.name}>
{child.name}
</span>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
</Link>
))}
</div>
)}
{!isLoading && (
<CardLinkFooter
href={`/organizations/${encodeURIComponent(organizationId)}/sub-organizations`}
className="flex items-center gap-2"
>
{children.length > 0 ? 'Manage sub-organizations' : 'Set up sub-organizations'}
<span className="ml-auto flex items-center gap-2">
{remainingCount > 0 && `${remainingCount} more`}
<ChevronRight className="size-4" />
</span>
</CardLinkFooter>
)}
</CardContent>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,10 @@ export function OrganizationDashboard({
<LockableContainer>
<OrganizationInfoCard organizationId={organizationId} />
</LockableContainer>
{canManageOrganizationBilling(currentRole) && (
<OrganizationChildOrganizationsCard organizationId={organizationId} />
)}
{canManageOrganizationBilling(currentRole) &&
organizationData?.parent_organization_id === null && (
<OrganizationChildOrganizationsCard organizationId={organizationId} />
)}
{organizationData && (
<>
{organizationData.plan === 'teams' ? (
Expand Down