4NK_IA_front/src/components/VirtualizedList.tsx
4NK IA aad52027c1 ci: docker_tag=dev-test
- Alignement backend: seules 4 entités retournées (persons, companies, addresses, contractual)
- Version API mise à jour à 1.0.1 dans /api/health
- Interface onglets d entités: Personnes, Adresses, Entreprises, Contractuel
- Correction erreurs TypeScript pour build stricte
- Tests et documentation mis à jour
- CHANGELOG.md mis à jour avec version 1.1.1
2025-09-18 20:07:08 +00:00

238 lines
5.9 KiB
TypeScript

/**
* Composant de liste virtualisée pour optimiser les performances avec de grandes listes
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Box, ListItem, ListItemText, CircularProgress } from '@mui/material'
interface VirtualizedListProps<T> {
items: T[]
itemHeight: number
containerHeight: number
renderItem: (item: T, index: number) => React.ReactNode
keyExtractor: (item: T, index: number) => string
loading?: boolean
onLoadMore?: () => void
threshold?: number
className?: string
style?: React.CSSProperties
}
export function VirtualizedList<T>({
items,
itemHeight,
containerHeight,
renderItem,
keyExtractor,
loading = false,
onLoadMore,
threshold = 5,
className,
style
}: VirtualizedListProps<T>) {
const [scrollTop, setScrollTop] = useState(0)
const [, setContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
// Calculer les éléments visibles
const visibleItems = useMemo(() => {
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + threshold,
items.length - 1
)
return {
startIndex: Math.max(0, startIndex - threshold),
endIndex: Math.max(0, endIndex),
items: items.slice(
Math.max(0, startIndex - threshold),
Math.max(0, endIndex + 1)
)
}
}, [scrollTop, itemHeight, containerHeight, items, threshold])
// Gestionnaire de scroll
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const target = event.target as HTMLDivElement
setScrollTop(target.scrollTop)
}, [])
// Observer pour le chargement infini
useEffect(() => {
if (!onLoadMore) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !loading) {
onLoadMore()
}
})
},
{
root: containerRef.current,
rootMargin: '100px'
}
)
// Observer le dernier élément
if (containerRef.current) {
const lastItem = containerRef.current.querySelector('[data-last-item]')
if (lastItem) {
observer.observe(lastItem)
}
}
observerRef.current = observer
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
}
}
}, [onLoadMore, loading, items.length])
// Mise à jour de la largeur du conteneur
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth)
}
}
updateWidth()
window.addEventListener('resize', updateWidth)
return () => {
window.removeEventListener('resize', updateWidth)
}
}, [])
// Calculer la hauteur totale
const totalHeight = items.length * itemHeight
// Calculer le décalage pour les éléments non visibles
const offsetY = visibleItems.startIndex * itemHeight
return (
<Box
ref={containerRef}
className={className}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative',
...style
}}
onScroll={handleScroll}
>
{/* Conteneur virtuel */}
<Box
style={{
height: totalHeight,
position: 'relative'
}}
>
{/* Éléments visibles */}
<Box
style={{
transform: `translateY(${offsetY}px)`,
position: 'absolute',
top: 0,
left: 0,
right: 0
}}
>
{visibleItems.items.map((item, index) => {
const actualIndex = visibleItems.startIndex + index
const isLastItem = actualIndex === items.length - 1
return (
<Box
key={keyExtractor(item, actualIndex)}
data-last-item={isLastItem ? 'true' : undefined}
style={{
height: itemHeight,
display: 'flex',
alignItems: 'center'
}}
>
{renderItem(item, actualIndex)}
</Box>
)
})}
</Box>
</Box>
{/* Indicateur de chargement */}
{loading && (
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
padding: 2,
backgroundColor: 'background.paper'
}}
>
<CircularProgress size={24} />
</Box>
)}
</Box>
)
}
// Composant spécialisé pour les documents
interface DocumentListProps {
documents: any[]
onDocumentClick: (document: any) => void
loading?: boolean
onLoadMore?: () => void
}
export const DocumentVirtualizedList: React.FC<DocumentListProps> = ({
documents,
onDocumentClick,
loading = false,
onLoadMore
}) => {
const renderDocument = useCallback((document: any) => (
<ListItem
component="div"
onClick={() => onDocumentClick(document)}
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<ListItemText
primary={document.fileName}
secondary={`${document.mimeType} - ${new Date(document.uploadTimestamp).toLocaleDateString()}`}
/>
</ListItem>
), [onDocumentClick])
const keyExtractor = useCallback((document: any, index: number) =>
document.id || `doc-${index}`, [])
return (
<VirtualizedList
items={documents}
itemHeight={72}
containerHeight={400}
renderItem={renderDocument}
keyExtractor={keyExtractor}
loading={loading}
onLoadMore={onLoadMore}
/>
)
}