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:
parent
bda883d34d
commit
d530695b8b
@ -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>
|
||||
<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',
|
||||
|
||||
174
admin/components/AddBeneficiaryModal.tsx
Normal file
174
admin/components/AddBeneficiaryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
admin/components/__tests__/AddBeneficiaryModal.test.tsx
Normal file
208
admin/components/__tests__/AddBeneficiaryModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user