diff --git a/package-lock.json b/package-lock.json index 46c7a3c..2c00284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@expo/vector-icons": "^14.0.0", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-menu/menu": "^1.1.6", + "@react-native-picker/picker": "^2.10.2", "@react-navigation/bottom-tabs": "^6.5.8", "@react-navigation/native": "^6.1.10", "@react-navigation/native-stack": "^6.9.13", @@ -5431,6 +5432,18 @@ "react-native": "*" } }, + "node_modules/@react-native-picker/picker": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.10.2.tgz", + "integrity": "sha512-kr3OvCRwTYjR/OKlb52k4xmQVU7dPRIALqpyiihexdJxEgvc1smnepgqCeM9oXmNSG4YaV5/RSxFlLC5Z/T/Eg==", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.75.4", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.75.4.tgz", diff --git a/package.json b/package.json index f36c176..65de1a4 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,12 @@ "@expo/react-native-action-sheet": "^4.1.0", "@expo/vector-icons": "^14.0.0", "@react-native-async-storage/async-storage": "1.23.1", - "@react-native-menu/menu": "^1.2.0", - "@react-navigation/bottom-tabs": "^7.2.0", - "@react-navigation/native": "^7.0.14", - "@react-navigation/native-stack": "^7.2", - "date-fns": "^4.1.0", + "@react-native-menu/menu": "^1.1.6", + "@react-native-picker/picker": "2.7.5", + "@react-navigation/bottom-tabs": "^6.5.8", + "@react-navigation/native": "^6.1.10", + "@react-navigation/native-stack": "^6.9.13", + "date-fns": "^2.30.0", "eas-cli": "^13.4.2", "expo": "~51.0.39", "expo-alternate-app-icons": "^1.1.0", @@ -32,32 +33,33 @@ "expo-constants": "~16.0.2", "expo-crypto": "~13.0.2", "expo-dev-client": "~4.0.29", - "expo-device": "~7.0.1", + "expo-device": "~6.0.2", "expo-haptics": "~13.0.1", "expo-image": "~1.13.0", "expo-image-picker": "~15.0.7", - "expo-insights": "~0.8.1", + "expo-insights": "~0.7.0", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-local-authentication": "~14.0.1", - "expo-notifications": "~0.29.11", - "expo-secure-store": "~14.0.0", + "expo-notifications": "^0.28.18", + "expo-secure-store": "~13.0.2", "expo-splash-screen": "~0.27.7", "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", - "expo-web-browser": "~14.0.1", + "expo-web-browser": "~13.0.3", "geopattern": "github:thedev132/geopattern", "humanize-string": "^3.0.0", "ky": "^1.2.3", "lodash": "^4.17.21", - "react": "18.3.1", - "react-native": "0.75", - "react-native-gesture-handler": "~2.21.2", + "react": "18.2.0", + "react-native": "0.74.5", + "react-native-gesture-handler": "~2.16.1", "react-native-image-viewing": "^0.2.2", - "react-native-pager-view": "6.5.1", - "react-native-reanimated": "~3.16.3", + "react-native-pager-view": "6.3.0", + "react-native-picker-select": "^9.3.1", + "react-native-reanimated": "~3.10.1", "react-native-safe-area-context": "4.10.5", - "react-native-screens": "4.4.0", + "react-native-screens": "3.31.1", "react-native-svg": "15.2.0", "react-native-web": "~0.19.10", "swr": "^2.2.1", diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 0ea9527..58d4950 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -23,6 +23,7 @@ import InvitationPage from "./pages/Invitation"; import OrganizationPage from "./pages/organization"; import AccountNumberPage from "./pages/organization/AccountNumber"; import OrganizationSettingsPage from "./pages/organization/Settings"; +import TransferPage from "./pages/organization/transfer"; import ReceiptsPage from "./pages/Receipts"; import RenameTransactionPage from "./pages/RenameTransaction"; import SettingsPage from "./pages/Settings"; @@ -69,6 +70,7 @@ export default function Navigator() { // headerStyle: { backgroundColor: themeColors.background }, headerShown: false, tabBarStyle: { position: "absolute" }, + tabBarHideOnKeyboard: true, tabBarBackground: () => ( + )} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 1818dd5..3aa6454 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -15,6 +15,7 @@ export interface ButtonProps { onPress?: () => void; color?: string; loading?: boolean; + disabled?: boolean; icon?: React.ComponentProps["name"]; } @@ -45,9 +46,13 @@ export default function Button( ) { return ( props.onPress && props.onPress()} - disabled={props.loading} + disabled={props.loading || props.disabled} > {props.icon && ( { + const [amount, setAmount] = useState('$0.00'); + const [chosenOrg, setOrganization] = useState(''); + const [reason, setReason] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { colors: themeColors } = useTheme(); + const { data: organizations } = useSWR('user/organizations'); + const { token } = useContext(AuthContext); + + const validateInputs = () => { + const numericAmount = Number(amount.replace('$', '').replace(',', '')); + if (!chosenOrg) { + Alert.alert('Error', 'Please select an organization to transfer to.'); + return false; + } + if (numericAmount <= 0 || isNaN(numericAmount)) { + Alert.alert('Error', 'Please enter a valid amount greater than $0.'); + return false; + } + if (numericAmount * 100 > organization.balance_cents) { + Alert.alert('Error', 'Insufficient balance for this transfer.'); + return false; + } + if (!reason.trim()) { + Alert.alert('Error', 'Please provide a reason for the transfer.'); + return false; + } + return true; + }; + + const handleTransfer = async () => { + if (!validateInputs()) return; + + setIsLoading(true); + try { + const response = await fetch(process.env.EXPO_PUBLIC_API_BASE + `/organizations/${organization.id}/transfers`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event_id: organization.id, + to_organization_id: chosenOrg, + amount_cents: Number(amount.replace('$', '').replace(',', '')) * 100, + name: reason, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + Alert.alert('Error', errorData.message || 'Failed to complete the transfer. Please try again.'); + } else { + Alert.alert('Success', 'Transfer completed successfully!'); + setOrganization(''); + setAmount('$0.00'); + setReason(''); + } + } catch (error) { + Alert.alert('Error', 'An unexpected error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (chosenOrg === '') { + setAmount('$0.00'); + } + }, [chosenOrg]); + + if (!organizations) { + return ( + + + + ); + } + + return ( + + {/* From Section */} + From + + + {organization.name} ({renderMoney(organization.balance_cents)}) + + + + {/* To Section */} + To + + setOrganization(itemValue)} + style={{ inputIOS: { color: themeColors.text, padding: 15, fontSize: 16 }, inputAndroid: { color: themeColors.text, padding: 15, fontSize: 16 } }} + items={[ + ...organizations.map((org) => ({ label: org.name, value: org.id })), + ]} + /> + + + You can transfer to any organization you're a part of. + + + {/* Amount Section */} + Amount + { + const sanitizedText = text.replace(/[^\d.]/g, ''); + // remove 0.00 if user enters a new number + if (sanitizedText.startsWith('0.00')) { + setAmount(text.replace('0.00', '')); + return; + } + setAmount(sanitizedText ? `$${sanitizedText}` : '$0.00'); + }} + placeholder="$0.00" + placeholderTextColor={themeColors.text} + keyboardType="numeric" + /> + + {/* Purpose Section */} + What is the transfer for? + setReason(text)} + placeholder="Donating extra funds to another organization" + placeholderTextColor={palette.muted} + /> + + This is to help HCB keep record of our transactions. + + + {/* Transfer Button */} + + {isLoading ? ( + + ) : ( + Make Transfer + )} + + + ); +}; + +export default DisbursementScreen; diff --git a/src/lib/NavigatorParamList.ts b/src/lib/NavigatorParamList.ts index 83f35ca..6147a1f 100644 --- a/src/lib/NavigatorParamList.ts +++ b/src/lib/NavigatorParamList.ts @@ -17,6 +17,7 @@ export type StackParamList = { transaction?: Transaction; }; RenameTransaction: { orgId: string; transaction: Transaction }; + Transfer: { organization: Organization }; }; export type CardsStackParamList = { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 5ba839d..23a36cb 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -231,6 +231,10 @@ export default function App({ navigation }: Props) { preload("user", fetcher!); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion preload("user/cards", fetcher!); + // prefetch all user organization details + for (const org of organizations || []) { + preload(`organizations/${org.id}`, fetcher!); + } }, []); const onRefresh = () => { diff --git a/src/pages/organization/index.tsx b/src/pages/organization/index.tsx index a5eb9a1..1918278 100644 --- a/src/pages/organization/index.tsx +++ b/src/pages/organization/index.tsx @@ -70,6 +70,8 @@ export default function OrganizationPage({ const { data: organization, isLoading: organizationLoading } = useSWR< Organization | OrganizationExpanded >(`organizations/${orgId}`, { fallbackData: _organization }); + + const { data: user, isLoading: userLoading } = useSWR("user"); const { transactions: _transactions, isLoadingMore, @@ -79,7 +81,12 @@ export default function OrganizationPage({ const [refreshing, setRefreshing] = useState(false); useEffect(() => { - if (organization) { + if (organization && user) { + const isManager = "users" in organization && + organization.users.some( + (u) => u.id === user?.id && u.role === "manager", + ); + navigation.setOptions({ title: organization.name, // headerTitle: () => , @@ -97,12 +104,21 @@ export default function OrganizationPage({ }); } + if (isManager) { + menuActions.push({ + id: "transfer", + title: "Transfer Money", + image: "dollarsign.circle", + }); + } + menuActions.push({ id: "settings", title: "Manage Organization", image: "gearshape", }); + navigation.setOptions({ headerRight: () => ( @@ -131,7 +149,7 @@ export default function OrganizationPage({ ), }); } - }, [organization, scheme, navigation]); + }, [organization, scheme, navigation, user]); const tabBarSize = useBottomTabBarHeight(); const { colors: themeColors } = useTheme(); @@ -153,13 +171,13 @@ export default function OrganizationPage({ })), [transactions], ); - + const onRefresh = () => { mutate("organizations"); mutate(`organizations/${orgId}`); } - if (organizationLoading) { + if (organizationLoading || userLoading) { return ; } @@ -188,33 +206,47 @@ export default function OrganizationPage({ {organization?.playground_mode && ( )} - - + + + Balance + + + {"balance_cents" in organization && + renderMoney(organization.balance_cents)} + + + {/* */} - {/* - - Transactions - - */} + {isLoading && } )} diff --git a/src/pages/organization/transfer.tsx b/src/pages/organization/transfer.tsx new file mode 100644 index 0000000..f4ceb30 --- /dev/null +++ b/src/pages/organization/transfer.tsx @@ -0,0 +1,77 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import Constants from "expo-constants"; +import { useEffect, useState } from "react"; +import { KeyboardAvoidingView, Button as NativeButton, View, ScrollView, Platform } from "react-native"; + +import DisbursementScreen from "../../components/organizations/transfer/Disbursement"; +import { StackParamList } from "../../lib/NavigatorParamList"; +import { OrganizationExpanded } from "../../lib/types/Organization"; +import { palette } from "../../theme"; + +type Props = NativeStackScreenProps; + +export default function TransferPage({ navigation, route }: Props) { + const { organization } = route.params as { organization: OrganizationExpanded }; // Grab the organization value from the route params + + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + + navigation.goBack()} + /> + + ), + }); + }, []); + + const [transferType, setTransferType] = useState<"ach" | "check" | "hcb">("hcb"); + + return ( + + + {/* Transfer Type Buttons */} + {/* + + + + */} + + {/* Display transfer screen based on transfer type */} + {transferType === "hcb" && } + + + ); +}