diff --git a/app/epd/components/epd-header.tsx b/app/epd/components/epd-header.tsx
index 2e195cb..705e9d4 100644
--- a/app/epd/components/epd-header.tsx
+++ b/app/epd/components/epd-header.tsx
@@ -1,5 +1,5 @@
"use client";
-import React from 'react';
+import React, { useMemo, useCallback, memo } from 'react';
import { Search, MoreVertical, Mic } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { usePatientContext } from './patient-context';
@@ -37,12 +37,71 @@ function StatusBadge({ status }: { status?: string }) {
);
}
-export function EPDHeader({ className = "" }: EPDHeaderProps) {
+export const EPDHeader = memo(function EPDHeader({ className = "" }: EPDHeaderProps) {
const { patient } = usePatientContext();
const router = useRouter();
const pathname = usePathname();
- const handleNewReportClick = () => {
+ // Memoize patient display data to prevent recalculation on every render
+ const patientDisplay = useMemo(() => {
+ if (!patient) return null;
+
+ // Extract patient data
+ const name = patient.name?.[0];
+ const fullName = name
+ ? [
+ ...(name.prefix || []),
+ ...(name.given || []),
+ name.family,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ : '';
+
+ // Extract status from extension
+ const statusExtension = (patient as any)?.extension?.find(
+ (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status'
+ );
+ const status = statusExtension?.valueCode;
+
+ // Extract birth date
+ const birthDate = patient.birthDate
+ ? new Date(patient.birthDate).toLocaleDateString('nl-NL', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ })
+ : null;
+
+ // Extract BSN from identifiers
+ const bsnIdentifier = patient.identifier?.find(
+ (id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' ||
+ id.system?.includes('bsn') ||
+ id.type?.coding?.[0]?.code === 'BSN'
+ );
+ const bsn = bsnIdentifier?.value;
+
+ // Extract last modified
+ const lastModified = patient.meta?.lastUpdated
+ ? new Date(patient.meta.lastUpdated).toLocaleString('nl-NL', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ : null;
+
+ // Check if John Doe
+ const isJohnDoe = (patient as any)?.extension?.find(
+ (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe'
+ )?.valueBoolean;
+
+ return { fullName, status, birthDate, bsn, lastModified, isJohnDoe };
+ }, [patient]);
+
+ // Stabilize event handler with useCallback
+ const handleNewReportClick = useCallback(() => {
if (!patient?.id) return;
const rapportagePath = `/epd/patients/${patient.id}/rapportage`;
const onRapportagePage = pathname?.startsWith(rapportagePath);
@@ -58,58 +117,10 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
}
router.push(`${rapportagePath}#rapportage-composer`);
- };
+ }, [patient?.id, pathname, router]);
- // Extract patient data
- const name = patient?.name?.[0];
- const fullName = name
- ? [
- ...(name.prefix || []),
- ...(name.given || []),
- name.family,
- ]
- .filter(Boolean)
- .join(' ')
- : '';
-
- // Extract status from extension
- const statusExtension = (patient as any)?.extension?.find(
- (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status'
- );
- const status = statusExtension?.valueCode;
-
- // Extract birth date
- const birthDate = patient?.birthDate
- ? new Date(patient.birthDate).toLocaleDateString('nl-NL', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- })
- : null;
-
- // Extract BSN from identifiers
- const bsnIdentifier = patient?.identifier?.find(
- (id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' ||
- id.system?.includes('bsn') ||
- id.type?.coding?.[0]?.code === 'BSN'
- );
- const bsn = bsnIdentifier?.value;
-
- // Extract last modified
- const lastModified = patient?.meta?.lastUpdated
- ? new Date(patient.meta.lastUpdated).toLocaleString('nl-NL', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- })
- : null;
-
- // Check if John Doe
- const isJohnDoe = (patient as any)?.extension?.find(
- (ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe'
- )?.valueBoolean;
+ // Destructure memoized values for cleaner JSX
+ const { fullName, status, birthDate, bsn, lastModified, isJohnDoe } = patientDisplay || {};
return (
@@ -179,4 +190,4 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
);
-}
+});
diff --git a/app/epd/components/epd-sidebar.tsx b/app/epd/components/epd-sidebar.tsx
index 48635ca..c60e2d6 100644
--- a/app/epd/components/epd-sidebar.tsx
+++ b/app/epd/components/epd-sidebar.tsx
@@ -1,7 +1,8 @@
"use client";
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
+import { cn } from '@/lib/utils';
import {
Users,
Settings,
@@ -53,22 +54,119 @@ const level2NavigationItems: NavigationItem[] = [
{ id: "rapportage", name: "Rapportage", icon: FileBarChart, href: "/rapportage" },
];
+// Memoized sidebar item component to prevent unnecessary re-renders
+interface SidebarItemProps {
+ item: NavigationItem;
+ isActive: boolean;
+ isCollapsed: boolean;
+ onClick: () => void;
+}
+
+const SidebarItem = memo(function SidebarItem({ item, isActive, isCollapsed, onClick }: SidebarItemProps) {
+ const Icon = item.icon;
+
+ return (
+
+
+
+
+
+
+ {!isCollapsed && (
+
+
+ {item.name}
+
+ {item.badge && (
+
+ {item.badge}
+
+ )}
+
+ )}
+
+ {/* Tooltip for collapsed state */}
+ {isCollapsed && (
+
+ )}
+
+
+ );
+}, (prev, next) => {
+ // Custom comparison - only re-render if these props change
+ return prev.item.href === next.item.href &&
+ prev.isActive === next.isActive &&
+ prev.isCollapsed === next.isCollapsed;
+});
+
export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
// Context detection: Level 2 if URL contains /patients/[id]
- const isPatientContext = pathname.match(/\/epd\/patients\/[^\/]+/);
+ const isPatientContext = pathname?.match(/\/epd\/patients\/[^\/]+/);
const patientId = isPatientContext ? pathname.split('/')[3] : null;
- // Determine which navigation items to show
- const navigationItems = isPatientContext
- ? level2NavigationItems.map(item => ({
+ // Memoize navigation items to prevent recreation on every render
+ const navigationItems = useMemo(() => {
+ if (isPatientContext) {
+ return level2NavigationItems.map(item => ({
...item,
href: `/epd/patients/${patientId}${item.href}`
- }))
- : level1NavigationItems;
+ }));
+ }
+ return level1NavigationItems;
+ }, [isPatientContext, patientId]);
+
+ // Stabilize event handlers with useCallback
+ const toggleSidebar = useCallback(() => {
+ setIsOpen(prev => !prev);
+ }, []);
+
+ const toggleCollapse = useCallback(() => {
+ setIsCollapsed(prev => !prev);
+ }, []);
+
+ const handleItemClick = useCallback(() => {
+ if (window.innerWidth < 768) {
+ setIsOpen(false);
+ }
+ }, []);
+
+ // Memoize isActive check function
+ const getIsActive = useCallback((item: NavigationItem): boolean => {
+ if (item.id === 'dashboard') {
+ return pathname === item.href;
+ }
+ return pathname === item.href || Boolean(item.href && pathname?.startsWith(item.href + '/'));
+ }, [pathname]);
// Auto-open sidebar on desktop
useEffect(() => {
@@ -85,15 +183,6 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
return () => window.removeEventListener('resize', handleResize);
}, []);
- const toggleSidebar = () => setIsOpen(!isOpen);
- const toggleCollapse = () => setIsCollapsed(!isCollapsed);
-
- const handleItemClick = () => {
- if (window.innerWidth < 768) {
- setIsOpen(false);
- }
- };
-
// Get user initials
const userInitials = userName
? userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
@@ -182,71 +271,15 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
)}
diff --git a/app/epd/components/patient-context.tsx b/app/epd/components/patient-context.tsx
index 6b5a29d..7555df9 100644
--- a/app/epd/components/patient-context.tsx
+++ b/app/epd/components/patient-context.tsx
@@ -29,10 +29,15 @@ export function PatientProvider({ children }: { children: React.ReactNode }) {
// Hook to inject patient data into context from nested layouts
export function useSetPatient(patient: FHIRPatient | null) {
- const { setPatient } = usePatientContext();
+ const { patient: currentPatient, setPatient } = usePatientContext();
useEffect(() => {
- setPatient(patient);
- return () => setPatient(null); // Cleanup when unmounting
- }, [patient, setPatient]);
+ // Only update if patient ID changed - prevents flashing during navigation
+ if (patient?.id !== currentPatient?.id) {
+ setPatient(patient);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only depend on ID, not full object
+ }, [patient?.id, currentPatient?.id, setPatient]);
+
+ // No cleanup - keep patient in context during navigation to prevent flashing
}
diff --git a/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx b/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx
index 75c4999..b51c551 100644
--- a/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx
+++ b/app/epd/patients/[id]/intakes/[intakeId]/components/intake-tabs.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useMemo, useCallback, memo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
@@ -9,11 +10,48 @@ interface IntakeTabsProps {
intakeId: string;
}
+interface Tab {
+ name: string;
+ href: string;
+ exact?: boolean;
+}
+
+// Memoized tab item component to prevent unnecessary re-renders
+interface TabItemProps {
+ tab: Tab;
+ isActive: boolean;
+}
+
+const TabItem = memo(function TabItem({ tab, isActive }: TabItemProps) {
+ return (
+
+ {tab.name}
+
+ );
+}, (prev, next) => {
+ // Only re-render if href or active state changes
+ return prev.tab.href === next.tab.href && prev.isActive === next.isActive;
+});
+
export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) {
const pathname = usePathname();
- const baseUrl = `/epd/patients/${patientId}/intakes/${intakeId}`;
- const tabs = [
+ // Memoize base URL to prevent recalculation
+ const baseUrl = useMemo(
+ () => `/epd/patients/${patientId}/intakes/${intakeId}`,
+ [patientId, intakeId]
+ );
+
+ // Memoize tabs array to prevent recreation on every render
+ const tabs = useMemo(() => [
{ name: 'Algemeen', href: baseUrl, exact: true },
{ name: 'Contactmomenten', href: `${baseUrl}/contacts` },
{ name: 'Kindcheck', href: `${baseUrl}/kindcheck` },
@@ -23,31 +61,26 @@ export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) {
{ name: 'ROM', href: `${baseUrl}/rom` },
{ name: 'Diagnose', href: `${baseUrl}/diagnosis` },
{ name: 'Behandeladvies', href: `${baseUrl}/behandeladvies` },
- ];
+ ], [baseUrl]);
+
+ // Memoize isActive check function
+ const getIsActive = useCallback((tab: Tab) => {
+ if (tab.exact) {
+ return pathname === tab.href;
+ }
+ return pathname?.startsWith(tab.href);
+ }, [pathname]);
return (
);