- 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
238 lines
5.9 KiB
TypeScript
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}
|
|
/>
|
|
)
|
|
}
|