feat(ui): refonte complete interface extraction moderne

This commit is contained in:
4NK IA 2025-09-18 16:49:48 +00:00
parent 0f9e50df71
commit 9af63f22fe
2 changed files with 657 additions and 278 deletions

View File

@ -95,7 +95,7 @@ export default function App() {
const hiddenDelay = 20000 const hiddenDelay = 20000
console.log('⏸️ [APP] Onglet caché, report du polling de', hiddenDelay, 'ms') console.log('⏸️ [APP] Onglet caché, report du polling de', hiddenDelay, 'ms')
const t = setTimeout(tick, hiddenDelay) const t = setTimeout(tick, hiddenDelay)
dispatch(setPollingInterval(t as unknown as number)) dispatch(setPollingInterval(t as any))
return return
} }
@ -108,11 +108,11 @@ export default function App() {
const factor = Math.min(4, Math.pow(2, Math.floor(pollCount / 5))) const factor = Math.min(4, Math.pow(2, Math.floor(pollCount / 5)))
const delay = base * factor const delay = base * factor
const t = setTimeout(tick, delay) const t = setTimeout(tick, delay)
dispatch(setPollingInterval(t as unknown as number)) dispatch(setPollingInterval(t as any))
} }
const t0 = setTimeout(tick, 0) const t0 = setTimeout(tick, 0)
dispatch(setPollingInterval(t0 as unknown as number)) dispatch(setPollingInterval(t0 as any))
}, },
[dispatch], [dispatch],
) )

View File

@ -1,6 +1,15 @@
import React, { useState } from 'react' import React, { useState, useCallback } from 'react'
import { Box, Typography, Paper, Card, CardContent, Chip, Button, List, ListItem, ListItemText, ListItemButton, Tooltip, Alert, Accordion, AccordionSummary, AccordionDetails, CircularProgress } from '@mui/material' import {
import { Person, LocationOn, Business, Description, Language, Verified, ExpandMore, TextFields, Assessment } from '@mui/icons-material' Box, Typography, Paper, Card, CardContent, Chip, Button, List, ListItemText, ListItemButton,
Tooltip, Alert, Accordion, AccordionSummary, AccordionDetails, CircularProgress, TextField,
Grid, Divider, Badge, Stack, Avatar, CardHeader, Fade,
Snackbar, Alert as MuiAlert
} from '@mui/material'
import {
Person, LocationOn, Business, Description, Language, Verified, ExpandMore, TextFields, Assessment,
Save, Delete, Search, Visibility, Check, Refresh,
FileDownload, FileOpen, AutoAwesome, Security
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store' import { useAppDispatch, useAppSelector } from '../store'
import { setCurrentResultIndex } from '../store/documentSlice' import { setCurrentResultIndex } from '../store/documentSlice'
import { clearFolderCache, reprocessFolder, startEnrichment, getEnrichmentStatus } from '../services/folderApi' import { clearFolderCache, reprocessFolder, startEnrichment, getEnrichmentStatus } from '../services/folderApi'
@ -13,14 +22,20 @@ export default function ExtractionView() {
const { currentFolderHash } = useAppSelector((state) => state.document) const { currentFolderHash } = useAppSelector((state) => state.document)
const [currentIndex, setCurrentIndex] = useState(currentResultIndex) const [currentIndex, setCurrentIndex] = useState(currentResultIndex)
// Utiliser les résultats du dossier pour la navigation
const currentResult = folderResults[currentIndex]
const [savingKey, setSavingKey] = useState<string | null>(null) const [savingKey, setSavingKey] = useState<string | null>(null)
const [personsDraft, setPersonsDraft] = useState<any[]>([]) const [personsDraft, setPersonsDraft] = useState<any[]>([])
const [addressesDraft, setAddressesDraft] = useState<any[]>([]) const [addressesDraft, setAddressesDraft] = useState<any[]>([])
const [companiesDraft, setCompaniesDraft] = useState<any[]>([]) const [companiesDraft, setCompaniesDraft] = useState<any[]>([])
const [enriching, setEnriching] = useState<Record<string, string>>({}) const [enriching, setEnriching] = useState<Record<string, string>>({})
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' | 'info' }>({
open: false,
message: '',
severity: 'info'
})
const [showTextExtract, setShowTextExtract] = useState(false)
// Utiliser les résultats du dossier pour la navigation
const currentResult = folderResults[currentIndex]
// Initialiser les brouillons à chaque changement de résultat courant // Initialiser les brouillons à chaque changement de résultat courant
React.useEffect(() => { React.useEffect(() => {
@ -39,12 +54,76 @@ export default function ExtractionView() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentResult?.fileHash]) }, [currentResult?.fileHash])
const gotoResult = (index: number) => { const gotoResult = useCallback((index: number) => {
if (index >= 0 && index < folderResults.length) { if (index >= 0 && index < folderResults.length) {
setCurrentIndex(index) setCurrentIndex(index)
dispatch(setCurrentResultIndex(index)) dispatch(setCurrentResultIndex(index))
} }
}, [dispatch, folderResults.length])
const showSnackbar = useCallback((message: string, severity: 'success' | 'error' | 'info' = 'info') => {
setSnackbar({ open: true, message, severity })
}, [])
const handleSaveEntity = useCallback(async (type: 'person' | 'address' | 'company', index: number, entity: any) => {
if (!currentFolderHash || !currentResult) return
try {
setSavingKey(`${type}-${index}`)
await updateEntity(currentFolderHash, currentResult.fileHash, type, {
index,
id: entity.id,
patch: entity
})
showSnackbar(`${type === 'person' ? 'Personne' : type === 'address' ? 'Adresse' : 'Entreprise'} sauvegardée`, 'success')
} catch (error: any) {
showSnackbar(`Erreur lors de la sauvegarde: ${error?.message || error}`, 'error')
} finally {
setSavingKey(null)
} }
}, [currentFolderHash, currentResult, showSnackbar])
const handleDeleteEntity = useCallback(async (type: 'person' | 'address' | 'company', index: number, entity: any) => {
if (!currentFolderHash || !currentResult) return
try {
await deleteEntity(currentFolderHash, currentResult.fileHash, type, { index, id: entity.id })
showSnackbar(`${type === 'person' ? 'Personne' : type === 'address' ? 'Adresse' : 'Entreprise'} supprimée`, 'success')
// Mettre à jour les brouillons locaux
if (type === 'person') {
setPersonsDraft(prev => prev.filter((_, i) => i !== index))
} else if (type === 'address') {
setAddressesDraft(prev => prev.filter((_, i) => i !== index))
} else {
setCompaniesDraft(prev => prev.filter((_, i) => i !== index))
}
} catch (error: any) {
showSnackbar(`Erreur lors de la suppression: ${error?.message || error}`, 'error')
}
}, [currentFolderHash, currentResult, showSnackbar])
const handleEnrichment = useCallback(async (type: 'person' | 'address' | 'company', index: number) => {
if (!currentFolderHash || !currentResult) return
try {
setEnriching(prev => ({ ...prev, [`${type}-${index}`]: 'running' }))
await startEnrichment(currentFolderHash, currentResult.fileHash, type)
showSnackbar(`Enrichissement ${type} démarré`, 'info')
setTimeout(async () => {
try {
const status = await getEnrichmentStatus(currentFolderHash, currentResult.fileHash, type)
setEnriching(prev => ({ ...prev, [`${type}-${index}`]: status.state || 'idle' }))
} catch (error) {
setEnriching(prev => ({ ...prev, [`${type}-${index}`]: 'error' }))
}
}, 2000)
} catch (error: any) {
showSnackbar(`Erreur lors de l'enrichissement: ${error?.message || error}`, 'error')
setEnriching(prev => ({ ...prev, [`${type}-${index}`]: 'error' }))
}
}, [currentFolderHash, currentResult, showSnackbar])
// Navigation supprimée // Navigation supprimée
@ -82,93 +161,123 @@ export default function ExtractionView() {
return ( return (
<Layout> <Layout>
<Box sx={{ mb: 3 }}> {/* Header moderne avec actions */}
<Typography variant="h4" gutterBottom> <Box sx={{ mb: 4 }}>
Résultats d'extraction <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Box>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'primary.main' }}>
Extraction & Analyse
</Typography> </Typography>
<Typography variant="body1" color="text.secondary">
Gestion et enrichissement des entités extraites
</Typography>
</Box>
{/* Actions de dossier */} <Stack direction="row" spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}> <Tooltip title="Re-traiter tous les documents du dossier">
<Tooltip title="Re-traiter le dossier: vide le cache puis relance l'analyse de tous les fichiers présents dans uploads/<hash>.">
<span>
<Button <Button
variant="contained" variant="outlined"
color="primary" startIcon={<Refresh />}
disabled={!currentFolderHash} disabled={!currentFolderHash}
onClick={async () => { onClick={async () => {
if (!currentFolderHash) return if (!currentFolderHash) return
try { try {
const cleared = await clearFolderCache(currentFolderHash) const cleared = await clearFolderCache(currentFolderHash)
const repro = await reprocessFolder(currentFolderHash) const repro = await reprocessFolder(currentFolderHash)
// eslint-disable-next-line no-alert showSnackbar(
alert( `Cache vidé (${cleared.removed} éléments). Re-traitement lancé (${repro.scheduled} fichiers).`,
`Cache vidé (${cleared.removed} éléments). Re-traitement lancé (${repro.scheduled} fichiers).` 'success'
) )
} catch (e: any) { } catch (e: any) {
// eslint-disable-next-line no-alert showSnackbar(`Erreur lors du re-traitement: ${e?.message || e}`, 'error')
alert(`Erreur lors du re-traitement: ${e?.message || e}`)
} }
}} }}
> >
Re-traiter le dossier Re-traiter
</Button> </Button>
</span>
</Tooltip> </Tooltip>
</Stack>
</Stack>
</Box> </Box>
{/* Navigation supprimée */} <Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', md: 'row' } }}>
</Box> {/* Sidebar de navigation moderne */}
<Box sx={{ flex: '0 0 300px', minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 3 }}> <Card sx={{ height: 'fit-content', position: 'sticky', top: 20 }}>
{/* Liste latérale de navigation avec ellipsis */} <CardHeader
<Card sx={{ flex: '0 0 320px', maxHeight: '70vh', overflow: 'auto' }}> title="Documents"
<CardContent sx={{ p: 0 }}> subheader={`${folderResults.length} fichier${folderResults.length > 1 ? 's' : ''}`}
avatar={<Avatar sx={{ bgcolor: 'primary.main' }}><Description /></Avatar>}
/>
<Divider />
<List dense disablePadding> <List dense disablePadding>
{folderResults.map((result, index) => ( {folderResults.map((result, index) => (
<ListItemButton <ListItemButton
key={result.fileHash} key={result.fileHash}
selected={index === currentIndex} selected={index === currentIndex}
onClick={() => gotoResult(index)} onClick={() => gotoResult(index)}
sx={{
borderLeft: index === currentIndex ? 3 : 0,
borderLeftColor: 'primary.main',
bgcolor: index === currentIndex ? 'primary.50' : 'transparent'
}}
> >
<Tooltip title={result.document.fileName} placement="right">
<ListItemText <ListItemText
primaryTypographyProps={{ primary={
sx: { <Typography
display: 'block', variant="body2"
maxWidth: 260, sx={{
whiteSpace: 'nowrap', fontWeight: index === currentIndex ? 600 : 400,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
}, whiteSpace: 'nowrap'
}} }}
primary={result.document.fileName} >
secondary={new Date( {result.document.fileName}
result.document.uploadTimestamp as unknown as string, </Typography>
).toLocaleString()} }
secondary={
<Typography variant="caption" color="text.secondary">
{new Date(result.document.uploadTimestamp as unknown as string).toLocaleDateString()}
</Typography>
}
/> />
</Tooltip> {index === currentIndex && (
<Check color="primary" sx={{ ml: 1 }} />
)}
</ListItemButton> </ListItemButton>
))} ))}
</List> </List>
</CardContent>
</Card> </Card>
</Box>
{/* Contenu principal */}
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}> <Box sx={{ flex: '1 1 auto', minWidth: 0 }}>
{/* Informations du document courant */} {/* Header du document avec métadonnées */}
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
<CardContent> <CardHeader
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}> avatar={<Avatar sx={{ bgcolor: 'primary.main' }}><FileOpen /></Avatar>}
<Description color="primary" /> title={extraction.document.fileName}
<Typography variant="h6">{extraction.document.fileName}</Typography> subheader={`Téléversé le ${new Date(extraction.document.uploadTimestamp as unknown as string).toLocaleString()}`}
<Chip label={extraction.document.mimeType} size="small" variant="outlined" /> action={
<Stack direction="row" spacing={1}>
<Chip
label={extraction.document.mimeType}
size="small"
variant="outlined"
color="info"
/>
<Chip <Chip
label={`${(extraction.document.fileSize / 1024 / 1024).toFixed(2)} MB`} label={`${(extraction.document.fileSize / 1024 / 1024).toFixed(2)} MB`}
size="small" size="small"
variant="outlined" variant="outlined"
color="default"
/> />
</Box> </Stack>
}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> />
<CardContent>
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip <Chip
icon={<Language />} icon={<Language />}
label={`Langue: ${extraction.classification.language}`} label={`Langue: ${extraction.classification.language}`}
@ -187,211 +296,481 @@ export default function ExtractionView() {
color={extraction.metadata.quality.globalConfidence > 0.8 ? 'success' : 'warning'} color={extraction.metadata.quality.globalConfidence > 0.8 ? 'success' : 'warning'}
variant="outlined" variant="outlined"
/> />
</Box> {(extraction.metadata.quality as any).ollamaScore && (
<Chip
icon={<AutoAwesome />}
label={`IA: ${((extraction.metadata.quality as any).ollamaScore * 100).toFixed(1)}%`}
color="secondary"
variant="outlined"
/>
)}
</Stack>
</CardContent> </CardContent>
</Card> </Card>
{/* Texte extrait */} {/* Texte extrait avec toggle */}
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
<CardContent> <CardHeader
<Typography variant="h6" gutterBottom> title="Texte extrait"
<TextFields sx={{ mr: 1, verticalAlign: 'middle' }} /> avatar={<Avatar sx={{ bgcolor: 'info.main' }}><TextFields /></Avatar>}
Texte extrait action={
</Typography> <Button
<Paper sx={{ p: 2, bgcolor: 'grey.50', maxHeight: 300, overflow: 'auto' }}> size="small"
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}> startIcon={showTextExtract ? <Visibility /> : <Visibility />}
onClick={() => setShowTextExtract(!showTextExtract)}
>
{showTextExtract ? 'Masquer' : 'Afficher'}
</Button>
}
/>
<Fade in={showTextExtract}>
<CardContent sx={{ pt: 0 }}>
<Paper
sx={{
p: 2,
bgcolor: 'grey.50',
maxHeight: 300,
overflow: 'auto',
border: '1px solid',
borderColor: 'grey.200'
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
{extraction.extraction.text.raw} {extraction.extraction.text.raw}
</Typography> </Typography>
</Paper> </Paper>
</CardContent> </CardContent>
</Fade>
</Card> </Card>
{/* Entités extraites */} {/* Entités extraites avec design moderne */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Personnes */} {/* Personnes */}
{personsDraft.length > 0 && ( {personsDraft.length > 0 && (
<Card sx={{ flex: '1 1 300px' }}> <Box sx={{ flex: '1 1 300px', minWidth: 0 }}>
<CardContent> <Card sx={{ height: '100%' }}>
<Typography variant="h6" gutterBottom> <CardHeader
<Person sx={{ mr: 1, verticalAlign: 'middle' }} /> title="Personnes"
Personnes ({personsDraft.length}) subheader={`${personsDraft.length} entité${personsDraft.length > 1 ? 's' : ''} détectée${personsDraft.length > 1 ? 's' : ''}`}
</Typography> avatar={<Avatar sx={{ bgcolor: 'success.main' }}><Person /></Avatar>}
<Box sx={{ mb: 1, color: 'text.secondary' }}>Au clic sur "Collecter", la collecte externe démarre si nécessaire puis génère un PDF dans le dossier.</Box> action={
<List dense> <Badge badgeContent={personsDraft.length} color="primary">
<Person color="action" />
</Badge>
}
/>
<Divider />
<CardContent sx={{ p: 2 }}>
<Stack spacing={2}>
{personsDraft.map((p: any, i: number) => ( {personsDraft.map((p: any, i: number) => (
<ListItem key={`p-${i}`} disableGutters sx={{ py: 0.5 }}> <Paper key={`p-${i}`} sx={{ p: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, width: '100%', flexWrap: 'wrap' }}> <Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: '1 1 auto', minWidth: 0, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<input style={{ padding: 4, width: 140 }} placeholder="Prénom" value={p.firstName} onChange={(e)=> setPersonsDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], firstName:e.target.value}; return c})} /> <Box sx={{ display: 'flex', gap: 1 }}>
<input style={{ padding: 4, width: 160 }} placeholder="Nom" value={p.lastName} onChange={(e)=> setPersonsDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], lastName:e.target.value}; return c})} /> <TextField
<input style={{ padding: 4, width: 260 }} placeholder="Description" value={p.description} onChange={(e)=> setPersonsDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], description:e.target.value}; return c})} /> size="small"
fullWidth
label="Prénom"
value={p.firstName}
onChange={(e) => setPersonsDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], firstName: e.target.value }
return c
})}
/>
<TextField
size="small"
fullWidth
label="Nom"
value={p.lastName}
onChange={(e) => setPersonsDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], lastName: e.target.value }
return c
})}
/>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, whiteSpace: 'nowrap' }}> <TextField
<Button size="small" variant="outlined" size="small"
onClick={async ()=>{ fullWidth
setEnriching((s)=>({ ...s, [`p-${i}`]: 'running' })) label="Description"
await startEnrichment(currentFolderHash!, extraction.fileHash, 'person') multiline
// sondage court rows={2}
setTimeout(async ()=>{ value={p.description}
const st = await getEnrichmentStatus(currentFolderHash!, extraction.fileHash, 'person') onChange={(e) => setPersonsDraft((prev) => {
setEnriching((s)=>({ ...s, [`p-${i}`]: st.state || 'idle' })) const c = [...prev]
}, 1800) c[i] = { ...c[i], description: e.target.value }
}} return c
>{enriching[`p-${i}`]==='running' ? 'Collecte…' : 'Collecter'}</Button> })}
<Button size="small" component="a" href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/person/pdf`} target="_blank" rel="noopener noreferrer">Voir PDF</Button> />
<Button size="small" component="a" href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/person/status`} target="_blank" rel="noopener noreferrer">Voir JSON</Button>
<Button size="small" variant="outlined" disabled={savingKey===`p-${i}`}
onClick={async ()=>{
try{
setSavingKey(`p-${i}`)
await updateEntity(currentFolderHash!, extraction.fileHash, 'person', { index: i, id: p.id, patch: { firstName: p.firstName, lastName: p.lastName, description: p.description || '' } })
} finally { setSavingKey(null) }
}}>Enregistrer</Button>
<Button size="small" color="error"
onClick={async ()=>{
await deleteEntity(currentFolderHash!, extraction.fileHash, 'person', { index: i, id: p.id })
}}>Supprimer</Button>
</Box> </Box>
</Box>
</ListItem> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button
size="small"
variant="outlined"
startIcon={enriching[`person-${i}`] === 'running' ? <CircularProgress size={16} /> : <Search />}
disabled={enriching[`person-${i}`] === 'running'}
onClick={() => handleEnrichment('person', i)}
>
{enriching[`person-${i}`] === 'running' ? 'Collecte...' : 'Enrichir'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<FileDownload />}
component="a"
href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/person/pdf`}
target="_blank"
rel="noopener noreferrer"
>
PDF
</Button>
<Button
size="small"
variant="contained"
startIcon={savingKey === `person-${i}` ? <CircularProgress size={16} /> : <Save />}
disabled={savingKey === `person-${i}`}
onClick={() => handleSaveEntity('person', i, p)}
>
Sauver
</Button>
<Button
size="small"
color="error"
startIcon={<Delete />}
onClick={() => handleDeleteEntity('person', i, p)}
>
Supprimer
</Button>
</Stack>
</Stack>
</Paper>
))} ))}
</List> </Stack>
</CardContent> </CardContent>
</Card> </Card>
</Grid>
)} )}
{/* Adresses */} {/* Adresses */}
{addressesDraft.length > 0 && ( {addressesDraft.length > 0 && (
<Card sx={{ flex: '1 1 300px' }}> <Grid item xs={12} lg={4}>
<CardContent> <Card sx={{ height: '100%' }}>
<Typography variant="h6" gutterBottom> <CardHeader
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} /> title="Adresses"
Adresses ({addressesDraft.length}) subheader={`${addressesDraft.length} entité${addressesDraft.length > 1 ? 's' : ''} détectée${addressesDraft.length > 1 ? 's' : ''}`}
</Typography> avatar={<Avatar sx={{ bgcolor: 'warning.main' }}><LocationOn /></Avatar>}
<List dense> action={
<Badge badgeContent={addressesDraft.length} color="primary">
<LocationOn color="action" />
</Badge>
}
/>
<Divider />
<CardContent sx={{ p: 2 }}>
<Stack spacing={2}>
{addressesDraft.map((a: any, i: number) => ( {addressesDraft.map((a: any, i: number) => (
<ListItem key={`a-${i}`} disableGutters sx={{ py: 0.5 }}> <Paper key={`a-${i}`} sx={{ p: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, width: '100%', flexWrap: 'wrap' }}> <Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: '1 1 auto', minWidth: 0, flexWrap: 'wrap' }}> <Grid container spacing={1}>
<input style={{ padding: 4, width: 240 }} placeholder="Rue" value={a.street} onChange={(e)=> setAddressesDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], street:e.target.value}; return c})} /> <Grid item xs={12}>
<input style={{ padding: 4, width: 100 }} placeholder="CP" value={a.postalCode} onChange={(e)=> setAddressesDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], postalCode:e.target.value}; return c})} /> <TextField
<input style={{ padding: 4, width: 180 }} placeholder="Ville" value={a.city} onChange={(e)=> setAddressesDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], city:e.target.value}; return c})} /> size="small"
<input style={{ padding: 4, width: 140 }} placeholder="Pays" value={a.country} onChange={(e)=> setAddressesDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], country:e.target.value}; return c})} /> fullWidth
<input style={{ padding: 4, width: 260 }} placeholder="Description" value={a.description} onChange={(e)=> setAddressesDraft((prev)=>{ const c=[...prev]; c[i]={...c[i], description:e.target.value}; return c})} /> label="Rue"
</Box> value={a.street}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, whiteSpace: 'nowrap' }}> onChange={(e) => setAddressesDraft((prev) => {
<Button size="small" variant="outlined" const c = [...prev]
onClick={async ()=>{ c[i] = { ...c[i], street: e.target.value }
setEnriching((s)=>({ ...s, [`a-${i}`]: 'running' })) return c
await startEnrichment(currentFolderHash!, extraction.fileHash, 'address') })}
setTimeout(async ()=>{ />
const st = await getEnrichmentStatus(currentFolderHash!, extraction.fileHash, 'address') </Grid>
setEnriching((s)=>({ ...s, [`a-${i}`]: st.state || 'idle' })) <Grid item xs={4}>
}, 1800) <TextField
}} size="small"
>{enriching[`a-${i}`]==='running' ? 'Collecte…' : 'Collecter'}</Button> fullWidth
<Button size="small" component="a" href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/address/pdf`} target="_blank" rel="noopener noreferrer">Voir PDF</Button> label="Code postal"
<Button size="small" component="a" href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/address/status`} target="_blank" rel="noopener noreferrer">Voir JSON</Button> value={a.postalCode}
<Button size="small" variant="outlined" disabled={savingKey===`a-${i}`} onChange={(e) => setAddressesDraft((prev) => {
onClick={async ()=>{ const c = [...prev]
try{ c[i] = { ...c[i], postalCode: e.target.value }
setSavingKey(`a-${i}`) return c
await updateEntity(currentFolderHash!, extraction.fileHash, 'address', { index: i, id: a.id, patch: { street: a.street, city: a.city, postalCode: a.postalCode, country: a.country, description: a.description || '' } }) })}
} finally { setSavingKey(null) } />
}}>Enregistrer</Button> </Grid>
<Button size="small" color="error" <Grid item xs={8}>
onClick={async ()=>{ <TextField
await deleteEntity(currentFolderHash!, extraction.fileHash, 'address', { index: i, id: a.id }) size="small"
}}>Supprimer</Button> fullWidth
</Box> label="Ville"
</Box> value={a.city}
</ListItem> onChange={(e) => setAddressesDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], city: e.target.value }
return c
})}
/>
</Grid>
<Grid item xs={6}>
<TextField
size="small"
fullWidth
label="Pays"
value={a.country}
onChange={(e) => setAddressesDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], country: e.target.value }
return c
})}
/>
</Grid>
<Grid item xs={12}>
<TextField
size="small"
fullWidth
label="Description"
multiline
rows={2}
value={a.description}
onChange={(e) => setAddressesDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], description: e.target.value }
return c
})}
/>
</Grid>
</Grid>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button
size="small"
variant="outlined"
startIcon={enriching[`address-${i}`] === 'running' ? <CircularProgress size={16} /> : <Search />}
disabled={enriching[`address-${i}`] === 'running'}
onClick={() => handleEnrichment('address', i)}
>
{enriching[`address-${i}`] === 'running' ? 'Collecte...' : 'Enrichir'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<FileDownload />}
component="a"
href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/address/pdf`}
target="_blank"
rel="noopener noreferrer"
>
PDF
</Button>
<Button
size="small"
variant="contained"
startIcon={savingKey === `address-${i}` ? <CircularProgress size={16} /> : <Save />}
disabled={savingKey === `address-${i}`}
onClick={() => handleSaveEntity('address', i, a)}
>
Sauver
</Button>
<Button
size="small"
color="error"
startIcon={<Delete />}
onClick={() => handleDeleteEntity('address', i, a)}
>
Supprimer
</Button>
</Stack>
</Stack>
</Paper>
))} ))}
</List> </Stack>
</CardContent> </CardContent>
</Card> </Card>
</Grid>
)} )}
{/* Entreprises */} {/* Entreprises */}
{companiesDraft.length > 0 && ( {companiesDraft.length > 0 && (
<Card sx={{ flex: '1 1 300px' }}> <Grid item xs={12} lg={4}>
<CardContent> <Card sx={{ height: '100%' }}>
<Typography variant="h6" gutterBottom> <CardHeader
<Business sx={{ mr: 1, verticalAlign: 'middle' }} /> title="Entreprises"
Entreprises ({companiesDraft.length}) subheader={`${companiesDraft.length} entité${companiesDraft.length > 1 ? 's' : ''} détectée${companiesDraft.length > 1 ? 's' : ''}`}
</Typography> avatar={<Avatar sx={{ bgcolor: 'info.main' }}><Business /></Avatar>}
<List dense> action={
<Badge badgeContent={companiesDraft.length} color="primary">
<Business color="action" />
</Badge>
}
/>
<Divider />
<CardContent sx={{ p: 2 }}>
<Stack spacing={2}>
{companiesDraft.map((c: any, i: number) => ( {companiesDraft.map((c: any, i: number) => (
<ListItem key={`c-${i}`} disableGutters sx={{ py: 0.5 }}> <Paper key={`c-${i}`} sx={{ p: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, width: '100%', flexWrap: 'wrap' }}> <Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: '1 1 auto', minWidth: 0, flexWrap: 'wrap' }}> <Grid container spacing={1}>
<input style={{ padding: 4, width: 300 }} placeholder="Raison sociale" value={c.name} onChange={(e)=> setCompaniesDraft((prev)=>{ const x=[...prev]; x[i]={...x[i], name:e.target.value}; return x})} /> <Grid item xs={12}>
<input style={{ padding: 4, width: 260 }} placeholder="Description" value={c.description} onChange={(e)=> setCompaniesDraft((prev)=>{ const x=[...prev]; x[i]={...x[i], description:e.target.value}; return x})} /> <TextField
</Box> size="small"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, whiteSpace: 'nowrap' }}> fullWidth
<Button size="small" variant="outlined" label="Raison sociale"
onClick={async ()=>{ value={c.name}
setEnriching((s)=>({ ...s, [`c-${i}`]: 'running' })) onChange={(e) => setCompaniesDraft((prev) => {
await startEnrichment(currentFolderHash!, extraction.fileHash, 'company') const x = [...prev]
setTimeout(async ()=>{ x[i] = { ...x[i], name: e.target.value }
const st = await getEnrichmentStatus(currentFolderHash!, extraction.fileHash, 'company') return x
setEnriching((s)=>({ ...s, [`c-${i}`]: st.state || 'idle' })) })}
}, 1800) />
}} </Grid>
>{enriching[`c-${i}`]==='running' ? 'Collecte…' : 'Collecter'}</Button> <Grid item xs={12}>
<Button size="small" component="a" href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/company/pdf`} target="_blank" rel="noopener noreferrer">Voir PDF</Button> <TextField
<Button size="small" component="a" href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/company/status`} target="_blank" rel="noopener noreferrer">Voir JSON</Button> size="small"
<Button size="small" variant="outlined" disabled={savingKey===`c-${i}`} fullWidth
onClick={async ()=>{ label="Description"
try{ multiline
setSavingKey(`c-${i}`) rows={3}
await updateEntity(currentFolderHash!, extraction.fileHash, 'company', { index: i, id: c.id, patch: { name: c.name, description: c.description || '' } }) value={c.description}
} finally { setSavingKey(null) } onChange={(e) => setCompaniesDraft((prev) => {
}}>Enregistrer</Button> const x = [...prev]
<Button size="small" color="error" x[i] = { ...x[i], description: e.target.value }
onClick={async ()=>{ return x
await deleteEntity(currentFolderHash!, extraction.fileHash, 'company', { index: i, id: c.id }) })}
}}>Supprimer</Button> />
</Box> </Grid>
</Box> </Grid>
</ListItem>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button
size="small"
variant="outlined"
startIcon={enriching[`company-${i}`] === 'running' ? <CircularProgress size={16} /> : <Search />}
disabled={enriching[`company-${i}`] === 'running'}
onClick={() => handleEnrichment('company', i)}
>
{enriching[`company-${i}`] === 'running' ? 'Collecte...' : 'Enrichir'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<FileDownload />}
component="a"
href={`/api/folders/${currentFolderHash}/files/${extraction.fileHash}/enrich/company/pdf`}
target="_blank"
rel="noopener noreferrer"
>
PDF
</Button>
<Button
size="small"
variant="contained"
startIcon={savingKey === `company-${i}` ? <CircularProgress size={16} /> : <Save />}
disabled={savingKey === `company-${i}`}
onClick={() => handleSaveEntity('company', i, c)}
>
Sauver
</Button>
<Button
size="small"
color="error"
startIcon={<Delete />}
onClick={() => handleDeleteEntity('company', i, c)}
>
Supprimer
</Button>
</Stack>
</Stack>
</Paper>
))} ))}
</List> </Stack>
</CardContent> </CardContent>
</Card> </Card>
</Grid>
)} )}
</Box> </Grid>
{/* Métadonnées détaillées */} {/* Métadonnées détaillées */}
<Card sx={{ mt: 3 }}> <Card sx={{ mt: 3 }}>
<CardHeader
title="Métadonnées techniques"
avatar={<Avatar sx={{ bgcolor: 'grey.600' }}><Security /></Avatar>}
/>
<Divider />
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom>
Métadonnées détaillées
</Typography>
<Accordion> <Accordion>
<AccordionSummary expandIcon={<ExpandMore />}> <AccordionSummary expandIcon={<ExpandMore />}>
<Typography>Informations de traitement</Typography> <Typography>Informations de traitement</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Grid container spacing={2}>
<Typography variant="body2"> <Grid item xs={12} sm={6}>
<strong>Hash du fichier:</strong> {extraction.fileHash} <Typography variant="body2" color="text.secondary">
Hash du fichier
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{extraction.fileHash}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="text.secondary">
Traitement effectué
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
<strong>Timestamp:</strong>{' '}
{new Date(extraction.status.timestamp).toLocaleString()} {new Date(extraction.status.timestamp).toLocaleString()}
</Typography> </Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="text.secondary">
Confiance globale
</Typography>
<Typography variant="body2"> <Typography variant="body2">
<strong>Confiance globale:</strong>{' '}
{(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}% {(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}%
</Typography> </Typography>
</Box> </Grid>
{(extraction.metadata.quality as any).ollamaScore && (
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="text.secondary">
Score IA (Ollama)
</Typography>
<Typography variant="body2">
{((extraction.metadata.quality as any).ollamaScore * 100).toFixed(1)}%
</Typography>
</Grid>
)}
</Grid>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
</CardContent> </CardContent>
</Card> </Card>
</Box> </Grid>
</Box> </Grid>
{/* Snackbar pour les notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<MuiAlert
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</MuiAlert>
</Snackbar>
</Layout> </Layout>
) )
} }