840 lines
38 KiB
TypeScript
840 lines
38 KiB
TypeScript
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 d’onglet 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>
|
||
)
|
||
}
|