feat(beneficiaries): Display customName in beneficiaries list

- Add displayName (customName || name) to BeneficiaryCard component
- Update header and MockDashboard to show customName when set
- Add custom name editing for non-custodian users (guardian/caretaker)
- Backend PATCH endpoint now supports customName updates via user_access table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-22 12:51:46 -08:00
parent 4bdfa69dbe
commit c058ebe2c6
3 changed files with 264 additions and 114 deletions

View File

@ -73,7 +73,12 @@ export default function BeneficiaryDetailScreen() {
// Edit modal state // Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined }); const [editForm, setEditForm] = useState({
name: '',
address: '',
avatar: undefined as string | undefined,
customName: '' // For non-custodian users
});
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
@ -215,11 +220,15 @@ export default function BeneficiaryDetailScreen() {
name: beneficiary.name || '', name: beneficiary.name || '',
address: beneficiary.address || '', address: beneficiary.address || '',
avatar: beneficiary.avatar, avatar: beneficiary.avatar,
customName: beneficiary.customName || '',
}); });
setIsEditModalVisible(true); setIsEditModalVisible(true);
} }
}; };
// Check if user is custodian (can edit all fields)
const isCustodian = beneficiary?.role === 'custodian';
const handlePickAvatar = async () => { const handlePickAvatar = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
@ -240,8 +249,8 @@ export default function BeneficiaryDetailScreen() {
}; };
const handleSaveEdit = async () => { const handleSaveEdit = async () => {
if (!editForm.name.trim() || !id) { if (!id) {
toast.error('Error', 'Name is required'); toast.error('Error', 'Invalid beneficiary');
return; return;
} }
@ -249,32 +258,51 @@ export default function BeneficiaryDetailScreen() {
setIsSavingEdit(true); setIsSavingEdit(true);
try { try {
// Update basic info if (isCustodian) {
const response = await api.updateWellNuoBeneficiary(beneficiaryId, { // Custodian: update name, address in beneficiaries table
name: editForm.name.trim(), if (!editForm.name.trim()) {
address: editForm.address.trim() || undefined, toast.error('Error', 'Name is required');
}); setIsSavingEdit(false);
return;
}
if (!response.ok) { const response = await api.updateWellNuoBeneficiary(beneficiaryId, {
toast.error('Error', response.error?.message || 'Failed to save changes.'); name: editForm.name.trim(),
setIsSavingEdit(false); address: editForm.address.trim() || undefined,
return; });
}
// Upload avatar if changed (new local file URI) if (!response.ok) {
if (editForm.avatar && editForm.avatar.startsWith('file://')) { toast.error('Error', response.error?.message || 'Failed to save changes.');
setIsUploadingAvatar(true); setIsSavingEdit(false);
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar); return;
setIsUploadingAvatar(false); }
if (!avatarResult.ok) {
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message); // Upload avatar if changed (new local file URI)
// Show info but don't fail the whole operation if (editForm.avatar && editForm.avatar.startsWith('file://')) {
toast.info('Note', 'Profile saved but avatar upload failed'); setIsUploadingAvatar(true);
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar);
setIsUploadingAvatar(false);
if (!avatarResult.ok) {
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
toast.info('Note', 'Profile saved but avatar upload failed');
}
}
} else {
// Guardian/Caretaker: update only customName in user_access table
const response = await api.updateBeneficiaryCustomName(
beneficiaryId,
editForm.customName.trim() || null
);
if (!response.ok) {
toast.error('Error', response.error?.message || 'Failed to save nickname.');
setIsSavingEdit(false);
return;
} }
} }
setIsEditModalVisible(false); setIsEditModalVisible(false);
toast.success('Saved', 'Profile updated successfully'); toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved');
loadBeneficiary(false); loadBeneficiary(false);
} catch (err) { } catch (err) {
toast.error('Error', 'Failed to save changes.'); toast.error('Error', 'Failed to save changes.');
@ -375,7 +403,7 @@ export default function BeneficiaryDetailScreen() {
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiary.name}</Text> <Text style={styles.headerTitle}>{beneficiary.customName || beneficiary.name}</Text>
</View> </View>
<BeneficiaryMenu <BeneficiaryMenu
@ -471,7 +499,7 @@ export default function BeneficiaryDetailScreen() {
/> />
} }
> >
<MockDashboard beneficiaryName={beneficiary.name} /> <MockDashboard beneficiaryName={beneficiary.customName || beneficiary.name} />
</ScrollView> </ScrollView>
)} )}
</View> </View>
@ -485,64 +513,94 @@ export default function BeneficiaryDetailScreen() {
> >
<View style={styles.modalContainer}> <View style={styles.modalContainer}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Edit Profile</Text> <Text style={styles.modalTitle}>
{isCustodian ? 'Edit Profile' : 'Edit Nickname'}
</Text>
<TouchableOpacity onPress={() => setIsEditModalVisible(false)}> <TouchableOpacity onPress={() => setIsEditModalVisible(false)}>
<Ionicons name="close" size={24} color={AppColors.textPrimary} /> <Ionicons name="close" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView style={styles.modalContent}> <ScrollView style={styles.modalContent}>
{/* Avatar */} {isCustodian ? (
<TouchableOpacity <>
style={styles.avatarPicker} {/* Custodian: Avatar, Name, Address */}
onPress={handlePickAvatar} <TouchableOpacity
disabled={isSavingEdit} style={styles.avatarPicker}
> onPress={handlePickAvatar}
{editForm.avatar ? ( disabled={isSavingEdit}
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} /> >
) : ( {editForm.avatar ? (
<View style={styles.avatarPickerPlaceholder}> <Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
<Ionicons name="camera" size={32} color={AppColors.textMuted} /> ) : (
</View> <View style={styles.avatarPickerPlaceholder}>
)} <Ionicons name="camera" size={32} color={AppColors.textMuted} />
{isUploadingAvatar && ( </View>
<View style={styles.avatarUploadOverlay}> )}
<ActivityIndicator size="large" color={AppColors.white} /> {isUploadingAvatar && (
<Text style={styles.avatarUploadText}>Uploading...</Text> <View style={styles.avatarUploadOverlay}>
</View> <ActivityIndicator size="large" color={AppColors.white} />
)} <Text style={styles.avatarUploadText}>Uploading...</Text>
{!isUploadingAvatar && ( </View>
<View style={styles.avatarPickerBadge}> )}
<Ionicons name="pencil" size={12} color={AppColors.white} /> {!isUploadingAvatar && (
</View> <View style={styles.avatarPickerBadge}>
)} <Ionicons name="pencil" size={12} color={AppColors.white} />
</TouchableOpacity> </View>
)}
</TouchableOpacity>
{/* Name */} <View style={styles.inputGroup}>
<View style={styles.inputGroup}> <Text style={styles.inputLabel}>Name</Text>
<Text style={styles.inputLabel}>Name</Text> <TextInput
<TextInput style={styles.textInput}
style={styles.textInput} value={editForm.name}
value={editForm.name} onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))} placeholder="Full name"
placeholder="Full name" placeholderTextColor={AppColors.textMuted}
placeholderTextColor={AppColors.textMuted} />
/> </View>
</View>
{/* Address */} <View style={styles.inputGroup}>
<View style={styles.inputGroup}> <Text style={styles.inputLabel}>Address</Text>
<Text style={styles.inputLabel}>Address</Text> <TextInput
<TextInput style={[styles.textInput, styles.textArea]}
style={[styles.textInput, styles.textArea]} value={editForm.address}
value={editForm.address} onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))}
onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))} placeholder="Street address"
placeholder="Street address" placeholderTextColor={AppColors.textMuted}
placeholderTextColor={AppColors.textMuted} multiline
multiline numberOfLines={3}
numberOfLines={3} />
/> </View>
</View> </>
) : (
<>
{/* Guardian/Caretaker: Only custom nickname */}
<View style={styles.nicknameInfo}>
<Text style={styles.nicknameInfoText}>
Set a personal nickname for {beneficiary?.name}. This is only visible to you.
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Nickname</Text>
<TextInput
style={styles.textInput}
value={editForm.customName}
onChangeText={(text) => setEditForm(prev => ({ ...prev, customName: text }))}
placeholder={`e.g., "Mom", "Dad", "Grandma"`}
placeholderTextColor={AppColors.textMuted}
maxLength={100}
/>
</View>
<View style={styles.originalNameContainer}>
<Text style={styles.originalNameLabel}>Original name:</Text>
<Text style={styles.originalNameValue}>{beneficiary?.name}</Text>
</View>
</>
)}
</ScrollView> </ScrollView>
<View style={styles.modalFooter}> <View style={styles.modalFooter}>
@ -819,4 +877,34 @@ const styles = StyleSheet.create({
buttonDisabled: { buttonDisabled: {
opacity: 0.6, opacity: 0.6,
}, },
// Non-custodian edit modal styles
nicknameInfo: {
backgroundColor: AppColors.surfaceSecondary,
padding: Spacing.md,
borderRadius: BorderRadius.md,
marginBottom: Spacing.lg,
},
nicknameInfoText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
lineHeight: 20,
},
originalNameContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: Spacing.sm,
paddingTop: Spacing.md,
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
originalNameLabel: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginRight: Spacing.xs,
},
originalNameValue: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
fontWeight: FontWeights.medium,
},
}); });

View File

@ -89,6 +89,9 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none; const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none;
// Display name: customName (e.g., "Mom") if set, otherwise full name
const displayName = beneficiary.customName || beneficiary.name;
// Check if avatar is valid (not empty, null, or placeholder) // Check if avatar is valid (not empty, null, or placeholder)
const hasValidAvatar = beneficiary.avatar && const hasValidAvatar = beneficiary.avatar &&
beneficiary.avatar.trim() !== '' && beneficiary.avatar.trim() !== '' &&
@ -116,7 +119,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
) : ( ) : (
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}> <View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}> <Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
{beneficiary.name.charAt(0).toUpperCase()} {displayName.charAt(0).toUpperCase()}
</Text> </Text>
</View> </View>
)} )}
@ -124,7 +127,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
{/* Name and Status */} {/* Name and Status */}
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text> <Text style={styles.name} numberOfLines={1}>{displayName}</Text>
{/* User's role for this beneficiary */} {/* User's role for this beneficiary */}
{beneficiary.role && ( {beneficiary.role && (
<Text style={styles.roleText}> <Text style={styles.roleText}>
@ -260,9 +263,10 @@ export default function HomeScreen() {
const handleActivate = (beneficiary: Beneficiary) => { const handleActivate = (beneficiary: Beneficiary) => {
setCurrentBeneficiary(beneficiary); setCurrentBeneficiary(beneficiary);
const lovedOneName = beneficiary.customName || beneficiary.name;
router.push({ router.push({
pathname: '/(auth)/activate', pathname: '/(auth)/activate',
params: { lovedOneName: beneficiary.name, beneficiaryId: beneficiary.id.toString() }, params: { lovedOneName, beneficiaryId: beneficiary.id.toString() },
}); });
}; };

View File

@ -508,8 +508,9 @@ router.post('/', async (req, res) => {
/** /**
* PATCH /api/me/beneficiaries/:id * PATCH /api/me/beneficiaries/:id
* Updates beneficiary info (requires custodian or guardian role) * Updates beneficiary info
* Now uses the proper beneficiaries table (not users) * - Custodian: can update name, phone, address in beneficiaries table
* - Guardian/Caretaker: can only update customName in user_access table
*/ */
router.patch('/:id', async (req, res) => { router.patch('/:id', async (req, res) => {
try { try {
@ -518,53 +519,110 @@ router.patch('/:id', async (req, res) => {
console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body }); console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body });
// Check user has custodian or guardian access - using beneficiary_id // Check user has access - using beneficiary_id
const { data: access, error: accessError } = await supabase const { data: access, error: accessError } = await supabase
.from('user_access') .from('user_access')
.select('role') .select('id, role')
.eq('accessor_id', userId) .eq('accessor_id', userId)
.eq('beneficiary_id', beneficiaryId) .eq('beneficiary_id', beneficiaryId)
.single(); .single();
if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) { if (accessError || !access) {
return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' }); return res.status(403).json({ error: 'Access denied to this beneficiary' });
} }
const { name, phone, address } = req.body; const { name, phone, address, customName } = req.body;
const isCustodian = access.role === 'custodian';
const updateData = { // Custodian can update beneficiary data (name, phone, address)
updated_at: new Date().toISOString() if (isCustodian) {
}; const updateData = {
updated_at: new Date().toISOString()
};
if (name !== undefined) updateData.name = name; if (name !== undefined) updateData.name = name;
if (phone !== undefined) updateData.phone = phone; if (phone !== undefined) updateData.phone = phone;
if (address !== undefined) updateData.address = address; if (address !== undefined) updateData.address = address;
// Update in beneficiaries table // Update in beneficiaries table
const { data: beneficiary, error } = await supabase const { data: beneficiary, error } = await supabase
.from('beneficiaries') .from('beneficiaries')
.update(updateData) .update(updateData)
.eq('id', beneficiaryId) .eq('id', beneficiaryId)
.select() .select()
.single(); .single();
if (error) { if (error) {
console.error('[BENEFICIARY PATCH] Supabase error:', error); console.error('[BENEFICIARY PATCH] Supabase error:', error);
return res.status(500).json({ error: 'Failed to update beneficiary' }); return res.status(500).json({ error: 'Failed to update beneficiary' });
}
console.log('[BENEFICIARY PATCH] Success:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address });
res.json({
success: true,
beneficiary: {
id: beneficiary.id,
name: beneficiary.name,
phone: beneficiary.phone,
address: beneficiary.address || null,
avatarUrl: beneficiary.avatar_url
} }
});
console.log('[BENEFICIARY PATCH] Custodian updated:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address });
res.json({
success: true,
beneficiary: {
id: beneficiary.id,
name: beneficiary.name,
displayName: beneficiary.name, // For custodian, displayName = name
originalName: beneficiary.name,
phone: beneficiary.phone,
address: beneficiary.address || null,
avatarUrl: beneficiary.avatar_url
}
});
} else {
// Guardian/Caretaker can only update their custom_name
if (customName === undefined) {
return res.status(400).json({ error: 'customName is required for non-custodian roles' });
}
// Validate custom name
if (customName !== null && typeof customName !== 'string') {
return res.status(400).json({ error: 'customName must be a string or null' });
}
if (customName && customName.length > 100) {
return res.status(400).json({ error: 'customName must be 100 characters or less' });
}
// Update custom_name in user_access
const { error: updateError } = await supabase
.from('user_access')
.update({
custom_name: customName || null // Empty string becomes null
})
.eq('id', access.id);
if (updateError) {
console.error('[BENEFICIARY PATCH] Custom name update error:', updateError);
return res.status(500).json({ error: 'Failed to update custom name' });
}
// Get beneficiary data for response
const { data: beneficiary } = await supabase
.from('beneficiaries')
.select('id, name, phone, address, avatar_url')
.eq('id', beneficiaryId)
.single();
const displayName = customName || beneficiary?.name || null;
console.log('[BENEFICIARY PATCH] Custom name updated:', { beneficiaryId, customName, displayName });
res.json({
success: true,
beneficiary: {
id: beneficiaryId,
name: beneficiary?.name || null,
displayName: displayName,
originalName: beneficiary?.name || null,
customName: customName || null,
phone: beneficiary?.phone || null,
address: beneficiary?.address || null,
avatarUrl: beneficiary?.avatar_url || null
}
});
}
} catch (error) { } catch (error) {
console.error('[BENEFICIARY PATCH] Error:', error); console.error('[BENEFICIARY PATCH] Error:', error);