Add Add Beneficiary Flow for web admin

- Add AddBeneficiaryModal component with form fields for name, address, phone
- Add createBeneficiary and updateBeneficiary API methods in admin/lib/api.js
- Update Beneficiaries page with Add button and modal integration
- Add comprehensive tests for AddBeneficiaryModal (15 test cases)

Features:
- Modal with form validation (name required)
- Keyboard support (Escape to close, Enter to submit)
- Click outside to dismiss
- Loading state during submission
- Error handling and display
- Form resets on modal reopen
- Automatic redirect to beneficiary detail after creation
This commit is contained in:
Sergei 2026-02-01 08:32:59 -08:00
parent bda883d34d
commit d530695b8b
4 changed files with 481 additions and 7 deletions

View File

@ -1,12 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import AdminLayout from '../../components/AdminLayout';
import { AddBeneficiaryModal } from '../../components/AddBeneficiaryModal';
import { Button } from '../../components/ui/Button';
import { getBeneficiaries } from '../../lib/api';
export default function BeneficiariesPage() {
const router = useRouter();
const [beneficiaries, setBeneficiaries] = useState([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
loadBeneficiaries();
@ -23,24 +28,73 @@ export default function BeneficiariesPage() {
}
};
const handleAddSuccess = (newBeneficiary) => {
loadBeneficiaries();
router.push(`/admin/beneficiaries/${newBeneficiary.id}`);
};
const handleCardClick = (id) => {
router.push(`/admin/beneficiaries/${id}`);
};
return (
<AdminLayout>
<h1 style={styles.title}>Beneficiaries</h1>
<p style={styles.subtitle}>Users being monitored (elderly relatives)</p>
<div style={styles.header}>
<div>
<h1 style={styles.title}>Beneficiaries</h1>
<p style={styles.subtitle}>Users being monitored (elderly relatives)</p>
</div>
<Button onClick={() => setIsModalOpen(true)}>
+ Add Beneficiary
</Button>
</div>
{loading ? (
<p style={styles.loading}>Loading...</p>
) : beneficiaries.length === 0 ? (
<div style={styles.empty}>
<p>No beneficiaries yet</p>
<div style={styles.emptyIcon}>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
</div>
<p style={styles.emptyTitle}>No beneficiaries yet</p>
<p style={styles.emptyHint}>
Beneficiaries appear when caretakers add someone to monitor
Click the button above to add your first beneficiary
</p>
<div style={styles.emptyAction}>
<Button onClick={() => setIsModalOpen(true)}>
Add Your First Beneficiary
</Button>
</div>
</div>
) : (
<div style={styles.grid}>
{beneficiaries.map((ben) => (
<div key={ben.id} style={styles.card}>
<div
key={ben.id}
style={styles.card}
onClick={() => handleCardClick(ben.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleCardClick(ben.id);
}
}}
>
<div style={styles.avatar}>
{getInitials(ben.first_name, ben.last_name)}
</div>
@ -70,6 +124,12 @@ export default function BeneficiariesPage() {
<div style={styles.stats}>
<span>Total: {beneficiaries.length} beneficiaries</span>
</div>
<AddBeneficiaryModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={handleAddSuccess}
/>
</AdminLayout>
);
}
@ -81,6 +141,12 @@ function getInitials(first, last) {
}
const styles = {
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '24px',
},
title: {
fontSize: '24px',
fontWeight: '600',
@ -89,7 +155,6 @@ const styles = {
subtitle: {
fontSize: '14px',
color: 'var(--text-muted)',
marginBottom: '24px',
},
loading: {
color: 'var(--text-muted)',
@ -100,10 +165,25 @@ const styles = {
borderRadius: '12px',
textAlign: 'center',
},
emptyIcon: {
color: 'var(--text-muted)',
marginBottom: '16px',
display: 'flex',
justifyContent: 'center',
},
emptyTitle: {
fontSize: '18px',
fontWeight: '600',
marginBottom: '8px',
},
emptyHint: {
fontSize: '14px',
color: 'var(--text-muted)',
marginTop: '8px',
marginBottom: '24px',
},
emptyAction: {
display: 'flex',
justifyContent: 'center',
},
grid: {
display: 'grid',
@ -116,6 +196,8 @@ const styles = {
padding: '20px',
display: 'flex',
gap: '16px',
cursor: 'pointer',
transition: 'box-shadow 0.2s, transform 0.2s',
},
avatar: {
width: '48px',

View File

@ -0,0 +1,174 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { ErrorMessage } from './ui/ErrorMessage';
import { createBeneficiary } from '../lib/api';
interface AddBeneficiaryModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (beneficiary: { id: number; name: string }) => void;
}
export function AddBeneficiaryModal({
isOpen,
onClose,
onSuccess,
}: AddBeneficiaryModalProps) {
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setName('');
setAddress('');
setPhone('');
setError(null);
setTimeout(() => {
nameInputRef.current?.focus();
}, 100);
}
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && !isLoading) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, isLoading, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setError('Please enter a name for the beneficiary');
return;
}
setIsLoading(true);
try {
const result = await createBeneficiary({
name: trimmedName,
address: address.trim() || undefined,
phone: phone.trim() || undefined,
});
onSuccess({ id: result.id, name: trimmedName });
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create beneficiary');
} finally {
setIsLoading(false);
}
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isLoading) {
onClose();
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 id="modal-title" className="text-lg font-semibold text-textPrimary">
Add New Beneficiary
</h2>
<p className="text-sm text-textSecondary mt-1">
Enter details for the person you want to care for
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
{error && (
<ErrorMessage
message={error}
onDismiss={() => setError(null)}
/>
)}
<Input
ref={nameInputRef}
id="beneficiary-name"
label="Name *"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="e.g., Grandma Julia"
disabled={isLoading}
fullWidth
autoComplete="off"
/>
<Input
id="beneficiary-address"
label="Address (optional)"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St, City, State"
disabled={isLoading}
fullWidth
autoComplete="off"
/>
<Input
id="beneficiary-phone"
label="Phone (optional)"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 (555) 123-4567"
disabled={isLoading}
fullWidth
autoComplete="off"
/>
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={isLoading}
>
Add Beneficiary
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,208 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AddBeneficiaryModal } from '../AddBeneficiaryModal';
import * as api from '../../lib/api';
jest.mock('../../lib/api');
const mockCreateBeneficiary = api.createBeneficiary as jest.MockedFunction<
typeof api.createBeneficiary
>;
describe('AddBeneficiaryModal', () => {
const defaultProps = {
isOpen: true,
onClose: jest.fn(),
onSuccess: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
const getNameInput = () => screen.getByPlaceholderText('e.g., Grandma Julia');
const getAddressInput = () => screen.getByPlaceholderText('123 Main St, City, State');
const getPhoneInput = () => screen.getByPlaceholderText('+1 (555) 123-4567');
const getSubmitButton = () => screen.getByRole('button', { name: /Add Beneficiary/i });
const getCancelButton = () => screen.getByRole('button', { name: /Cancel/i });
it('does not render when isOpen is false', () => {
render(<AddBeneficiaryModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders modal when isOpen is true', () => {
render(<AddBeneficiaryModal {...defaultProps} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Add New Beneficiary')).toBeInTheDocument();
});
it('renders all form fields', () => {
render(<AddBeneficiaryModal {...defaultProps} />);
expect(getNameInput()).toBeInTheDocument();
expect(getAddressInput()).toBeInTheDocument();
expect(getPhoneInput()).toBeInTheDocument();
});
it('shows validation error when name is empty', async () => {
render(<AddBeneficiaryModal {...defaultProps} />);
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(screen.getByText('Please enter a name for the beneficiary')).toBeInTheDocument();
});
expect(mockCreateBeneficiary).not.toHaveBeenCalled();
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
});
it('calls createBeneficiary with correct data on submit', async () => {
mockCreateBeneficiary.mockResolvedValueOnce({ id: 123, name: 'Test Name' });
render(<AddBeneficiaryModal {...defaultProps} />);
fireEvent.change(getNameInput(), { target: { value: 'Grandma Julia' } });
fireEvent.change(getAddressInput(), { target: { value: '123 Main St' } });
fireEvent.change(getPhoneInput(), { target: { value: '+1 555-123-4567' } });
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mockCreateBeneficiary).toHaveBeenCalledWith({
name: 'Grandma Julia',
address: '123 Main St',
phone: '+1 555-123-4567',
});
});
});
it('calls onSuccess and onClose after successful creation', async () => {
mockCreateBeneficiary.mockResolvedValueOnce({ id: 456, name: 'Test Person' });
render(<AddBeneficiaryModal {...defaultProps} />);
await userEvent.type(getNameInput(), 'Test Person');
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(defaultProps.onSuccess).toHaveBeenCalledWith({
id: 456,
name: 'Test Person',
});
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('shows error message on API failure', async () => {
mockCreateBeneficiary.mockRejectedValueOnce(new Error('Network error'));
render(<AddBeneficiaryModal {...defaultProps} />);
await userEvent.type(getNameInput(), 'Test Name');
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
expect(defaultProps.onClose).not.toHaveBeenCalled();
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
});
it('closes modal when Cancel button is clicked', () => {
render(<AddBeneficiaryModal {...defaultProps} />);
fireEvent.click(getCancelButton());
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('closes modal when clicking backdrop', () => {
render(<AddBeneficiaryModal {...defaultProps} />);
const backdrop = screen.getByRole('dialog');
fireEvent.click(backdrop);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('does not close modal when clicking inside the modal content', () => {
render(<AddBeneficiaryModal {...defaultProps} />);
const modalTitle = screen.getByText('Add New Beneficiary');
fireEvent.click(modalTitle);
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
it('closes modal when Escape key is pressed', () => {
render(<AddBeneficiaryModal {...defaultProps} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('disables form during submission', async () => {
let resolvePromise: (value: { id: number; name: string }) => void;
const promise = new Promise<{ id: number; name: string }>((resolve) => {
resolvePromise = resolve;
});
mockCreateBeneficiary.mockReturnValueOnce(promise);
render(<AddBeneficiaryModal {...defaultProps} />);
await userEvent.type(getNameInput(), 'Test Name');
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(getNameInput()).toBeDisabled();
expect(getCancelButton()).toBeDisabled();
});
resolvePromise!({ id: 1, name: 'Test Name' });
});
it('clears form when modal is reopened', async () => {
const { rerender } = render(<AddBeneficiaryModal {...defaultProps} />);
await userEvent.type(getNameInput(), 'Some Name');
rerender(<AddBeneficiaryModal {...defaultProps} isOpen={false} />);
rerender(<AddBeneficiaryModal {...defaultProps} isOpen={true} />);
expect(getNameInput()).toHaveValue('');
});
it('trims whitespace from inputs', async () => {
mockCreateBeneficiary.mockResolvedValueOnce({ id: 789, name: 'Trimmed Name' });
render(<AddBeneficiaryModal {...defaultProps} />);
await userEvent.type(getNameInput(), ' Trimmed Name ');
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(mockCreateBeneficiary).toHaveBeenCalledWith({
name: 'Trimmed Name',
address: undefined,
phone: undefined,
});
});
});
it('dismisses error when user starts typing', async () => {
render(<AddBeneficiaryModal {...defaultProps} />);
fireEvent.click(getSubmitButton());
await waitFor(() => {
expect(screen.getByText('Please enter a name for the beneficiary')).toBeInTheDocument();
});
await userEvent.type(getNameInput(), 'N');
expect(screen.queryByText('Please enter a name for the beneficiary')).not.toBeInTheDocument();
});
});

View File

@ -64,6 +64,16 @@ export const getUser = (id) => apiRequest(`/api/admin/users/${id}`);
export const getBeneficiaries = () => apiRequest('/api/admin/beneficiaries');
export const getBeneficiary = (id) => apiRequest(`/api/admin/beneficiaries/${id}`);
export const createBeneficiary = (data) =>
apiRequest('/api/me/beneficiaries', {
method: 'POST',
body: JSON.stringify(data),
});
export const updateBeneficiary = (id, data) =>
apiRequest(`/api/me/beneficiaries/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
export const getSubscriptions = () => apiRequest('/api/admin/subscriptions');
// Deployments