- BeneficiaryMenu: Navigate with ?edit=true param to open edit modal - Beneficiary index: Auto-open edit modal when edit=true in URL - Add loading indicator on Save button during edit save - Add "Uploading..." overlay on avatar during image upload
212 lines
6.3 KiB
TypeScript
212 lines
6.3 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { View, Text, TouchableOpacity, StyleSheet, Modal, Pressable } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { router } from 'expo-router';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
|
|
|
|
export type MenuItemId = 'dashboard' | 'edit' | 'access' | 'subscription' | 'sensors' | 'remove';
|
|
export type UserRole = 'custodian' | 'guardian' | 'caretaker';
|
|
|
|
interface MenuItem {
|
|
id: MenuItemId;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
label: string;
|
|
danger?: boolean;
|
|
}
|
|
|
|
// Permissions by role (Variant B)
|
|
// Custodian: all permissions
|
|
// Guardian: all except remove
|
|
// Caretaker: dashboard, edit, sensors only
|
|
const ROLE_PERMISSIONS: Record<UserRole, MenuItemId[]> = {
|
|
custodian: ['dashboard', 'edit', 'access', 'subscription', 'sensors', 'remove'],
|
|
guardian: ['dashboard', 'edit', 'access', 'subscription', 'sensors'],
|
|
caretaker: ['dashboard', 'edit', 'sensors'],
|
|
};
|
|
|
|
const ALL_MENU_ITEMS: MenuItem[] = [
|
|
{ id: 'dashboard', icon: 'grid-outline', label: 'Dashboard' },
|
|
{ id: 'edit', icon: 'create-outline', label: 'Edit' },
|
|
{ id: 'access', icon: 'share-outline', label: 'Access' },
|
|
{ id: 'subscription', icon: 'diamond-outline', label: 'Subscription' },
|
|
{ id: 'sensors', icon: 'hardware-chip-outline', label: 'Sensors' },
|
|
{ id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true },
|
|
];
|
|
|
|
interface BeneficiaryMenuProps {
|
|
beneficiaryId: string | number;
|
|
/** User's role for this beneficiary - determines available menu items */
|
|
userRole?: UserRole;
|
|
/** Which menu items to show. If not provided, shows all except current page */
|
|
visibleItems?: MenuItemId[];
|
|
/** Which menu item represents the current page (will be hidden) */
|
|
currentPage?: MenuItemId;
|
|
/** Custom handler for Edit action */
|
|
onEdit?: () => void;
|
|
/** Custom handler for Remove action */
|
|
onRemove?: () => void;
|
|
}
|
|
|
|
export function BeneficiaryMenu({
|
|
beneficiaryId,
|
|
userRole = 'caretaker', // Default to minimum permissions if not specified (security-first approach)
|
|
visibleItems,
|
|
currentPage,
|
|
onEdit,
|
|
onRemove,
|
|
}: BeneficiaryMenuProps) {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
const handleMenuAction = (itemId: MenuItemId) => {
|
|
setIsVisible(false);
|
|
|
|
switch (itemId) {
|
|
case 'dashboard':
|
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
|
break;
|
|
case 'edit':
|
|
if (onEdit) {
|
|
onEdit();
|
|
} else {
|
|
// Navigate to main page with edit=true param to open edit modal
|
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}?edit=true`);
|
|
}
|
|
break;
|
|
case 'access':
|
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/share`);
|
|
break;
|
|
case 'subscription':
|
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`);
|
|
break;
|
|
case 'sensors':
|
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`);
|
|
break;
|
|
case 'remove':
|
|
if (onRemove) {
|
|
onRemove();
|
|
} else {
|
|
// Navigate to main page
|
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Filter menu items based on:
|
|
// 1. User role permissions
|
|
// 2. Explicitly visible items (if provided)
|
|
// 3. Current page (hide it from menu)
|
|
const allowedByRole = ROLE_PERMISSIONS[userRole] || ROLE_PERMISSIONS.caretaker;
|
|
|
|
let menuItems = ALL_MENU_ITEMS.filter(item => allowedByRole.includes(item.id));
|
|
|
|
if (visibleItems) {
|
|
menuItems = menuItems.filter(item => visibleItems.includes(item.id));
|
|
}
|
|
|
|
if (currentPage) {
|
|
menuItems = menuItems.filter(item => item.id !== currentPage);
|
|
}
|
|
|
|
return (
|
|
<View>
|
|
<TouchableOpacity
|
|
style={styles.menuButton}
|
|
onPress={() => setIsVisible(!isVisible)}
|
|
>
|
|
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
<Modal
|
|
visible={isVisible}
|
|
transparent={true}
|
|
animationType="fade"
|
|
onRequestClose={() => setIsVisible(false)}
|
|
>
|
|
{/* Full screen backdrop */}
|
|
<Pressable
|
|
style={styles.modalBackdrop}
|
|
onPress={() => setIsVisible(false)}
|
|
>
|
|
{/* Menu positioned at top right */}
|
|
<Pressable
|
|
style={styles.dropdownMenuContainer}
|
|
onPress={(e) => e.stopPropagation()}
|
|
>
|
|
<View style={styles.dropdownMenu}>
|
|
{menuItems.map((item) => (
|
|
<TouchableOpacity
|
|
key={item.id}
|
|
style={[
|
|
styles.dropdownItem,
|
|
item.danger && styles.dropdownItemDanger,
|
|
]}
|
|
onPress={() => handleMenuAction(item.id)}
|
|
>
|
|
<Ionicons
|
|
name={item.icon}
|
|
size={20}
|
|
color={item.danger ? AppColors.error : AppColors.textPrimary}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.dropdownItemText,
|
|
item.danger && styles.dropdownItemTextDanger,
|
|
]}
|
|
>
|
|
{item.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
menuButton: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
modalBackdrop: {
|
|
flex: 1,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
dropdownMenuContainer: {
|
|
position: 'absolute',
|
|
top: 100, // Below status bar and header
|
|
right: Spacing.md,
|
|
},
|
|
dropdownMenu: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.lg,
|
|
minWidth: 180,
|
|
overflow: 'hidden',
|
|
...Shadows.lg,
|
|
},
|
|
dropdownItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.md,
|
|
paddingHorizontal: Spacing.md,
|
|
gap: Spacing.sm,
|
|
width: '100%',
|
|
},
|
|
dropdownItemText: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textPrimary,
|
|
},
|
|
dropdownItemDanger: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: AppColors.border,
|
|
},
|
|
dropdownItemTextDanger: {
|
|
color: AppColors.error,
|
|
},
|
|
});
|