From d530695b8bd8b22db6af4e7819b09bcb31557a90 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 08:32:59 -0800 Subject: [PATCH] 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 --- admin/app/beneficiaries/page.js | 96 +++++++- admin/components/AddBeneficiaryModal.tsx | 174 +++++++++++++++ .../__tests__/AddBeneficiaryModal.test.tsx | 208 ++++++++++++++++++ admin/lib/api.js | 10 + 4 files changed, 481 insertions(+), 7 deletions(-) create mode 100644 admin/components/AddBeneficiaryModal.tsx create mode 100644 admin/components/__tests__/AddBeneficiaryModal.test.tsx diff --git a/admin/app/beneficiaries/page.js b/admin/app/beneficiaries/page.js index 3739b1d..c2a77d5 100644 --- a/admin/app/beneficiaries/page.js +++ b/admin/app/beneficiaries/page.js @@ -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 ( -

Beneficiaries

-

Users being monitored (elderly relatives)

+
+
+

Beneficiaries

+

Users being monitored (elderly relatives)

+
+ +
{loading ? (

Loading...

) : beneficiaries.length === 0 ? (
-

No beneficiaries yet

+
+ + + + + + +
+

No beneficiaries yet

- Beneficiaries appear when caretakers add someone to monitor + Click the button above to add your first beneficiary

+
+ +
) : (
{beneficiaries.map((ben) => ( -
+
handleCardClick(ben.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleCardClick(ben.id); + } + }} + >
{getInitials(ben.first_name, ben.last_name)}
@@ -70,6 +124,12 @@ export default function BeneficiariesPage() {
Total: {beneficiaries.length} beneficiaries
+ + setIsModalOpen(false)} + onSuccess={handleAddSuccess} + /> ); } @@ -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', diff --git a/admin/components/AddBeneficiaryModal.tsx b/admin/components/AddBeneficiaryModal.tsx new file mode 100644 index 0000000..8ae4441 --- /dev/null +++ b/admin/components/AddBeneficiaryModal.tsx @@ -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(null); + const nameInputRef = useRef(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 ( +
+
+
+ +

+ Enter details for the person you want to care for +

+
+ +
+
+ {error && ( + setError(null)} + /> + )} + + { + setName(e.target.value); + setError(null); + }} + placeholder="e.g., Grandma Julia" + disabled={isLoading} + fullWidth + autoComplete="off" + /> + + setAddress(e.target.value)} + placeholder="123 Main St, City, State" + disabled={isLoading} + fullWidth + autoComplete="off" + /> + + setPhone(e.target.value)} + placeholder="+1 (555) 123-4567" + disabled={isLoading} + fullWidth + autoComplete="off" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/admin/components/__tests__/AddBeneficiaryModal.test.tsx b/admin/components/__tests__/AddBeneficiaryModal.test.tsx new file mode 100644 index 0000000..80fc077 --- /dev/null +++ b/admin/components/__tests__/AddBeneficiaryModal.test.tsx @@ -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(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders modal when isOpen is true', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Add New Beneficiary')).toBeInTheDocument(); + }); + + it('renders all form fields', () => { + render(); + expect(getNameInput()).toBeInTheDocument(); + expect(getAddressInput()).toBeInTheDocument(); + expect(getPhoneInput()).toBeInTheDocument(); + }); + + it('shows validation error when name is empty', async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + fireEvent.click(getCancelButton()); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('closes modal when clicking backdrop', () => { + render(); + + const backdrop = screen.getByRole('dialog'); + fireEvent.click(backdrop); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('does not close modal when clicking inside the modal content', () => { + render(); + + const modalTitle = screen.getByText('Add New Beneficiary'); + fireEvent.click(modalTitle); + + expect(defaultProps.onClose).not.toHaveBeenCalled(); + }); + + it('closes modal when Escape key is pressed', () => { + render(); + + 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(); + + 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(); + + await userEvent.type(getNameInput(), 'Some Name'); + + rerender(); + rerender(); + + expect(getNameInput()).toHaveValue(''); + }); + + it('trims whitespace from inputs', async () => { + mockCreateBeneficiary.mockResolvedValueOnce({ id: 789, name: 'Trimmed Name' }); + + render(); + + 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(); + + 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(); + }); +}); diff --git a/admin/lib/api.js b/admin/lib/api.js index 908c2a1..3c663d7 100644 --- a/admin/lib/api.js +++ b/admin/lib/api.js @@ -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