From db2ec45f5a6c9af0ec36c7f006f72ad5e8779944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 13:45:31 +0200 Subject: [PATCH 01/13] feat(mobile): add organization query and mutation hooks Data layer for upcoming org-management screens (hub, members, credit activity, invoices): thin tRPC query hooks plus optimistic mutations for renaming an org, inviting/updating/removing members, deleting invites, and toggling the minimum balance alert. --- .../lib/hooks/use-organization-mutations.ts | 191 ++++++++++++++++++ .../src/lib/hooks/use-organization-queries.ts | 60 ++++++ 2 files changed, 251 insertions(+) create mode 100644 apps/mobile/src/lib/hooks/use-organization-mutations.ts create mode 100644 apps/mobile/src/lib/hooks/use-organization-queries.ts 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..1339ac05fe --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -0,0 +1,60 @@ +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) { + const trpc = useTRPC(); + return useQuery(trpc.organizations.withMembers.queryOptions({ organizationId })); +} + +export type OrgWithMembers = NonNullable['data']>; +export type OrgMember = OrgWithMembers['members'][number]; +export type ActiveOrgMember = Extract; +export type InvitedOrgMember = Extract; + +export function useOrgUsageStats(organizationId: string) { + const trpc = useTRPC(); + return useQuery(trpc.organizations.usageStats.queryOptions({ organizationId })); +} + +export function useOrgCreditTransactions(organizationId: string) { + const trpc = useTRPC(); + return useQuery(trpc.organizations.creditTransactions.queryOptions({ organizationId })); +} + +export type CreditTransaction = NonNullable< + ReturnType['data'] +>[number]; + +export function useOrgInvoices(organizationId: string) { + const trpc = useTRPC(); + return useQuery(trpc.organizations.invoices.queryOptions({ organizationId, period: 'year' })); +} + +export type OrgInvoice = NonNullable['data']>[number]; From c87c2eac5138e40935c5437e1cdb8faf7319afa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 13:57:44 +0200 Subject: [PATCH 02/13] feat(mobile): organization hub screen Adds the org hub route and its screen: info card (name + rename, balance for money roles, seats), last-30-days usage stats, and nav rows to members/credit-activity/invoices/low-balance-alert. Consumes the Task 1 org data hooks; target nav routes are stubs for later tasks. --- .../(3_profile)/organization/_layout.tsx | 7 + .../(tabs)/(3_profile)/organization/index.tsx | 5 + .../components/organization/hub-screen.tsx | 145 ++++++++++++++++++ .../organization/org-usage-stats.tsx | 64 ++++++++ .../organization/rename-org-modal.tsx | 75 +++++++++ 5 files changed, 296 insertions(+) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx create mode 100644 apps/mobile/src/components/organization/hub-screen.tsx create mode 100644 apps/mobile/src/components/organization/org-usage-stats.tsx create mode 100644 apps/mobile/src/components/organization/rename-org-modal.tsx 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..976fc32b00 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx @@ -0,0 +1,7 @@ +import { Stack } from 'expo-router'; + +// formSheet screens (rename, invite, etc.) get registered here in a later +// task — see src/app/(app)/_layout.tsx for the Android fullSheetDetent pattern. +export default function OrganizationLayout() { + 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/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx new file mode 100644 index 0000000000..433388fc8c --- /dev/null +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -0,0 +1,145 @@ +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} + + {role === 'owner' && ( + { + 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/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} + /> + + + + + + + + ); +} From 19d0f9e9a865583c6306a75010e05f21a2be82da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 14:06:23 +0200 Subject: [PATCH 03/13] fix(mobile): gate org-scoped queries on a resolved org id useOrgWithMembers/useOrgUsageStats/useOrgCreditTransactions/useOrgInvoices now accept organizationId: string | null and disable the query until it resolves, instead of firing with an empty-string id that the backend rejects. hub-screen.tsx passes organizationId through directly. Also default the org info-card skeleton to the 2-row shape since role (and therefore row count) isn't known while loading. --- .../components/organization/hub-screen.tsx | 3 +- .../src/lib/hooks/use-organization-queries.ts | 36 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index 433388fc8c..804361d449 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -21,7 +21,6 @@ function InfoCardSkeleton() { - ); } @@ -30,7 +29,7 @@ export function OrganizationHubScreen() { const router = useRouter(); const colors = useThemeColors(); const { organizationId, role, org, isLoading } = useOrgRole(); - const orgWithMembers = useOrgWithMembers(organizationId ?? ''); + const orgWithMembers = useOrgWithMembers(organizationId); const mutations = useOrganizationMutations(organizationId ?? ''); const [renameVisible, setRenameVisible] = useState(false); diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts index 1339ac05fe..e637081866 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -28,9 +28,14 @@ export function isMoneyRole(role: OrgRole | undefined): boolean { return role === 'owner' || role === 'billing_manager'; } -export function useOrgWithMembers(organizationId: string) { +export function useOrgWithMembers(organizationId: string | null) { const trpc = useTRPC(); - return useQuery(trpc.organizations.withMembers.queryOptions({ organizationId })); + return useQuery( + trpc.organizations.withMembers.queryOptions( + { organizationId: organizationId ?? '' }, + { enabled: organizationId != null } + ) + ); } export type OrgWithMembers = NonNullable['data']>; @@ -38,23 +43,38 @@ export type OrgMember = OrgWithMembers['members'][number]; export type ActiveOrgMember = Extract; export type InvitedOrgMember = Extract; -export function useOrgUsageStats(organizationId: string) { +export function useOrgUsageStats(organizationId: string | null) { const trpc = useTRPC(); - return useQuery(trpc.organizations.usageStats.queryOptions({ organizationId })); + return useQuery( + trpc.organizations.usageStats.queryOptions( + { organizationId: organizationId ?? '' }, + { enabled: organizationId != null } + ) + ); } -export function useOrgCreditTransactions(organizationId: string) { +export function useOrgCreditTransactions(organizationId: string | null) { const trpc = useTRPC(); - return useQuery(trpc.organizations.creditTransactions.queryOptions({ organizationId })); + return useQuery( + trpc.organizations.creditTransactions.queryOptions( + { organizationId: organizationId ?? '' }, + { enabled: organizationId != null } + ) + ); } export type CreditTransaction = NonNullable< ReturnType['data'] >[number]; -export function useOrgInvoices(organizationId: string) { +export function useOrgInvoices(organizationId: string | null) { const trpc = useTRPC(); - return useQuery(trpc.organizations.invoices.queryOptions({ organizationId, period: 'year' })); + return useQuery( + trpc.organizations.invoices.queryOptions( + { organizationId: organizationId ?? '', period: 'year' }, + { enabled: organizationId != null } + ) + ); } export type OrgInvoice = NonNullable['data']>[number]; From 06b4161cdca76185b3e4eb9d1c505f79864dbc30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 14:10:45 +0200 Subject: [PATCH 04/13] feat(mobile): add org entry row to profile screen Extract ActionTile into its own file to keep profile-screen.tsx under the 300-line lint ceiling, then add a Manage/View org row that links to the organization hub screen added in Task 2. --- .../src/components/profile-action-tile.tsx | 35 ++++++++++++ apps/mobile/src/components/profile-screen.tsx | 57 ++++++++----------- 2 files changed, 60 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/components/profile-action-tile.tsx 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..3eca91b04b 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 */} From 7e0d8f22142a1cde99c9554c9e06a5cc227a70d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 14:22:50 +0200 Subject: [PATCH 05/13] feat(mobile): add organization members screen Builds the members drill-in linked from the org hub: active-member rows with role/limit/remove management for owners, invited-member rows with share/revoke actions, sorted lists, and an invite-member entry point in the header for owner/billing roles. --- .../(3_profile)/organization/members.tsx | 5 + .../organization/invited-member-row.tsx | 114 +++++++++++++ .../components/organization/member-row.tsx | 161 ++++++++++++++++++ .../organization/members-screen.tsx | 148 ++++++++++++++++ 4 files changed, 428 insertions(+) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx create mode 100644 apps/mobile/src/components/organization/invited-member-row.tsx create mode 100644 apps/mobile/src/components/organization/member-row.tsx create mode 100644 apps/mobile/src/components/organization/members-screen.tsx 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/invited-member-row.tsx b/apps/mobile/src/components/organization/invited-member-row.tsx new file mode 100644 index 0000000000..77050a8216 --- /dev/null +++ b/apps/mobile/src/components/organization/invited-member-row.tsx @@ -0,0 +1,114 @@ +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, type OrgRole } from '@/lib/hooks/use-organization-queries'; +import { cn, parseTimestamp } from '@/lib/utils'; + +type InvitedMemberRowProps = { + invite: InvitedOrgMember; + /** Caller is owner. */ + canManage: boolean; + organizationId: string; + /** Suppress bottom divider on the last row of a group. */ + last?: boolean; +}; + +const ROLE_LABEL: Record = { + owner: 'Owner', + member: 'Member', + billing_manager: 'Billing manager', +}; + +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/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx new file mode 100644 index 0000000000..26f9cfcffc --- /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; +}; + +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) => ( + + ))} + + + )} + + + ); +} From 9b950a9eb01806eb1f6a2cc5219988f211607d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 14:33:36 +0200 Subject: [PATCH 06/13] feat(mobile): add organization invite/limit/low-balance form sheets Register invite-member, member-limit, and low-balance-alert as formSheet routes on the organization stack, and build their sheet content. --- .../(3_profile)/organization/_layout.tsx | 28 +++- .../organization/invite-member.tsx | 5 + .../organization/low-balance-alert.tsx | 5 + .../(3_profile)/organization/member-limit.tsx | 8 + .../organization/invite-member-sheet.tsx | 113 +++++++++++++ .../organization/low-balance-alert-sheet.tsx | 156 ++++++++++++++++++ .../organization/member-limit-sheet.tsx | 120 ++++++++++++++ .../components/organization/member-row.tsx | 2 +- 8 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx create mode 100644 apps/mobile/src/components/organization/invite-member-sheet.tsx create mode 100644 apps/mobile/src/components/organization/low-balance-alert-sheet.tsx create mode 100644 apps/mobile/src/components/organization/member-limit-sheet.tsx 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 index 976fc32b00..c06b11e953 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx @@ -1,7 +1,29 @@ import { Stack } from 'expo-router'; +import { Platform, StatusBar, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; -// formSheet screens (rename, invite, etc.) get registered here in a later -// task — see src/app/(app)/_layout.tsx for the Android fullSheetDetent pattern. +// 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() { - return ; + 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/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/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/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx new file mode 100644 index 0000000000..f2e563737e --- /dev/null +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -0,0 +1,113 @@ +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 } from '@/lib/utils'; + +const EMAIL_PATTERN = /.+@.+\..+/; +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 && } + + ); + })} + + + )} + + + + ); +} 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..5a10e6bf06 --- /dev/null +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -0,0 +1,156 @@ +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 { Text } from '@/components/ui/text'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations'; +import { useOrgRole, useOrgWithMembers } from '@/lib/hooks/use-organization-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +const EMAIL_PATTERN = /.+@.+\..+/; + +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 !== ''); +} + +export function LowBalanceAlertSheet() { + const router = useRouter(); + const colors = useThemeColors(); + const { organizationId } = useOrgRole(); + const orgWithMembers = useOrgWithMembers(organizationId); + const mutations = useOrganizationMutations(organizationId ?? ''); + const { email: myEmail } = useCurrentUserId(); + const settings = orgWithMembers.data?.settings; + + 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( + !enabled || + (parseThreshold(thresholdRef.current) != null && + parseEmails(emailsRef.current).some(email => EMAIL_PATTERN.test(email))) + ); + + const revalidate = (nextEnabled: boolean, thresholdValue: string, emailsValue: string) => { + setCanSave( + !nextEnabled || + (parseThreshold(thresholdValue) != null && + parseEmails(emailsValue).some(email => EMAIL_PATTERN.test(email))) + ); + }; + + const onSave = () => { + const threshold = parseThreshold(thresholdRef.current); + const emails = parseEmails(emailsRef.current).filter(email => EMAIL_PATTERN.test(email)); + if (enabled && (threshold == null || emails.length === 0)) { + 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 ( + + Low balance alert + + + 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. + + + + )} + + + + ); +} 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..80a24ddf4d --- /dev/null +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -0,0 +1,120 @@ +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 { 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; +} + +export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { + const router = useRouter(); + const colors = useThemeColors(); + const { organizationId } = useOrgRole(); + const orgWithMembers = useOrgWithMembers(organizationId); + const mutations = useOrganizationMutations(organizationId ?? ''); + const member = orgWithMembers.data?.members.find( + (m): m is ActiveOrgMember => m.status === 'active' && m.id === memberId + ); + const currentLimit = member?.dailyUsageLimitUsd ?? null; + + 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 }); + }; + + if (!member) { + return ; + } + + return ( + + + Daily usage limit + + {firstNonEmpty(member.name, member.email)} + + + + + + Limit (USD per day) + + { + limitRef.current = value; + setCanSave(isValidLimit(value)); + }} + /> + + + + + {currentLimit != null && ( + + )} + + ); +} diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx index 26f9cfcffc..afe92935fd 100644 --- a/apps/mobile/src/components/organization/member-row.tsx +++ b/apps/mobile/src/components/organization/member-row.tsx @@ -19,7 +19,7 @@ type MemberRowProps = { last?: boolean; }; -const ROLE_LABEL: Record = { +export const ROLE_LABEL: Record = { owner: 'Owner', member: 'Member', billing_manager: 'Billing manager', From 8a84586005df20a0cdb05e3ba6c1809918f64562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 14:41:56 +0200 Subject: [PATCH 07/13] fix(mobile): handle loading/missing states and validate emails in org sheets Member-limit sheet now shows a skeleton while loading and a "Member not found" message instead of a blank sheet. Low-balance-alert sheet defers mounting its form until settings load, so its enabled/threshold state isn't seeded from stale data. Shares one EMAIL_PATTERN in lib/utils instead of duplicating it, and blocks saving when any email in the list is malformed instead of silently dropping it. --- .../organization/invite-member-sheet.tsx | 3 +- .../organization/low-balance-alert-sheet.tsx | 83 +++++++++++++------ .../organization/member-limit-sheet.tsx | 21 ++++- apps/mobile/src/lib/utils.ts | 4 +- 4 files changed, 82 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx index f2e563737e..ac94265fe6 100644 --- a/apps/mobile/src/components/organization/invite-member-sheet.tsx +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -10,9 +10,8 @@ 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 } from '@/lib/utils'; +import { cn, EMAIL_PATTERN } from '@/lib/utils'; -const EMAIL_PATTERN = /.+@.+\..+/; const INVITABLE_ROLES: OrgRole[] = ['member', 'billing_manager', 'owner']; export function InviteMemberSheet() { diff --git a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx index 5a10e6bf06..7afe319897 100644 --- a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx +++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx @@ -4,13 +4,17 @@ 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 { useOrgRole, useOrgWithMembers } from '@/lib/hooks/use-organization-queries'; +import { + type OrgWithMembers, + useOrgRole, + useOrgWithMembers, +} from '@/lib/hooks/use-organization-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -const EMAIL_PATTERN = /.+@.+\..+/; +import { EMAIL_PATTERN } from '@/lib/utils'; function parseThreshold(value: string): number | null { const trimmed = value.trim(); @@ -28,38 +32,46 @@ function parseEmails(value: string): string[] { .filter(email => email !== ''); } -export function LowBalanceAlertSheet() { +type LowBalanceAlertFormProps = Readonly<{ + organizationId: string | null; + settings: OrgWithMembers['settings']; +}>; + +function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormProps) { const router = useRouter(); const colors = useThemeColors(); - const { organizationId } = useOrgRole(); - const orgWithMembers = useOrgWithMembers(organizationId); const mutations = useOrganizationMutations(organizationId ?? ''); const { email: myEmail } = useCurrentUserId(); - const settings = orgWithMembers.data?.settings; - const [enabled, setEnabled] = useState(settings?.minimum_balance !== undefined); + const [enabled, setEnabled] = useState(settings.minimum_balance !== undefined); const thresholdRef = useRef( - settings?.minimum_balance != null ? String(settings.minimum_balance) : '' + settings.minimum_balance != null ? String(settings.minimum_balance) : '' ); - const emailsRef = useRef((settings?.minimum_balance_alert_email ?? []).join(', ')); - const [canSave, setCanSave] = useState( - !enabled || + const emailsRef = useRef((settings.minimum_balance_alert_email ?? []).join(', ')); + const [canSave, setCanSave] = useState(() => { + const emails = parseEmails(emailsRef.current); + return ( + !enabled || (parseThreshold(thresholdRef.current) != null && - parseEmails(emailsRef.current).some(email => EMAIL_PATTERN.test(email))) - ); + 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 && - parseEmails(emailsValue).some(email => EMAIL_PATTERN.test(email))) + emails.length > 0 && + emails.every(email => EMAIL_PATTERN.test(email))) ); }; const onSave = () => { const threshold = parseThreshold(thresholdRef.current); - const emails = parseEmails(emailsRef.current).filter(email => EMAIL_PATTERN.test(email)); - if (enabled && (threshold == null || emails.length === 0)) { + const emails = parseEmails(emailsRef.current); + if (enabled && (threshold == null || emails.length === 0 || !canSave)) { return; } mutations.updateMinimumBalanceAlert.mutate( @@ -79,14 +91,7 @@ export function LowBalanceAlertSheet() { }; return ( - - Low balance alert - + <> Enabled Save + + ); +} + +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 index 80a24ddf4d..49ecbba00b 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -4,6 +4,7 @@ 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 { @@ -63,8 +64,26 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { mutations.updateMember.mutate({ memberId, dailyUsageLimitUsd: null }, { onSuccess: onSaved }); }; + if (orgWithMembers.isLoading) { + return ( + + + + Daily usage limit + + + + + ); + } + if (!member) { - return ; + return ( + + Daily usage limit + Member not found + + ); } return ( 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 }; From e1c8bba29b131fb9e14475b5b6a13901b384a086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 14:48:23 +0200 Subject: [PATCH 08/13] feat(mobile): add organization credit activity and invoices screens --- .../organization/credit-activity.tsx | 5 + .../(3_profile)/organization/invoices.tsx | 5 + .../organization/credit-activity-screen.tsx | 103 ++++++++++++++++++ .../organization/invoices-screen.tsx | 103 ++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx create mode 100644 apps/mobile/src/components/organization/credit-activity-screen.tsx create mode 100644 apps/mobile/src/components/organization/invoices-screen.tsx 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/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/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/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={ + + } + /> + + )} + + ); +} From 36dce9df84060c922a49e12371ac118f760e8260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 15:10:18 +0200 Subject: [PATCH 09/13] fix(mobile): convert org balance from microdollars to dollars in hub screen organizations.list returns balance in microdollars (see organizations.ts's total_microdollars_acquired - microdollars_used), but the hub screen rendered it directly, showing $50000000.00 for a $50 org. --- apps/mobile/src/components/organization/hub-screen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index 804361d449..cf64f28789 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -74,7 +74,9 @@ export function OrganizationHubScreen() { )} - {showMoney && } + {showMoney && ( + + )} )} From 66457f89b9b95062838dfb9ff31d2ee9eb43a82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 15:25:46 +0200 Subject: [PATCH 10/13] fix(mobile): show mutation errors inline in org form sheets Toasts are invisible under iOS formSheet screens since they're native view controllers layered above the root layout where sonner-native mounts. Render the mutation error inline near the submit button instead so invite/limit/alert failures are visible while the sheet is open. The centralized onError toast stays for non-sheet callers. --- .../src/components/organization/invite-member-sheet.tsx | 4 ++++ .../src/components/organization/low-balance-alert-sheet.tsx | 6 ++++++ .../src/components/organization/member-limit-sheet.tsx | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx index ac94265fe6..bf93209677 100644 --- a/apps/mobile/src/components/organization/invite-member-sheet.tsx +++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx @@ -101,6 +101,10 @@ export function InviteMemberSheet() { )} + {mutations.invite.isError && ( + {mutations.invite.error.message} + )} + )} + + ); +} + +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)} + + + + ); } From ca5978cf354c15aaf3603bfed330b8511eb0d931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 10 Jul 2026 16:03:14 +0200 Subject: [PATCH 13/13] fix(mobile): allow billing managers to rename organization --- apps/mobile/src/components/organization/hub-screen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx index cf64f28789..d5c9b67581 100644 --- a/apps/mobile/src/components/organization/hub-screen.tsx +++ b/apps/mobile/src/components/organization/hub-screen.tsx @@ -60,7 +60,7 @@ export function OrganizationHubScreen() { {org.organizationName} - {role === 'owner' && ( + {showMoney && ( { setRenameVisible(true);