diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx new file mode 100644 index 0000000000..c06b11e953 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx @@ -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 ( + + + + + + ); +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx new file mode 100644 index 0000000000..d16156956e --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx @@ -0,0 +1,5 @@ +import { OrganizationCreditActivityScreen } from '@/components/organization/credit-activity-screen'; + +export default function OrganizationCreditActivityRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx new file mode 100644 index 0000000000..58cc2d6a91 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx @@ -0,0 +1,5 @@ +import { OrganizationHubScreen } from '@/components/organization/hub-screen'; + +export default function OrganizationHubRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx new file mode 100644 index 0000000000..c0899b96e6 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx @@ -0,0 +1,5 @@ +import { InviteMemberSheet } from '@/components/organization/invite-member-sheet'; + +export default function InviteMemberRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx new file mode 100644 index 0000000000..495ded7437 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx @@ -0,0 +1,5 @@ +import { OrganizationInvoicesScreen } from '@/components/organization/invoices-screen'; + +export default function OrganizationInvoicesRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx new file mode 100644 index 0000000000..7e314a39db --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx @@ -0,0 +1,5 @@ +import { LowBalanceAlertSheet } from '@/components/organization/low-balance-alert-sheet'; + +export default function LowBalanceAlertRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx new file mode 100644 index 0000000000..c2f96f816a --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx @@ -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 ; +} diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx new file mode 100644 index 0000000000..0f9af55ba4 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx @@ -0,0 +1,5 @@ +import { OrganizationMembersScreen } from '@/components/organization/members-screen'; + +export default function OrganizationMembersRoute() { + return ; +} diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx new file mode 100644 index 0000000000..3445039529 --- /dev/null +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -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 ( + + + + + ); +} + +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 ( + + + + {title} + + + {isPositive ? '+' : '-'}${Math.abs(amount).toFixed(2)} + + + + + {parseTimestamp(transaction.created_at).toLocaleDateString()} + + {transaction.expiry_date != null && ( + + Expires {parseTimestamp(transaction.expiry_date).toLocaleDateString()} + + )} + + + ); +} + +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 ( + + + {isLoading ? ( + + + + + + ) : ( + + item.id} + renderItem={({ item }) => } + contentContainerClassName="gap-3 px-6 pb-8 pt-4" + ListEmptyComponent={ + + } + /> + + )} + + ); +} diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx new file mode 100644 index 0000000000..d5c9b67581 --- /dev/null +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -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 ( + + + + + ); +} + +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 ( + + + + + {isLoading || !org ? ( + + + + ) : ( + + + + {org.organizationName} + + {showMoney && ( + { + setRenameVisible(true); + }} + hitSlop={12} + accessibilityRole="button" + accessibilityLabel="Rename organization" + className="active:opacity-70" + > + + + )} + + {showMoney && ( + + )} + + + )} + + + + + + { + router.push('/(app)/(tabs)/(3_profile)/organization/members' as Href); + }} + /> + {showMoney && ( + <> + { + router.push('/(app)/(tabs)/(3_profile)/organization/credit-activity' as Href); + }} + /> + { + router.push('/(app)/(tabs)/(3_profile)/organization/invoices' as Href); + }} + /> + { + router.push('/(app)/(tabs)/(3_profile)/organization/low-balance-alert' as Href); + }} + /> + + )} + + + + {renameVisible && org && ( + { + mutations.rename.mutate( + { name }, + { + onSuccess: () => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + } + ); + }} + onClose={() => { + setRenameVisible(false); + }} + /> + )} + + ); +} diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx new file mode 100644 index 0000000000..bf93209677 --- /dev/null +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -0,0 +1,116 @@ +import * as Haptics from 'expo-haptics'; +import { useRouter } from 'expo-router'; +import { Check } from 'lucide-react-native'; +import { useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native'; + +import { ROLE_LABEL } from '@/components/organization/member-row'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; +import { type OrgRole, useOrgRole } from '@/lib/hooks/use-organization-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn, EMAIL_PATTERN } from '@/lib/utils'; + +const INVITABLE_ROLES: OrgRole[] = ['member', 'billing_manager', 'owner']; + +export function InviteMemberSheet() { + const router = useRouter(); + const colors = useThemeColors(); + const { organizationId, role: myRole } = useOrgRole(); + const mutations = useOrganizationMutations(organizationId ?? ''); + const emailRef = useRef(''); + const [canSubmit, setCanSubmit] = useState(false); + const isBillingManager = myRole === 'billing_manager'; + const [role, setRole] = useState('member'); + + const onSubmit = () => { + const email = emailRef.current.trim().toLowerCase(); + if (!EMAIL_PATTERN.test(email)) { + return; + } + mutations.invite.mutate( + { email, role: isBillingManager ? 'member' : role }, + { + onSuccess: () => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + router.back(); + }, + } + ); + }; + + return ( + + Invite member + + + + Email + + { + emailRef.current = value; + setCanSubmit(EMAIL_PATTERN.test(value.trim())); + }} + /> + + + {isBillingManager ? ( + Role: Member + ) : ( + + + Role + + + {INVITABLE_ROLES.map((value, index) => { + const selected = role === value; + return ( + { + setRole(value); + }} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + {ROLE_LABEL[value]} + {selected && } + + ); + })} + + + )} + + {mutations.invite.isError && ( + {mutations.invite.error.message} + )} + + + + ); +} diff --git a/apps/mobile/src/components/organization/invited-member-row.tsx b/apps/mobile/src/components/organization/invited-member-row.tsx new file mode 100644 index 0000000000..0247858f0a --- /dev/null +++ b/apps/mobile/src/components/organization/invited-member-row.tsx @@ -0,0 +1,110 @@ +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { Alert, Pressable, Share, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Text } from '@/components/ui/text'; +import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; +import { type InvitedOrgMember } from '@/lib/hooks/use-organization-queries'; +import { cn, parseTimestamp } from '@/lib/utils'; + +import { ROLE_LABEL } from './member-row'; + +type InvitedMemberRowProps = { + invite: InvitedOrgMember; + /** Caller is owner. */ + canManage: boolean; + organizationId: string; + /** Suppress bottom divider on the last row of a group. */ + last?: boolean; +}; + +function inviteDateLabel(inviteDate: string | null): string | null { + if (inviteDate == null) { + return null; + } + return `Invited ${parseTimestamp(inviteDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`; +} + +export function InvitedMemberRow({ + invite, + canManage, + organizationId, + last, +}: Readonly) { + const { bottom } = useSafeAreaInsets(); + const { showActionSheetWithOptions } = useActionSheet(); + const mutations = useOrganizationMutations(organizationId); + const dateLabel = inviteDateLabel(invite.inviteDate); + + function confirmRevoke() { + Alert.alert('Revoke invitation', `Revoke the invitation sent to ${invite.email}?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Revoke', + style: 'destructive', + onPress: () => { + mutations.deleteInvite.mutate({ inviteId: invite.inviteId }); + }, + }, + ]); + } + + function openActions() { + const options = ['Share invite link', 'Revoke invitation', 'Cancel']; + showActionSheetWithOptions( + { + options, + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + containerStyle: { paddingBottom: bottom }, + }, + index => { + if (index === 0) { + void Share.share({ message: invite.inviteUrl }); + } else if (index === 1) { + confirmRevoke(); + } + } + ); + } + + const inner = ( + + + + {invite.email} + + {dateLabel && ( + + {dateLabel} + + )} + + + + {ROLE_LABEL[invite.role]} + + + + ); + + if (!canManage) { + return {inner}; + } + + return ( + + {inner} + + ); +} diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx new file mode 100644 index 0000000000..3c31a81d77 --- /dev/null +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -0,0 +1,103 @@ +import { FileText } 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 OrgInvoice, useOrgInvoices, useOrgRole } from '@/lib/hooks/use-organization-queries'; +import { cn, firstNonEmpty } from '@/lib/utils'; + +const STATUS_META: Record = { + paid: { label: 'Paid', pillClass: 'bg-good', textClass: 'text-good-foreground' }, + open: { label: 'Open', pillClass: 'bg-warn', textClass: 'text-warn-foreground' }, + void: { label: 'Void', pillClass: 'bg-muted', textClass: 'text-muted-foreground' }, +}; + +function statusMeta(status: string): { label: string; pillClass: string; textClass: string } { + return ( + STATUS_META[status] ?? { + label: status.charAt(0).toUpperCase() + status.slice(1), + pillClass: 'bg-muted', + textClass: 'text-muted-foreground', + } + ); +} + +function InvoiceRowSkeleton() { + return ( + + + + + ); +} + +function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) { + const meta = statusMeta(invoice.status); + const title = firstNonEmpty(invoice.number, invoice.description, 'Invoice'); + + return ( + + + + {title} + + + ${(invoice.amount_due / 100).toFixed(2)} + + + + + {new Date(invoice.created * 1000).toLocaleDateString()} + + + {meta.label} + + + + ); +} + +export function OrganizationInvoicesScreen() { + const { organizationId } = useOrgRole(); + const query = useOrgInvoices(organizationId); + + if (organizationId == null) { + return null; + } + + const isLoading = query.isLoading || !query.data; + const invoices = query.data ?? []; + + return ( + + + {isLoading ? ( + + + + + + ) : ( + + item.id} + renderItem={({ item }) => } + contentContainerClassName="gap-3 px-6 pb-8 pt-4" + ListEmptyComponent={ + + } + /> + + )} + + ); +} diff --git a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx new file mode 100644 index 0000000000..ee39022f68 --- /dev/null +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -0,0 +1,195 @@ +import * as Haptics from 'expo-haptics'; +import { useRouter } from 'expo-router'; +import { useRef, useState } from 'react'; +import { ActivityIndicator, ScrollView, Switch, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; +import { + type OrgWithMembers, + useOrgRole, + useOrgWithMembers, +} from '@/lib/hooks/use-organization-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { EMAIL_PATTERN } from '@/lib/utils'; + +function parseThreshold(value: string): number | null { + const trimmed = value.trim(); + const parsed = Number(trimmed); + if (trimmed === '' || !Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function parseEmails(value: string): string[] { + return value + .split(',') + .map(email => email.trim()) + .filter(email => email !== ''); +} + +type LowBalanceAlertFormProps = Readonly<{ + organizationId: string | null; + settings: OrgWithMembers['settings']; +}>; + +function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormProps) { + const router = useRouter(); + const colors = useThemeColors(); + const mutations = useOrganizationMutations(organizationId ?? ''); + const { email: myEmail } = useCurrentUserId(); + + const [enabled, setEnabled] = useState(settings.minimum_balance !== undefined); + const thresholdRef = useRef( + settings.minimum_balance != null ? String(settings.minimum_balance) : '' + ); + const emailsRef = useRef((settings.minimum_balance_alert_email ?? []).join(', ')); + const [canSave, setCanSave] = useState(() => { + const emails = parseEmails(emailsRef.current); + return ( + !enabled || + (parseThreshold(thresholdRef.current) != null && + emails.length > 0 && + emails.every(email => EMAIL_PATTERN.test(email))) + ); + }); + + const revalidate = (nextEnabled: boolean, thresholdValue: string, emailsValue: string) => { + const emails = parseEmails(emailsValue); + setCanSave( + !nextEnabled || + (parseThreshold(thresholdValue) != null && + emails.length > 0 && + emails.every(email => EMAIL_PATTERN.test(email))) + ); + }; + + const onSave = () => { + const threshold = parseThreshold(thresholdRef.current); + const emails = parseEmails(emailsRef.current); + if (enabled && (threshold == null || emails.length === 0 || !canSave)) { + return; + } + mutations.updateMinimumBalanceAlert.mutate( + { + enabled, + ...(enabled && threshold != null + ? { minimum_balance: threshold, minimum_balance_alert_email: emails } + : {}), + }, + { + onSuccess: () => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + router.back(); + }, + } + ); + }; + + return ( + <> + + Enabled + { + void Haptics.selectionAsync(); + setEnabled(value); + revalidate(value, thresholdRef.current, emailsRef.current); + }} + /> + + + {enabled && ( + <> + + + Alert below (USD) + + { + thresholdRef.current = value; + revalidate(enabled, value, emailsRef.current); + }} + /> + + + + + Notify emails + + { + emailsRef.current = value; + revalidate(enabled, thresholdRef.current, value); + }} + /> + + Separate multiple emails with commas. + + + + )} + + {mutations.updateMinimumBalanceAlert.isError && ( + + {mutations.updateMinimumBalanceAlert.error.message} + + )} + + + + ); +} + +export function LowBalanceAlertSheet() { + const { organizationId } = useOrgRole(); + const orgWithMembers = useOrgWithMembers(organizationId); + + return ( + + Low balance alert + + {orgWithMembers.data ? ( + + ) : ( + <> + + + + )} + + ); +} diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx new file mode 100644 index 0000000000..4276bf8853 --- /dev/null +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -0,0 +1,158 @@ +import * as Haptics from 'expo-haptics'; +import { useRouter } from 'expo-router'; +import { useRef, useState } from 'react'; +import { ActivityIndicator, ScrollView, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; +import { + type ActiveOrgMember, + useOrgRole, + useOrgWithMembers, +} from '@/lib/hooks/use-organization-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { firstNonEmpty } from '@/lib/utils'; + +const MAX_DAILY_LIMIT_USD = 2000; + +function parseLimit(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === '') { + return null; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > MAX_DAILY_LIMIT_USD) { + return null; + } + return parsed; +} + +function isValidLimit(value: string): boolean { + return parseLimit(value) != null; +} + +type MemberLimitFormProps = Readonly<{ + memberId: string; + organizationId: string | null; + member: ActiveOrgMember; +}>; + +function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormProps) { + const router = useRouter(); + const colors = useThemeColors(); + const mutations = useOrganizationMutations(organizationId ?? ''); + const currentLimit = member.dailyUsageLimitUsd; + + const limitRef = useRef(currentLimit != null ? String(currentLimit) : ''); + const [canSave, setCanSave] = useState(isValidLimit(limitRef.current)); + + const onSaved = () => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + router.back(); + }; + + const onSave = () => { + const parsed = parseLimit(limitRef.current); + if (parsed == null) { + return; + } + mutations.updateMember.mutate({ memberId, dailyUsageLimitUsd: parsed }, { onSuccess: onSaved }); + }; + + const onRemove = () => { + mutations.updateMember.mutate({ memberId, dailyUsageLimitUsd: null }, { onSuccess: onSaved }); + }; + + return ( + <> + + + Limit (USD per day) + + { + limitRef.current = value; + setCanSave(isValidLimit(value)); + }} + /> + + + {mutations.updateMember.isError && ( + {mutations.updateMember.error.message} + )} + + + + {currentLimit != null && ( + + )} + + ); +} + +export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { + const { organizationId } = useOrgRole(); + const orgWithMembers = useOrgWithMembers(organizationId); + const member = orgWithMembers.data?.members.find( + (m): m is ActiveOrgMember => m.status === 'active' && m.id === memberId + ); + + if (orgWithMembers.isLoading) { + return ( + + + + Daily usage limit + + + + + ); + } + + if (!member) { + return ( + + Daily usage limit + Member not found + + ); + } + + return ( + + + Daily usage limit + + {firstNonEmpty(member.name, member.email)} + + + + + + ); +} diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx new file mode 100644 index 0000000000..afe92935fd --- /dev/null +++ b/apps/mobile/src/components/organization/member-row.tsx @@ -0,0 +1,161 @@ +import { useActionSheet } from '@expo/react-native-action-sheet'; +import * as Haptics from 'expo-haptics'; +import { type Href, useRouter } from 'expo-router'; +import { Alert, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Text } from '@/components/ui/text'; +import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; +import { type ActiveOrgMember, type OrgRole } from '@/lib/hooks/use-organization-queries'; +import { cn, firstNonEmpty } from '@/lib/utils'; + +type MemberRowProps = { + member: ActiveOrgMember; + /** Caller is owner and this isn't their own row. */ + canManage: boolean; + enableUsageLimits: boolean; + organizationId: string; + /** Suppress bottom divider on the last row of a group. */ + last?: boolean; +}; + +export const ROLE_LABEL: Record = { + owner: 'Owner', + member: 'Member', + billing_manager: 'Billing manager', +}; + +const ROLE_OPTIONS: OrgRole[] = ['owner', 'member', 'billing_manager']; + +export function MemberRow({ + member, + canManage, + enableUsageLimits, + organizationId, + last, +}: Readonly) { + const router = useRouter(); + const { bottom } = useSafeAreaInsets(); + const { showActionSheetWithOptions } = useActionSheet(); + const mutations = useOrganizationMutations(organizationId); + const displayName = firstNonEmpty(member.name, member.email); + + function openRoleSheet() { + const options = [...ROLE_OPTIONS.map(role => ROLE_LABEL[role]), 'Cancel']; + showActionSheetWithOptions( + { + options, + cancelButtonIndex: options.length - 1, + containerStyle: { paddingBottom: bottom }, + }, + index => { + const role = index !== undefined ? ROLE_OPTIONS[index] : undefined; + if (!role) { + return; + } + mutations.updateMember.mutate( + { memberId: member.id, role }, + { + onSuccess: () => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }, + } + ); + } + ); + } + + function confirmRemove() { + Alert.alert('Remove member', `Remove ${displayName} from this organization?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: () => { + mutations.removeMember.mutate( + { memberId: member.id }, + { + onSuccess: () => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + } + ); + }, + }, + ]); + } + + function openActions() { + const options = [ + 'Change role', + ...(enableUsageLimits ? ['Set daily usage limit'] : []), + 'Remove member', + 'Cancel', + ]; + showActionSheetWithOptions( + { + options, + cancelButtonIndex: options.length - 1, + destructiveButtonIndex: options.length - 2, + containerStyle: { paddingBottom: bottom }, + }, + index => { + const label = index !== undefined ? options[index] : undefined; + if (label === 'Change role') { + openRoleSheet(); + } else if (label === 'Set daily usage limit') { + router.push( + `/(app)/(tabs)/(3_profile)/organization/member-limit?memberId=${member.id}` as Href + ); + } else if (label === 'Remove member') { + confirmRemove(); + } + } + ); + } + + const inner = ( + + + + {displayName} + + + {member.email} + + + + + + {ROLE_LABEL[member.role]} + + + {member.dailyUsageLimitUsd != null && ( + + ${member.dailyUsageLimitUsd.toFixed(2)}/day + + )} + + + ); + + if (!canManage) { + return {inner}; + } + + return ( + + {inner} + + ); +} diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx new file mode 100644 index 0000000000..987cee4b17 --- /dev/null +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -0,0 +1,148 @@ +import { type Href, useRouter } from 'expo-router'; +import { UserPlus } from 'lucide-react-native'; +import { Pressable, ScrollView, View } from 'react-native'; +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { InvitedMemberRow } from '@/components/organization/invited-member-row'; +import { MemberRow } from '@/components/organization/member-row'; +import { ScreenHeader } from '@/components/screen-header'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { + type ActiveOrgMember, + type InvitedOrgMember, + isMoneyRole, + useOrgRole, + useOrgWithMembers, +} from '@/lib/hooks/use-organization-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { firstNonEmpty, parseTimestamp } from '@/lib/utils'; + +function sortActiveMembers(members: ActiveOrgMember[]): ActiveOrgMember[] { + // eslint-disable-next-line unicorn/no-array-sort -- toSorted() is not available in Hermes + return [...members].sort((a, b) => + firstNonEmpty(a.name, a.email).localeCompare(firstNonEmpty(b.name, b.email)) + ); +} + +function sortInvitedMembers(invites: InvitedOrgMember[]): InvitedOrgMember[] { + // eslint-disable-next-line unicorn/no-array-sort -- toSorted() is not available in Hermes + return [...invites].sort((a, b) => { + if (a.inviteDate == null) { + return b.inviteDate == null ? 0 : 1; + } + if (b.inviteDate == null) { + return -1; + } + return parseTimestamp(b.inviteDate).getTime() - parseTimestamp(a.inviteDate).getTime(); + }); +} + +function MemberRowSkeleton({ last }: Readonly<{ last?: boolean }>) { + return ( + + + + + + + ); +} + +export function OrganizationMembersScreen() { + const router = useRouter(); + const colors = useThemeColors(); + const { organizationId, role } = useOrgRole(); + const orgWithMembers = useOrgWithMembers(organizationId); + const { userId: currentUserId } = useCurrentUserId(); + + if (organizationId == null) { + return null; + } + + const isLoading = orgWithMembers.isLoading || !orgWithMembers.data; + const enableUsageLimits = orgWithMembers.data?.settings.enable_usage_limits !== false; + const canInvite = isMoneyRole(role); + const isOwner = role === 'owner'; + + const activeMembers = sortActiveMembers( + orgWithMembers.data?.members.filter(m => m.status === 'active') ?? [] + ); + const invitedMembers = sortInvitedMembers( + orgWithMembers.data?.members.filter(m => m.status === 'invited') ?? [] + ); + + return ( + + { + router.push('/(app)/(tabs)/(3_profile)/organization/invite-member' as Href); + }} + hitSlop={12} + accessibilityRole="button" + accessibilityLabel="Invite member" + className="active:opacity-70" + > + + + ) : undefined + } + /> + + + Members + {isLoading ? ( + + + + + + ) : ( + + {activeMembers.map((member, index) => ( + + ))} + + )} + + + {!isLoading && invitedMembers.length > 0 && ( + + Pending invitations + + {invitedMembers.map((invite, index) => ( + + ))} + + + )} + + + ); +} diff --git a/apps/mobile/src/components/organization/org-usage-stats.tsx b/apps/mobile/src/components/organization/org-usage-stats.tsx new file mode 100644 index 0000000000..206d924b77 --- /dev/null +++ b/apps/mobile/src/components/organization/org-usage-stats.tsx @@ -0,0 +1,64 @@ +import { View } from 'react-native'; +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useOrgUsageStats } from '@/lib/hooks/use-organization-queries'; + +function StatTile({ label, value }: Readonly<{ label: string; value: string }>) { + return ( + + {value} + {label} + + ); +} + +function StatTileSkeleton() { + return ( + + + + + ); +} + +type OrgUsageStatsProps = { + organizationId: string; +}; + +/** "Last 30 days" eyebrow + 2x2 usage stat tile grid. Visible to all org roles. */ +export function OrgUsageStats({ organizationId }: Readonly) { + const { data, isLoading } = useOrgUsageStats(organizationId); + + return ( + + + Last 30 days + + {isLoading || !data ? ( + + + + + + + + + + + ) : ( + + + + + + + + + + + )} + + ); +} diff --git a/apps/mobile/src/components/organization/rename-org-modal.tsx b/apps/mobile/src/components/organization/rename-org-modal.tsx new file mode 100644 index 0000000000..51842a46a1 --- /dev/null +++ b/apps/mobile/src/components/organization/rename-org-modal.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; +import { Modal, Platform, Pressable, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type RenameOrgModalProps = { + defaultName: string; + onSubmit: (name: string) => void; + onClose: () => void; +}; + +export function RenameOrgModal({ defaultName, onSubmit, onClose }: Readonly) { + const colors = useThemeColors(); + const nameRef = useRef(defaultName); + const inputRef = useRef(null); + + // autoFocus doesn't reliably raise the keyboard inside Modal on Android + useEffect(() => { + if (Platform.OS !== 'android') { + return undefined; + } + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 100); + return () => { + clearTimeout(timer); + }; + }, []); + + return ( + + + + { + e.stopPropagation(); + }} + > + Rename Organization + { + nameRef.current = val; + }} + autoFocus={Platform.OS !== 'android'} + maxLength={50} + /> + + + + + + + + ); +} diff --git a/apps/mobile/src/components/profile-action-tile.tsx b/apps/mobile/src/components/profile-action-tile.tsx new file mode 100644 index 0000000000..d874c0a1a5 --- /dev/null +++ b/apps/mobile/src/components/profile-action-tile.tsx @@ -0,0 +1,35 @@ +import { Pressable } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +export function ActionTile({ + icon: Icon, + label, + color, + onPress, + destructive, + disabled, +}: { + icon: React.ComponentType<{ size?: number; color?: string }>; + label: string; + color: string; + onPress: () => void; + destructive?: boolean; + disabled?: boolean; +}) { + return ( + + + + {label} + + + ); +} diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index 5cd6dfc67a..23e1ae465a 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import * as Application from 'expo-application'; import { type Href, useRouter } from 'expo-router'; import { + Building2, GitPullRequest, KeyRound, LifeBuoy, @@ -17,6 +18,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanim import { RestorePurchasesButton } from '@/components/kilo-pass/restore-purchases-button'; import { NotificationsCard } from '@/components/notifications-card'; +import { ActionTile } from '@/components/profile-action-tile'; import { CreditsCard } from '@/components/profile-credits-card'; import { ScreenHeader } from '@/components/screen-header'; import { ConfigureRow } from '@/components/ui/configure-row'; @@ -37,38 +39,6 @@ function providerIcon(_provider: string) { return KeyRound; } -function ActionTile({ - icon: Icon, - label, - color, - onPress, - destructive, - disabled, -}: { - icon: React.ComponentType<{ size?: number; color?: string }>; - label: string; - color: string; - onPress: () => void; - destructive?: boolean; - disabled?: boolean; -}) { - return ( - - - - {label} - - - ); -} - export function ProfileScreen() { const { signOut, token } = useAuth(); const router = useRouter(); @@ -92,6 +62,9 @@ export function ProfileScreen() { const agentScope = organizationContextLoaded ? getProfileAgentScope(organizationId, orgs, organizationsFetching) : undefined; + const selectedOrg = orgs?.find(org => org.organizationId === organizationId); + const orgRole = selectedOrg?.role; + const orgName = selectedOrg?.organizationName; const { userId } = useCurrentUserId({ enabled: isAuthenticated }); @@ -201,6 +174,26 @@ export function ProfileScreen() { /> + {/* Organization */} + {organizationId != null && ( + + + Organization + + { + router.push('/(app)/(tabs)/(3_profile)/organization' as Href); + }} + /> + + )} + {/* Linked accounts */} diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts new file mode 100644 index 0000000000..3d3f14871f --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts @@ -0,0 +1,191 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner-native'; + +import { + type OrgListEntry, + type OrgRole, + type OrgWithMembers, +} from '@/lib/hooks/use-organization-queries'; +import { trpcClient, useTRPC } from '@/lib/trpc'; + +const onMutationError = (error: { message: string }) => { + toast.error(error.message || 'Something went wrong'); +}; + +export function useOrganizationMutations(organizationId: string) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const withMembersKey = trpc.organizations.withMembers.queryKey({ organizationId }); + const listKey = trpc.organizations.list.queryKey(); + + const invalidateAll = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: withMembersKey }), + queryClient.invalidateQueries({ queryKey: listKey }), + ]); + }; + + const invalidateWithMembers = async () => { + await queryClient.invalidateQueries({ queryKey: withMembersKey }); + }; + + // Every optimistic mutation here only touches the withMembers cache, so the + // key is fixed rather than threaded through like use-kiloclaw-mutations.ts + // (which juggles many caches across a personal/org split). + function optimistic(updater: (old: OrgWithMembers, input: TInput) => OrgWithMembers) { + return { + onMutate: async (input: TInput) => { + await queryClient.cancelQueries({ queryKey: withMembersKey }); + const previous = queryClient.getQueryData(withMembersKey); + queryClient.setQueryData(withMembersKey, old => + old ? updater(old, input) : old + ); + return { previous }; + }, + onError: ( + error: { message: string }, + _input: TInput, + context?: { previous?: OrgWithMembers } + ) => { + if (context?.previous) { + queryClient.setQueryData(withMembersKey, context.previous); + } + onMutationError(error); + }, + onSettled: invalidateAll, + }; + } + + return { + rename: useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (input: { name: string }) => + trpcClient.organizations.update.mutate({ organizationId, name: input.name }), + onMutate: async (input: { name: string }) => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: withMembersKey }), + queryClient.cancelQueries({ queryKey: listKey }), + ]); + const previousWithMembers = queryClient.getQueryData(withMembersKey); + const previousList = queryClient.getQueryData(listKey); + queryClient.setQueryData(withMembersKey, old => + old ? { ...old, name: input.name } : old + ); + queryClient.setQueryData(listKey, old => + old + ? old.map(entry => + entry.organizationId === organizationId + ? { ...entry, organizationName: input.name } + : entry + ) + : old + ); + return { previousWithMembers, previousList }; + }, + onError: ( + error: { message: string }, + _input, + context?: { previousWithMembers?: OrgWithMembers; previousList?: OrgListEntry[] } + ) => { + if (context?.previousWithMembers) { + queryClient.setQueryData(withMembersKey, context.previousWithMembers); + } + if (context?.previousList) { + queryClient.setQueryData(listKey, context.previousList); + } + onMutationError(error); + }, + onSettled: invalidateAll, + }), + + invite: useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (input: { email: string; role: OrgRole }) => + trpcClient.organizations.members.invite.mutate({ organizationId, ...input }), + onSuccess: invalidateWithMembers, + onError: onMutationError, + onSettled: invalidateAll, + }), + + updateMember: useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (input: { + memberId: string; + role?: OrgRole; + dailyUsageLimitUsd?: number | null; + }) => trpcClient.organizations.members.update.mutate({ organizationId, ...input }), + ...optimistic<{ memberId: string; role?: OrgRole; dailyUsageLimitUsd?: number | null }>( + (old, input) => ({ + ...old, + members: old.members.map(member => + member.status === 'active' && member.id === input.memberId + ? { + ...member, + ...(input.role !== undefined && { role: input.role }), + ...(input.dailyUsageLimitUsd !== undefined && { + dailyUsageLimitUsd: input.dailyUsageLimitUsd, + }), + } + : member + ), + }) + ), + }), + + removeMember: useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (input: { memberId: string }) => + trpcClient.organizations.members.remove.mutate({ organizationId, ...input }), + ...optimistic<{ memberId: string }>((old, input) => ({ + ...old, + members: old.members.filter( + member => !(member.status === 'active' && member.id === input.memberId) + ), + })), + }), + + deleteInvite: useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (input: { inviteId: string }) => + trpcClient.organizations.members.deleteInvite.mutate({ organizationId, ...input }), + ...optimistic<{ inviteId: string }>((old, input) => ({ + ...old, + members: old.members.filter( + member => !(member.status === 'invited' && member.inviteId === input.inviteId) + ), + })), + }), + + updateMinimumBalanceAlert: useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mutationFn: (input: { + enabled: boolean; + minimum_balance?: number; + minimum_balance_alert_email?: string[]; + }) => + trpcClient.organizations.settings.updateMinimumBalanceAlert.mutate({ + organizationId, + ...input, + }), + ...optimistic<{ + enabled: boolean; + minimum_balance?: number; + minimum_balance_alert_email?: string[]; + }>((old, input) => { + if (input.enabled) { + return { + ...old, + settings: { + ...old.settings, + minimum_balance: input.minimum_balance, + minimum_balance_alert_email: input.minimum_balance_alert_email, + }, + }; + } + const { minimum_balance: _mb, minimum_balance_alert_email: _mbae, ...rest } = old.settings; + return { ...old, settings: rest }; + }), + }), + }; +} diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts new file mode 100644 index 0000000000..e637081866 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useAuth } from '@/lib/auth/auth-context'; +import { useOrganization } from '@/lib/organization-context'; +import { useTRPC } from '@/lib/trpc'; + +/** + * The current user's role in the active organization. `trpc.organizations.list` + * requires auth (not an active org selection), so it's gated on the token + * rather than on `organizationId` — mirrors profile-screen's `orgs` query. + */ +export function useOrgRole() { + const trpc = useTRPC(); + const { token } = useAuth(); + const { organizationId } = useOrganization(); + const { data: orgs, isLoading } = useQuery({ + ...trpc.organizations.list.queryOptions(), + enabled: token != null, + }); + const org = orgs?.find(entry => entry.organizationId === organizationId); + return { organizationId, role: org?.role, org, isLoading }; +} + +export type OrgListEntry = NonNullable['org']>; +export type OrgRole = OrgListEntry['role']; + +export function isMoneyRole(role: OrgRole | undefined): boolean { + return role === 'owner' || role === 'billing_manager'; +} + +export function useOrgWithMembers(organizationId: string | null) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.withMembers.queryOptions( + { organizationId: organizationId ?? '' }, + { enabled: organizationId != null } + ) + ); +} + +export type OrgWithMembers = NonNullable['data']>; +export type OrgMember = OrgWithMembers['members'][number]; +export type ActiveOrgMember = Extract; +export type InvitedOrgMember = Extract; + +export function useOrgUsageStats(organizationId: string | null) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.usageStats.queryOptions( + { organizationId: organizationId ?? '' }, + { enabled: organizationId != null } + ) + ); +} + +export function useOrgCreditTransactions(organizationId: string | null) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.creditTransactions.queryOptions( + { organizationId: organizationId ?? '' }, + { enabled: organizationId != null } + ) + ); +} + +export type CreditTransaction = NonNullable< + ReturnType['data'] +>[number]; + +export function useOrgInvoices(organizationId: string | null) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.invoices.queryOptions( + { organizationId: organizationId ?? '', period: 'year' }, + { enabled: organizationId != null } + ) + ); +} + +export type OrgInvoice = NonNullable['data']>[number]; diff --git a/apps/mobile/src/lib/utils.ts b/apps/mobile/src/lib/utils.ts index 86adc9e8a2..c503ec76af 100644 --- a/apps/mobile/src/lib/utils.ts +++ b/apps/mobile/src/lib/utils.ts @@ -5,6 +5,8 @@ function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +const EMAIL_PATTERN = /.+@.+\..+/; + /** * Parse a Drizzle `mode: 'string'` timestamp into a Date. * Hermes can't parse PostgreSQL's default format (`2026-03-13 14:30:00+00`), @@ -64,4 +66,4 @@ function firstNonEmpty(...values: (string | null | undefined)[]): string { return ''; } -export { asyncNoop, cn, firstNonEmpty, parseTimestamp, timeAgo }; +export { asyncNoop, cn, EMAIL_PATTERN, firstNonEmpty, parseTimestamp, timeAgo };