4NK_IA_front/src/views/ExtractionView.tsx

840 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback } from 'react'
import {
Box, Typography, Paper, Card, CardContent, Chip, Button, List, ListItemText, ListItemButton,
Tooltip, Alert, Accordion, AccordionSummary, AccordionDetails, CircularProgress, TextField,
Divider, Badge, Stack, Avatar, CardHeader, Fade, Tabs, Tab,
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 { setCurrentResultIndex } from '../store/documentSlice'
import { clearFolderCache, reprocessFolder, startEnrichment, getEnrichmentStatus, deleteEntity, updateEntity } from '../services/folderApi'
import { Layout } from '../components/Layout'
export default function ExtractionView() {
const dispatch = useAppDispatch()
const { folderResults, currentResultIndex, loading } = useAppSelector((state) => state.document)
const { currentFolderHash } = useAppSelector((state) => state.document)
const [currentIndex, setCurrentIndex] = useState(currentResultIndex)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [personsDraft, setPersonsDraft] = useState<any[]>([])
const [addressesDraft, setAddressesDraft] = useState<any[]>([])
const [companiesDraft, setCompaniesDraft] = useState<any[]>([])
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)
const [entityTab, setEntityTab] = useState<'persons' | 'addresses' | 'companies' | 'contractual'>('persons')
// Utiliser les résultats du dossier pour la navigation
const currentResult = folderResults[currentIndex]
// Brouillons synchronisés au changement de résultat courant
React.useEffect(() => {
if (!currentResult) {
setPersonsDraft([]); setAddressesDraft([]); setCompaniesDraft([])
return
}
try {
const ents = (currentResult as any).extraction?.entities || {}
setPersonsDraft((Array.isArray(ents.persons) ? ents.persons : []).map((p: any) => ({ id: p.id, firstName: p.firstName || '', lastName: p.lastName || '', description: p.description || '' })))
setAddressesDraft((Array.isArray(ents.addresses) ? ents.addresses : []).map((a: any) => ({ id: a.id, street: a.street || '', postalCode: a.postalCode || '', city: a.city || '', country: a.country || '', description: a.description || '' })))
setCompaniesDraft((Array.isArray(ents.companies) ? ents.companies : []).map((c: any) => ({ id: c.id, name: c.name || '', description: c.description || '' })))
} catch {
setPersonsDraft([]); setAddressesDraft([]); setCompaniesDraft([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentResult?.fileHash])
// Choix donglet initial basé sur les données
React.useEffect(() => {
const ents = (currentResult as any)?.extraction?.entities || {}
const persons = Array.isArray(ents.persons) ? ents.persons : []
const addresses = Array.isArray(ents.addresses) ? ents.addresses : []
const companies = Array.isArray(ents.companies) ? ents.companies : []
const contractualClauses = ents.contractual && Array.isArray(ents.contractual.clauses) ? ents.contractual.clauses : []
const contractualSignatures = ents.contractual && Array.isArray(ents.contractual.signatures) ? ents.contractual.signatures : []
if (persons.length > 0) { setEntityTab('persons'); return }
if (addresses.length > 0) { setEntityTab('addresses'); return }
if (companies.length > 0) { setEntityTab('companies'); return }
if (contractualClauses.length + contractualSignatures.length > 0) { setEntityTab('contractual'); return }
setEntityTab('persons')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentResult?.fileHash])
const gotoResult = useCallback((index: number) => {
if (index >= 0 && index < folderResults.length) {
setCurrentIndex(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 as any).fileHash, type, {
index,
id: (entity as any).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 as any).fileHash, type, { index, id: (entity as any).id })
showSnackbar(`${type === 'person' ? 'Personne' : type === 'address' ? 'Adresse' : 'Entreprise'} supprimée`, 'success')
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 (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
showSnackbar(`Erreur lors de la suppression: ${message}`, '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 as any).fileHash, type)
showSnackbar(`Enrichissement ${type} démarré`, 'info')
setTimeout(async () => {
try {
const status = await getEnrichmentStatus(currentFolderHash, (currentResult as any).fileHash, type)
setEnriching(prev => ({ ...prev, [`${type}-${index}`]: (status as any).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])
if (loading) {
return (
<Layout>
<Box display="flex" alignItems="center" justifyContent="center" minHeight={200}>
<CircularProgress size={28} sx={{ mr: 2 }} />
<Typography>Chargement des fichiers du dossier</Typography>
</Box>
</Layout>
)
}
if (folderResults.length === 0) {
return (
<Layout>
<Alert severity="info">
Aucun résultat d'extraction disponible. Veuillez d'abord téléverser des documents.
</Alert>
</Layout>
)
}
if (!currentResult) {
return (
<Layout>
<Alert severity="error">Erreur: Résultat d'extraction non trouvé.</Alert>
</Layout>
)
}
const extraction = currentResult as any
const entities = (extraction.extraction as any)?.entities || {}
const contractualClauses = entities.contractual && Array.isArray(entities.contractual.clauses) ? entities.contractual.clauses : []
const contractualSignatures = entities.contractual && Array.isArray(entities.contractual.signatures) ? entities.contractual.signatures : []
return (
<Layout>
{/* Header moderne avec actions */}
<Box sx={{ mb: 4 }}>
<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 variant="body1" color="text.secondary">
Gestion et enrichissement des entités extraites
</Typography>
</Box>
<Stack direction="row" spacing={2}>
<Tooltip title="Re-traiter tous les documents du dossier">
<Button
variant="outlined"
startIcon={<Refresh />}
disabled={!currentFolderHash}
onClick={async () => {
if (!currentFolderHash) return
try {
const cleared = await clearFolderCache(currentFolderHash)
const repro = await reprocessFolder(currentFolderHash)
showSnackbar(
`Cache vidé (${(cleared as any).removed} éléments). Re-traitement lancé (${(repro as any).scheduled} fichiers).`,
'success'
)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e)
showSnackbar(`Erreur lors du re-traitement: ${message}` , 'error')
}
}}
>
Re-traiter
</Button>
</Tooltip>
</Stack>
</Stack>
</Box>
<Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', md: 'row' } }}>
{/* Sidebar */}
<Box sx={{ flex: '0 0 300px', minWidth: 0 }}>
<Card sx={{ height: 'fit-content', position: 'sticky', top: 20 }}>
<CardHeader
title="Documents"
subheader={`${folderResults.length} fichier${folderResults.length > 1 ? 's' : ''}`}
avatar={<Avatar sx={{ bgcolor: 'primary.main' }}><Description /></Avatar>}
/>
<Divider />
<List dense disablePadding>
{folderResults.map((result, index) => (
<ListItemButton
key={(result as any).fileHash}
selected={index === currentIndex}
onClick={() => gotoResult(index)}
sx={{
borderLeft: index === currentIndex ? 3 : 0,
borderLeftColor: 'primary.main',
bgcolor: index === currentIndex ? 'primary.50' : 'transparent'
}}
>
<ListItemText
primary={
<Typography
variant="body2"
sx={{
fontWeight: index === currentIndex ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{(result as any).document.fileName
}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
{new Date((result as any).document.uploadTimestamp as unknown as string).toLocaleDateString()}
</Typography>
}
/>
{index === currentIndex && (
<Check color="primary" sx={{ ml: 1 }} />
)}
</ListItemButton>
))}
</List>
</Card>
</Box>
{/* Contenu principal */}
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>
<Card sx={{ mb: 3 }}>
<CardHeader
avatar={<Avatar sx={{ bgcolor: 'primary.main' }}><FileOpen /></Avatar>}
title={extraction.document.fileName}
subheader={`Téléversé le ${new Date(extraction.document.uploadTimestamp as unknown as string).toLocaleString()}`}
action={
<Stack direction="row" spacing={1}>
<Chip
label={extraction.document.mimeType}
size="small"
variant="outlined"
color="info"
/>
<Chip
label={`${(extraction.document.fileSize / 1024 / 1024).toFixed(2)} MB`}
size="small"
variant="outlined"
color="default"
/>
</Stack>
}
/>
<CardContent>
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
icon={<Language />}
label={`Langue: ${extraction.classification.language}`}
color="info"
variant="outlined"
/>
<Chip
icon={<Verified />}
label={`Type: ${extraction.classification.documentType}`}
color="success"
variant="outlined"
/>
<Chip
icon={<Assessment />}
label={`Confiance: ${(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}%`}
color={extraction.metadata.quality.globalConfidence > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
{(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>
</Card>
{/* Tabs entités */}
<Card sx={{ mb: 2 }}>
<CardContent sx={{ pt: 1 }}>
<Tabs
value={entityTab}
onChange={(_, v) => setEntityTab(v)}
variant="scrollable"
scrollButtons="auto"
>
<Tab value="persons" label={`Personnes (${personsDraft.length})`} />
<Tab value="addresses" label={`Adresses (${addressesDraft.length})`} />
<Tab value="companies" label={`Entreprises (${companiesDraft.length})`} />
<Tab value="contractual" label={`Contractuel (${contractualClauses.length + contractualSignatures.length})`} />
</Tabs>
</CardContent>
</Card>
{/* Entités */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Personnes */}
{entityTab === 'persons' && personsDraft.length > 0 && (
<Box sx={{ flex: '1 1 300px', minWidth: 0 }}>
<Card sx={{ height: '100%' }}>
<CardHeader
title="Personnes"
subheader={`${personsDraft.length} entité${personsDraft.length > 1 ? 's' : ''} détectée${personsDraft.length > 1 ? 's' : ''}`}
avatar={<Avatar sx={{ bgcolor: 'success.main' }}><Person /></Avatar>}
action={
<Badge badgeContent={personsDraft.length} color="primary">
<Person color="action" />
</Badge>
}
/>
<Divider />
<CardContent sx={{ p: 2 }}>
<Stack spacing={2}>
{personsDraft.map((p: any, i: number) => (
<Paper key={`p-${i}`} sx={{ p: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
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>
<TextField
size="small"
fullWidth
label="Description"
multiline
rows={2}
value={p.description}
onChange={(e) => setPersonsDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], description: e.target.value }
return c
})}
/>
</Box>
<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...' :
enriching[`person-${i}`] === 'completed' ? 'Bodacc ' :
enriching[`person-${i}`] === 'error' ? 'Erreur' : '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>
))}
</Stack>
</CardContent>
</Card>
</Box>
)}
{/* Adresses */}
{entityTab === 'addresses' && addressesDraft.length > 0 && (
<Box sx={{ flex: '1 1 300px', minWidth: 0 }}>
<Card sx={{ height: '100%' }}>
<CardHeader
title="Adresses"
subheader={`${addressesDraft.length} entité${addressesDraft.length > 1 ? 's' : ''} détectée${addressesDraft.length > 1 ? 's' : ''}`}
avatar={<Avatar sx={{ bgcolor: 'warning.main' }}><LocationOn /></Avatar>}
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) => (
<Paper key={`a-${i}`} sx={{ p: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField
size="small"
fullWidth
label="Rue"
value={a.street}
onChange={(e) => setAddressesDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], street: e.target.value }
return c
})}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
label="Code postal"
value={a.postalCode}
onChange={(e) => setAddressesDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], postalCode: e.target.value }
return c
})}
/>
<TextField
size="small"
fullWidth
label="Ville"
value={a.city}
onChange={(e) => setAddressesDraft((prev) => {
const c = [...prev]
c[i] = { ...c[i], city: e.target.value }
return c
})}
/>
</Box>
<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
})}
/>
<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
})}
/>
</Box>
<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...' :
enriching[`address-${i}`] === 'completed' ? 'BAN+GéoRisque+Cadastre ' :
enriching[`address-${i}`] === 'error' ? 'Erreur' : '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>
))}
</Stack>
</CardContent>
</Card>
</Box>
)}
{/* Entreprises */}
{entityTab === 'companies' && companiesDraft.length > 0 && (
<Box sx={{ flex: '1 1 300px', minWidth: 0 }}>
<Card sx={{ height: '100%' }}>
<CardHeader
title="Entreprises"
subheader={`${companiesDraft.length} entité${companiesDraft.length > 1 ? 's' : ''} détectée${companiesDraft.length > 1 ? 's' : ''}`}
avatar={<Avatar sx={{ bgcolor: 'info.main' }}><Business /></Avatar>}
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) => (
<Paper key={`c-${i}`} sx={{ p: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box>
<TextField
size="small"
fullWidth
label="Raison sociale"
value={c.name}
onChange={(e) => setCompaniesDraft((prev) => {
const x = [...prev]
x[i] = { ...x[i], name: e.target.value }
return x
})}
/>
</Box>
<TextField
size="small"
fullWidth
label="Description"
multiline
rows={3}
value={c.description}
onChange={(e) => setCompaniesDraft((prev) => {
const x = [...prev]
x[i] = { ...x[i], description: e.target.value }
return x
})}
/>
</Box>
<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...' :
enriching[`company-${i}`] === 'completed' ? 'Inforgreffe+Societe.com ' :
enriching[`company-${i}`] === 'error' ? 'Erreur' : '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>
))}
</Stack>
</CardContent>
</Card>
</Box>
)}
{/* Contractuel */}
{entityTab === 'contractual' && (
<Box sx={{ flex: '1 1 300px', minWidth: 0 }}>
<Card sx={{ height: '100%' }}>
<CardHeader title="Contractuel" subheader={`${contractualClauses.length} clause(s), ${contractualSignatures.length} signature(s)`} />
<Divider />
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Clauses</Typography>
{contractualClauses.length === 0 ? (
<Alert severity="info">Aucune clause détectée</Alert>
) : (
<List dense>
{contractualClauses.map((c: any, i: number) => (
<ListItemText key={`clause-${i}`} primary={c?.title || c?.type || `Clause ${i + 1}`} secondary={c?.text || undefined} />
))}
</List>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>Signatures</Typography>
{contractualSignatures.length === 0 ? (
<Alert severity="info">Aucune signature détectée</Alert>
) : (
<List dense>
{contractualSignatures.map((s: any, i: number) => (
<ListItemText key={`signature-${i}`} primary={s?.signer || `Signature ${i + 1}`} secondary={s?.status || undefined} />
))}
</List>
)}
</CardContent>
</Card>
</Box>
)}
</Box>
{/* Métadonnées */}
<Card sx={{ mt: 3 }}>
<CardHeader
title="Métadonnées techniques"
avatar={<Avatar sx={{ bgcolor: 'grey.600' }}><Security /></Avatar>}
/>
<Divider />
<CardContent>
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography>Informations de traitement</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
<Box sx={{ flex: '1 1 300px' }}>
<Typography variant="body2" color="text.secondary">
Hash du fichier
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{extraction.fileHash}
</Typography>
</Box>
<Box sx={{ flex: '1 1 300px' }}>
<Typography variant="body2" color="text.secondary">
Traitement effectué
</Typography>
<Typography variant="body2">
{new Date(extraction.status.timestamp).toLocaleString()}
</Typography>
</Box>
<Box sx={{ flex: '1 1 300px' }}>
<Typography variant="body2" color="text.secondary">
Confiance globale
</Typography>
<Typography variant="body2">
{(extraction.metadata.quality.globalConfidence * 100).toFixed(1)}%
</Typography>
</Box>
{(extraction.metadata.quality as any).ollamaScore && (
<Box sx={{ flex: '1 1 300px' }}>
<Typography variant="body2" color="text.secondary">
Score IA (Ollama)
</Typography>
<Typography variant="body2">
{((extraction.metadata.quality as any).ollamaScore * 100).toFixed(1)}%
</Typography>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
{/* Texte extrait */}
<Card sx={{ mt: 3 }}>
<CardHeader
title="Texte extrait"
avatar={<Avatar sx={{ bgcolor: 'info.main' }}><TextFields /></Avatar>}
action={
<Button
size="small"
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}
</Typography>
</Paper>
</CardContent>
</Fade>
</Card>
</Box>
</Box>
<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>
)
}