diff --git a/frontend/index.html b/frontend/index.html index 2e83921..74d2dbf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - my-react-app + QueueNova Health
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 08f4b04..3dcad04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ProtectedRoute } from './routes/ProtectedRoute'; import RegisterPage from './pages/RegisterPage'; import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; +import ProfilePage from './pages/ProfilePage'; function App() { return ( @@ -23,6 +24,10 @@ function App() { path="/dashboard" element={} /> + } + /> {/* Fallback */} diff --git a/frontend/src/pages/.gitkeep b/frontend/src/pages/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..c23c3d4 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,355 @@ +import { useState, useEffect } from 'react'; +import { AxiosError } from 'axios'; +import { patientService } from '../services/patientService'; +import { LogoutButton } from '../components/LogoutButton'; +import type { + PatientProfile, + PatientProfileUpdatePayload, + // ApiSuccessResponse, +} from '../types/patient'; + +type FieldErrors = Partial>; + +interface ApiValidationError { + message: string; + errors: Record; +} + +export default function ProfilePage() { + const [profile, setProfile] = useState(null); + const [isLoadingProfile, setIsLoadingProfile] = useState(true); + const [fetchError, setFetchError] = useState(''); + + const [form, setForm] = useState({}); + const [fieldErrors, setFieldErrors] = useState({}); + const [genericError, setGenericError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + patientService + .getProfile() + .then((response) => { + const data = response.data; + setProfile(data); + setForm({ + name: data.name, + phone: data.phone, + bpjs_number: data.bpjs_number, + birth_place: data.birth_place, + birth_date: data.birth_date, + gender: data.gender, + }); + }) + .catch(() => { + setFetchError('Failed to load profile. Please refresh the page.'); + }) + .finally(() => { + setIsLoadingProfile(false); + }); + }, []); + + function handleChange( + e: React.ChangeEvent + ) { + const { name, value } = e.target; + setForm((prev) => ({ ...prev, [name]: value === '' ? null : value })); + setFieldErrors((prev) => ({ ...prev, [name]: undefined })); + setGenericError(''); + setSuccessMessage(''); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsSubmitting(true); + setFieldErrors({}); + setGenericError(''); + setSuccessMessage(''); + + try { + const response = await patientService.updateProfile(form); + setProfile(response.data); + setSuccessMessage('Profile updated successfully.'); + } catch (err) { + const error = err as AxiosError; + + if (error.response?.status === 422 && error.response.data.errors) { + const raw = error.response.data.errors; + const mapped: FieldErrors = {}; + + for (const key of Object.keys(raw) as Array) { + mapped[key] = raw[key][0]; + } + + setFieldErrors(mapped); + } else { + setGenericError("Failed to update profile. Please try again."); + } + } finally { + setIsSubmitting(false); + } + } + + if (isLoadingProfile) { + return
Loading profile...
; + } + + if (fetchError) { + return ( +
+

{fetchError}

+
+ ); + } + + return ( +
+
+
+
+

My Profile

+

+ {profile?.name} · {profile?.phone ?? '-'} +

+
+ +
+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {genericError && ( +
+ {genericError} +
+ )} + +
+ + + + + + +
+ + + {fieldErrors.gender && ( + + {fieldErrors.gender} + + )} +
+ + + +
+
+ ); +} + +interface FieldProps { + label: string; + name: string; + type: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + error?: string; +} + +function Field({ label, name, type, value, onChange, error }: FieldProps) { + return ( +
+ + + {error && ( + + {error} + + )} +
+ ); +} + +const styles: Record = { + container: { + minHeight: '100vh', + backgroundColor: '#f5f7fa', + padding: '32px 24px', + }, + card: { + backgroundColor: '#ffffff', + borderRadius: '8px', + border: '1px solid #e2e8f0', + padding: '40px', + width: '100%', + maxWidth: '520px', + margin: '0 auto', + }, + headerRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: '28px', + }, + title: { + fontSize: '20px', + fontWeight: 700, + color: '#1a202c', + margin: 0, + }, + subtitle: { + fontSize: '13px', + color: '#64748b', + marginTop: '4px', + marginBottom: 0, + }, + form: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + fieldWrapper: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + label: { + fontSize: '13px', + fontWeight: 500, + color: '#374151', + }, + input: { + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + outline: 'none', + color: '#1a202c', + backgroundColor: '#ffffff', + }, + inputError: { + borderColor: '#ef4444', + }, + fieldError: { + fontSize: '12px', + color: '#ef4444', + }, + successBanner: { + padding: '10px 14px', + backgroundColor: '#f0fdf4', + border: '1px solid #bbf7d0', + borderRadius: '6px', + fontSize: '13px', + color: '#15803d', + marginBottom: '16px', + }, + genericError: { + padding: '10px 14px', + backgroundColor: '#fef2f2', + border: '1px solid #fecaca', + borderRadius: '6px', + fontSize: '13px', + color: '#b91c1c', + marginBottom: '16px', + }, + button: { + marginTop: '4px', + padding: '11px', + backgroundColor: '#2563eb', + color: '#ffffff', + border: 'none', + borderRadius: '6px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', + }, + buttonDisabled: { + backgroundColor: '#93c5fd', + cursor: 'not-allowed', + }, + loadingContainer: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '14px', + color: '#64748b', + }, +}; \ No newline at end of file diff --git a/frontend/src/services/.gitkeep b/frontend/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/services/patientService.ts b/frontend/src/services/patientService.ts new file mode 100644 index 0000000..73936e6 --- /dev/null +++ b/frontend/src/services/patientService.ts @@ -0,0 +1,27 @@ +import axiosInstance from "../api/axiosInstance"; +import type { + PatientProfile, + PatientProfileUpdatePayload, + ApiSuccessResponse, +} from "../types/patient"; + +export const patientService = { + async getProfile() : Promise> { + const response = + await axiosInstance.get>( + "/patient/me" + ) + return response.data; + }, + + async updateProfile( + payload: PatientProfileUpdatePayload + ) : Promise> { + const response = + await axiosInstance.patch>( + "/patient/me", + payload + ); + return response.data; + }, +} \ No newline at end of file diff --git a/frontend/src/tests/pages/ProfilePage.test.tsx b/frontend/src/tests/pages/ProfilePage.test.tsx new file mode 100644 index 0000000..ca0d698 --- /dev/null +++ b/frontend/src/tests/pages/ProfilePage.test.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthContext } from '../../contexts/AuthContext'; +import ProfilePage from '../../pages/ProfilePage'; +import { patientService } from '../../services/patientService'; +import type { AuthState, LoginResponseData } from '../../types/auth'; +import type { PatientProfile } from '../../types/patient'; + +vi.mock('../../services/patientService'); + +const mockAuthContextValue = { + user: null, + isAuthenticated: true, + isLoading: false, + setAuth: vi.fn<(data: LoginResponseData) => void>(), + clearAuth: vi.fn<() => void>(), +} satisfies AuthState & { + isLoading: boolean; + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; +}; + +const mockProfile: PatientProfile = { + patient_id: 5, + name: 'Ucok Sitorus', + phone: '081234567890', + bpjs_number: '0001234567890', + birth_place: 'Medan', + birth_date: '1995-06-15', + gender: 'laki-laki', + user_id: 10, +}; + +function renderProfilePage() { + return render( + + + + + + ); +} + +describe('ProfilePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state initially', () => { + vi.mocked(patientService.getProfile).mockImplementation( + () => new Promise(() => {}) + ); + + renderProfilePage(); + expect(screen.getByText('Loading profile...')).toBeInTheDocument(); + }); + + it('renders profile data after successful fetch', async () => { + vi.mocked(patientService.getProfile).mockResolvedValueOnce({ + data: mockProfile, + }); + + renderProfilePage(); + + await waitFor(() => { + expect(screen.getByDisplayValue('Ucok Sitorus')).toBeInTheDocument(); + expect(screen.getByDisplayValue('081234567890')).toBeInTheDocument(); + expect(screen.getByDisplayValue('0001234567890')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Medan')).toBeInTheDocument(); + expect(screen.getByDisplayValue('1995-06-15')).toBeInTheDocument(); + }); + }); + + it('renders fetch error message on failure', async () => { + vi.mocked(patientService.getProfile).mockRejectedValueOnce( + new Error('Network Error') + ); + + renderProfilePage(); + + await waitFor(() => { + expect( + screen.getByText('Failed to load profile. Please refresh the page.') + ).toBeInTheDocument(); + }); + }); + + it('calls patientService.updateProfile with form values on submit', async () => { + vi.mocked(patientService.getProfile).mockResolvedValueOnce({ + data: mockProfile, + }); + vi.mocked(patientService.updateProfile).mockResolvedValueOnce({ + message: 'Profil berhasil diperbarui.', + data: { ...mockProfile, birth_place: 'Jakarta' }, + }); + + renderProfilePage(); + + await waitFor(() => { + expect(screen.getByDisplayValue('Medan')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText('Birth Place'), { + target: { value: 'Jakarta' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(patientService.updateProfile).toHaveBeenCalledWith( + expect.objectContaining({ birth_place: 'Jakarta' }) + ); + }); + }); + + it('shows success message after successful update', async () => { + vi.mocked(patientService.getProfile).mockResolvedValueOnce({ + data: mockProfile, + }); + vi.mocked(patientService.updateProfile).mockResolvedValueOnce({ + message: 'Profil berhasil diperbarui.', + data: mockProfile, + }); + + renderProfilePage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect( + screen.getByText('Profile updated successfully.') + ).toBeInTheDocument(); + }); + }); + + it('displays per-field errors on 422', async () => { + vi.mocked(patientService.getProfile).mockResolvedValueOnce({ + data: mockProfile, + }); + + const { AxiosError } = await import('axios'); + const error = new AxiosError('Validation error'); + error.response = { + status: 422, + data: { + message: 'The given data was invalid.', + errors: { phone: ['The phone field must not exceed 15 characters.'] }, + }, + } as never; + + vi.mocked(patientService.updateProfile).mockRejectedValueOnce(error); + + renderProfilePage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect( + screen.getByText('The phone field must not exceed 15 characters.') + ).toBeInTheDocument(); + }); + }); + + it('disables submit button while submitting', async () => { + vi.mocked(patientService.getProfile).mockResolvedValueOnce({ + data: mockProfile, + }); + vi.mocked(patientService.updateProfile).mockImplementation( + () => new Promise(() => {}) + ); + + renderProfilePage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Saving...' }) + ).toBeDisabled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/types/patient.ts b/frontend/src/types/patient.ts new file mode 100644 index 0000000..113e56f --- /dev/null +++ b/frontend/src/types/patient.ts @@ -0,0 +1,24 @@ +export interface PatientProfile { + patient_id: number; + name: string; + phone: string; + bpjs_number: string | null; + birth_place: string | null; + birth_date: string | null; + gender: 'laki-laki' | 'perempuan' | null; + user_id: number; +} + +export interface PatientProfileUpdatePayload { + name?: string; + phone?: string; + bpjs_number?: string | null; + birth_place?: string | null; + birth_date?: string | null; + gender?: 'laki-laki' | 'perempuan' | null; +} + +export interface ApiSuccessResponse { + message?: string; + data: T; +} \ No newline at end of file