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
@@ -0,0 +1,29 @@
import { Stack } from 'expo-router';
import { Platform, StatusBar, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

// Mirrors apps/(app)/_layout.tsx's Android-safe full-sheet detent — Android
// formSheets can't hit 1.0 without clipping under the status bar.
export default function OrganizationLayout() {
const { height } = useWindowDimensions();
const { top } = useSafeAreaInsets();
const androidTopInset = top > 0 ? top : (StatusBar.currentHeight ?? 0);
const androidFullSheetDetent =
height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1;
const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1;

const sheetOptions = {
presentation: 'formSheet' as const,
sheetAllowedDetents: [0.5, fullSheetDetent] as [number, number],
sheetGrabberVisible: true,
headerShown: false,
};

return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="invite-member" options={sheetOptions} />
<Stack.Screen name="member-limit" options={sheetOptions} />
<Stack.Screen name="low-balance-alert" options={sheetOptions} />
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OrganizationCreditActivityScreen } from '@/components/organization/credit-activity-screen';

export default function OrganizationCreditActivityRoute() {
return <OrganizationCreditActivityScreen />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OrganizationHubScreen } from '@/components/organization/hub-screen';

export default function OrganizationHubRoute() {
return <OrganizationHubScreen />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { InviteMemberSheet } from '@/components/organization/invite-member-sheet';

export default function InviteMemberRoute() {
return <InviteMemberSheet />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OrganizationInvoicesScreen } from '@/components/organization/invoices-screen';

export default function OrganizationInvoicesRoute() {
return <OrganizationInvoicesScreen />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LowBalanceAlertSheet } from '@/components/organization/low-balance-alert-sheet';

export default function LowBalanceAlertRoute() {
return <LowBalanceAlertSheet />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useLocalSearchParams } from 'expo-router';

import { MemberLimitSheet } from '@/components/organization/member-limit-sheet';

export default function MemberLimitRoute() {
const { memberId } = useLocalSearchParams<{ memberId: string }>();
return <MemberLimitSheet memberId={memberId} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OrganizationMembersScreen } from '@/components/organization/members-screen';

export default function OrganizationMembersRoute() {
return <OrganizationMembersScreen />;
}
103 changes: 103 additions & 0 deletions apps/mobile/src/components/organization/credit-activity-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Receipt } from 'lucide-react-native';
import { FlatList, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

import { EmptyState } from '@/components/empty-state';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import {
type CreditTransaction,
useOrgCreditTransactions,
useOrgRole,
} from '@/lib/hooks/use-organization-queries';
import { cn, firstNonEmpty, parseTimestamp } from '@/lib/utils';

function humanizeCategory(category: string): string {
const spaced = category.replaceAll('_', ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}

function CreditRowSkeleton() {
return (
<View className="gap-1.5 rounded-lg bg-secondary p-3">
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-3 w-24 rounded" />
</View>
);
}

function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }>) {
const amount = transaction.amount_microdollars / 1_000_000;
const isPositive = amount >= 0;
const title = firstNonEmpty(
transaction.description,
transaction.credit_category ? humanizeCategory(transaction.credit_category) : undefined,
'Credit transaction'
);

return (
<View className="gap-1 rounded-lg bg-secondary p-3">
<View className="flex-row items-center justify-between gap-3">
<Text className="flex-1 text-sm font-medium text-foreground" numberOfLines={1}>
{title}
</Text>
<Text className={cn('text-sm font-medium', isPositive ? 'text-good' : 'text-foreground')}>
{isPositive ? '+' : '-'}${Math.abs(amount).toFixed(2)}
</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-xs text-muted-foreground">
{parseTimestamp(transaction.created_at).toLocaleDateString()}
</Text>
{transaction.expiry_date != null && (
<Text className="text-xs text-muted-foreground">
Expires {parseTimestamp(transaction.expiry_date).toLocaleDateString()}
</Text>
)}
</View>
</View>
);
}

export function OrganizationCreditActivityScreen() {
const { organizationId } = useOrgRole();
const query = useOrgCreditTransactions(organizationId);

if (organizationId == null) {
return null;
}

const isLoading = query.isLoading || !query.data;
const transactions = query.data ?? [];

return (
<View className="flex-1 bg-background">
<ScreenHeader title="Credit activity" />
{isLoading ? (
<Animated.View exiting={FadeOut.duration(150)} className="gap-3 px-6 pt-4">
<CreditRowSkeleton />
<CreditRowSkeleton />
<CreditRowSkeleton />
</Animated.View>
) : (
<Animated.View entering={FadeIn.duration(200)} className="flex-1">
<FlatList
data={transactions}
keyExtractor={item => item.id}
renderItem={({ item }) => <CreditRow transaction={item} />}
contentContainerClassName="gap-3 px-6 pb-8 pt-4"
ListEmptyComponent={
<EmptyState
icon={Receipt}
className="pt-16"
title="No credit activity"
description="Credit transactions for this organization will show up here."
/>
}
/>
</Animated.View>
)}
</View>
);
}
146 changes: 146 additions & 0 deletions apps/mobile/src/components/organization/hub-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
import { Bell, FileText, Pencil, Receipt, Users } from 'lucide-react-native';
import { useState } from 'react';
import { Pressable, ScrollView, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';

import { OrgUsageStats } from '@/components/organization/org-usage-stats';
import { RenameOrgModal } from '@/components/organization/rename-org-modal';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
import { KvRow } from '@/components/ui/kv-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
import { isMoneyRole, useOrgRole, useOrgWithMembers } from '@/lib/hooks/use-organization-queries';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

function InfoCardSkeleton() {
return (
<View className="gap-2.5 rounded-lg bg-secondary px-3 py-3">
<Skeleton className="h-5 w-2/3 rounded-md" />
<Skeleton className="h-5 w-1/3 rounded-md" />
</View>
);
}

export function OrganizationHubScreen() {
const router = useRouter();
const colors = useThemeColors();
const { organizationId, role, org, isLoading } = useOrgRole();
const orgWithMembers = useOrgWithMembers(organizationId);
const mutations = useOrganizationMutations(organizationId ?? '');
const [renameVisible, setRenameVisible] = useState(false);

if (organizationId == null) {
return null;
}

const showMoney = isMoneyRole(role);
const minimumBalance = orgWithMembers.data?.settings.minimum_balance;
const lowBalanceSubtitle = minimumBalance != null ? `Below $${minimumBalance.toFixed(2)}` : 'Off';

return (
<View className="flex-1 bg-background">
<ScreenHeader title={org?.organizationName ?? 'Organization'} />
<ScrollView
className="flex-1 px-6"
contentContainerClassName="gap-6 pt-4 pb-8"
showsVerticalScrollIndicator={false}
>
<Animated.View layout={LinearTransition}>
{isLoading || !org ? (
<Animated.View exiting={FadeOut.duration(150)}>
<InfoCardSkeleton />
</Animated.View>
) : (
<Animated.View entering={FadeIn.duration(200)} className="rounded-lg bg-secondary px-3">
<View className="flex-row items-center justify-between border-b-[0.5px] border-hair-soft py-3">
<Text className="flex-1 pr-3 text-sm font-medium text-foreground" numberOfLines={1}>
{org.organizationName}
</Text>
{showMoney && (
<Pressable
onPress={() => {
setRenameVisible(true);
}}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Rename organization"
className="active:opacity-70"
>
<Pencil size={16} color={colors.mutedForeground} />
</Pressable>
)}
</View>
{showMoney && (
<KvRow label="Balance" value={`$${(org.balance / 1_000_000).toFixed(2)}`} />
)}
<KvRow label="Seats" value={`${org.seatCount.used} / ${org.seatCount.total}`} last />
</Animated.View>
)}
</Animated.View>

<OrgUsageStats organizationId={organizationId} />

<View className="rounded-lg bg-secondary px-3">
<ConfigureRow
icon={Users}
title="Members"
last={!showMoney}
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization/members' as Href);
}}
/>
{showMoney && (
<>
<ConfigureRow
icon={Receipt}
title="Credit activity"
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization/credit-activity' as Href);
}}
/>
<ConfigureRow
icon={FileText}
title="Invoices"
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization/invoices' as Href);
}}
/>
<ConfigureRow
icon={Bell}
title="Low balance alert"
subtitle={lowBalanceSubtitle}
last
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization/low-balance-alert' as Href);
}}
/>
</>
)}
</View>
</ScrollView>

{renameVisible && org && (
<RenameOrgModal
defaultName={org.organizationName}
onSubmit={name => {
mutations.rename.mutate(
{ name },
{
onSuccess: () => {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
}
);
}}
onClose={() => {
setRenameVisible(false);
}}
/>
)}
</View>
);
}
Loading