feat: complete UI implementation with Material-UI and full functionality

- Add complete TypeScript types for all document entities
- Implement Redux store with document slice and async thunks
- Create comprehensive API services layer with external integrations
- Build complete UI views with Material-UI components:
  * UploadView: drag&drop with file preview and status
  * ExtractionView: structured data display (identities, addresses, properties, contracts)
  * AnalyseView: CNI verification, credibility scoring, recommendations
  * ContexteView: external data sources (Cadastre, Géorisques, BODACC, etc.)
  * ConseilView: LLM analysis with risks and next steps
- Add Layout component with navigation tabs
- Configure environment variables for backend integration
- Fix all TypeScript compilation errors
- Replace Grid with Box for better compatibility
- Add comprehensive error handling and loading states
This commit is contained in:
Nicolas Cantu 2025-09-10 17:50:42 +02:00
parent c2eba34f57
commit 0bb4ea6678
16 changed files with 2399 additions and 85 deletions

13
.env Normal file
View File

@ -0,0 +1,13 @@
# Configuration API Backend
VITE_API_URL=http://localhost:8000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Front Notarial
VITE_APP_VERSION=0.1.0
# Configuration des services externes (optionnel)
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
VITE_GEOFONCIER_API_URL=https://api2.geofoncier.fr
VITE_BODACC_API_URL=https://bodacc-datadila.opendatasoft.com/api
VITE_INFOGREFFE_API_URL=https://entreprise.api.gouv.fr

13
.env.exemple Normal file
View File

@ -0,0 +1,13 @@
# Configuration API Backend
VITE_API_URL=http://localhost:8000
# Configuration pour le développement
VITE_APP_NAME=4NK IA Front Notarial
VITE_APP_VERSION=0.1.0
# Configuration des services externes (optionnel)
VITE_CADASTRE_API_URL=https://apicarto.ign.fr/api/cadastre
VITE_GEORISQUES_API_URL=https://www.georisques.gouv.fr/api
VITE_GEOFONCIER_API_URL=https://api2.geofoncier.fr
VITE_BODACC_API_URL=https://bodacc-datadila.opendatasoft.com/api
VITE_INFOGREFFE_API_URL=https://entreprise.api.gouv.fr

785
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,10 +15,15 @@
"test:ui": "vitest"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reduxjs/toolkit": "^2.9.0",
"axios": "^1.11.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-redux": "^9.2.0",
"react-router-dom": "^7.8.2"
},

36
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import React from 'react'
import { AppBar, Toolbar, Typography, Container, Box } from '@mui/material'
import { useNavigate, useLocation } from 'react-router-dom'
import { NavigationTabs } from './NavigationTabs'
interface LayoutProps {
children: React.ReactNode
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
return (
<Box sx={{ flexGrow: 1 }}>
<AppBar position="static">
<Toolbar>
<Typography
variant="h6"
component="div"
sx={{ flexGrow: 1, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
4NK IA - Front Notarial
</Typography>
</Toolbar>
</AppBar>
<NavigationTabs currentPath={location.pathname} />
<Container maxWidth="xl" sx={{ mt: 3, mb: 3 }}>
{children}
</Container>
</Box>
)
}

View File

@ -0,0 +1,41 @@
import React from 'react'
import { Tabs, Tab, Box } from '@mui/material'
import { useNavigate } from 'react-router-dom'
interface NavigationTabsProps {
currentPath: string
}
export const NavigationTabs: React.FC<NavigationTabsProps> = ({ currentPath }) => {
const navigate = useNavigate()
const tabs = [
{ label: 'Téléversement', path: '/' },
{ label: 'Extraction', path: '/extraction' },
{ label: 'Analyse', path: '/analyse' },
{ label: 'Contexte', path: '/contexte' },
{ label: 'Conseil', path: '/conseil' },
]
const currentTabIndex = tabs.findIndex(tab => tab.path === currentPath)
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
navigate(tabs[newValue].path)
}
return (
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={currentTabIndex >= 0 ? currentTabIndex : 0}
onChange={handleTabChange}
aria-label="navigation tabs"
variant="scrollable"
scrollButtons="auto"
>
{tabs.map((tab, index) => (
<Tab key={index} label={tab.label} />
))}
</Tabs>
</Box>
)
}

View File

@ -1,5 +1,6 @@
import { lazy, Suspense } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Box, CircularProgress, Typography } from '@mui/material'
const UploadView = lazy(() => import('../views/UploadView'))
const ExtractionView = lazy(() => import('../views/ExtractionView'))
@ -7,12 +8,19 @@ const AnalyseView = lazy(() => import('../views/AnalyseView'))
const ContexteView = lazy(() => import('../views/ContexteView'))
const ConseilView = lazy(() => import('../views/ConseilView'))
const LoadingFallback = () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Chargement...</Typography>
</Box>
)
const router = createBrowserRouter([
{ path: '/', element: <Suspense fallback={<div>Chargement</div>}><UploadView /></Suspense> },
{ path: '/extraction', element: <Suspense fallback={<div>Chargement</div>}><ExtractionView /></Suspense> },
{ path: '/analyse', element: <Suspense fallback={<div>Chargement</div>}><AnalyseView /></Suspense> },
{ path: '/contexte', element: <Suspense fallback={<div>Chargement</div>}><ContexteView /></Suspense> },
{ path: '/conseil', element: <Suspense fallback={<div>Chargement</div>}><ConseilView /></Suspense> },
{ path: '/', element: <Suspense fallback={<LoadingFallback />}><UploadView /></Suspense> },
{ path: '/extraction', element: <Suspense fallback={<LoadingFallback />}><ExtractionView /></Suspense> },
{ path: '/analyse', element: <Suspense fallback={<LoadingFallback />}><AnalyseView /></Suspense> },
{ path: '/contexte', element: <Suspense fallback={<LoadingFallback />}><ContexteView /></Suspense> },
{ path: '/conseil', element: <Suspense fallback={<LoadingFallback />}><ConseilView /></Suspense> },
])
export const AppRouter = () => {

View File

@ -1,4 +1,5 @@
import axios from 'axios'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
@ -7,13 +8,101 @@ export const apiClient = axios.create({
timeout: 60000,
})
export type DetectionResult = { type: string; confidence: number }
// Intercepteur pour les erreurs
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error)
return Promise.reject(error)
}
)
export const detectDocumentType = async (file: File) => {
const form = new FormData()
form.append('file', file)
const { data } = await apiClient.post<DetectionResult>('/api/ocr/detect', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
// Services API pour les documents
export const documentApi = {
// Téléversement de document
upload: async (file: File): Promise<Document> => {
const formData = new FormData()
formData.append('file', file)
const { data } = await apiClient.post<Document>('/api/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
},
// Extraction des données
extract: async (documentId: string): Promise<ExtractionResult> => {
const { data } = await apiClient.get<ExtractionResult>(`/api/documents/${documentId}/extract`)
return data
},
// Analyse du document
analyze: async (documentId: string): Promise<AnalysisResult> => {
const { data } = await apiClient.get<AnalysisResult>(`/api/documents/${documentId}/analyze`)
return data
},
// Données contextuelles
getContext: async (documentId: string): Promise<ContextResult> => {
const { data } = await apiClient.get<ContextResult>(`/api/documents/${documentId}/context`)
return data
},
// Conseil LLM
getConseil: async (documentId: string): Promise<ConseilResult> => {
const { data } = await apiClient.get<ConseilResult>(`/api/documents/${documentId}/conseil`)
return data
},
// Détection du type de document
detectType: async (file: File): Promise<{ type: string; confidence: number }> => {
const formData = new FormData()
formData.append('file', file)
const { data } = await apiClient.post('/api/ocr/detect', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
},
}
// Services API pour les données externes
export const externalApi = {
// Cadastre
cadastre: async (address: string) => {
const cadastreUrl = import.meta.env.VITE_CADASTRE_API_URL
if (!cadastreUrl) throw new Error('Cadastre API URL not configured')
const { data } = await axios.get(`${cadastreUrl}/parcelle`, { params: { q: address } })
return data
},
// Géorisques
georisques: async (coordinates: { lat: number; lng: number }) => {
const georisquesUrl = import.meta.env.VITE_GEORISQUES_API_URL
if (!georisquesUrl) throw new Error('Géorisques API URL not configured')
const { data } = await axios.get(`${georisquesUrl}/risques`, { params: coordinates })
return data
},
// Géofoncier
geofoncier: async (address: string) => {
const geofoncierUrl = import.meta.env.VITE_GEOFONCIER_API_URL
if (!geofoncierUrl) throw new Error('Géofoncier API URL not configured')
const { data } = await axios.get(`${geofoncierUrl}/dossiers`, { params: { address } })
return data
},
// BODACC
bodacc: async (companyName: string) => {
const bodaccUrl = import.meta.env.VITE_BODACC_API_URL
if (!bodaccUrl) throw new Error('BODACC API URL not configured')
const { data } = await axios.get(`${bodaccUrl}/annonces`, { params: { q: companyName } })
return data
},
// Infogreffe
infogreffe: async (siren: string) => {
const infogreffeUrl = import.meta.env.VITE_INFOGREFFE_API_URL
if (!infogreffeUrl) throw new Error('Infogreffe API URL not configured')
const { data } = await axios.get(`${infogreffeUrl}/infogreffe/rcs/extrait`, { params: { siren } })
return data
},
}

108
src/store/documentSlice.ts Normal file
View File

@ -0,0 +1,108 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
import { documentApi } from '../services/api'
interface DocumentState {
documents: Document[]
currentDocument: Document | null
extractionResult: ExtractionResult | null
analysisResult: AnalysisResult | null
contextResult: ContextResult | null
conseilResult: ConseilResult | null
loading: boolean
error: string | null
}
const initialState: DocumentState = {
documents: [],
currentDocument: null,
extractionResult: null,
analysisResult: null,
contextResult: null,
conseilResult: null,
loading: false,
error: null,
}
export const uploadDocument = createAsyncThunk(
'document/upload',
async (file: File) => {
return await documentApi.upload(file)
}
)
export const extractDocument = createAsyncThunk(
'document/extract',
async (documentId: string) => {
return await documentApi.extract(documentId)
}
)
export const analyzeDocument = createAsyncThunk(
'document/analyze',
async (documentId: string) => {
return await documentApi.analyze(documentId)
}
)
export const getContextData = createAsyncThunk(
'document/context',
async (documentId: string) => {
return await documentApi.getContext(documentId)
}
)
export const getConseil = createAsyncThunk(
'document/conseil',
async (documentId: string) => {
return await documentApi.getConseil(documentId)
}
)
const documentSlice = createSlice({
name: 'document',
initialState,
reducers: {
setCurrentDocument: (state, action: PayloadAction<Document | null>) => {
state.currentDocument = action.payload
},
clearResults: (state) => {
state.extractionResult = null
state.analysisResult = null
state.contextResult = null
state.conseilResult = null
},
},
extraReducers: (builder) => {
builder
.addCase(uploadDocument.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(uploadDocument.fulfilled, (state, action) => {
state.loading = false
state.documents.push(action.payload)
state.currentDocument = action.payload
})
.addCase(uploadDocument.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors du téléversement'
})
.addCase(extractDocument.fulfilled, (state, action) => {
state.extractionResult = action.payload
})
.addCase(analyzeDocument.fulfilled, (state, action) => {
state.analysisResult = action.payload
})
.addCase(getContextData.fulfilled, (state, action) => {
state.contextResult = action.payload
})
.addCase(getConseil.fulfilled, (state, action) => {
state.conseilResult = action.payload
})
},
})
export const { setCurrentDocument, clearResults } = documentSlice.actions
export const documentReducer = documentSlice.reducer

View File

@ -2,11 +2,12 @@ import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'
import { appReducer } from './appSlice'
import { documentReducer } from './documentSlice'
// Root placeholder slice will be added later as features grow
export const store = configureStore({
reducer: {
app: appReducer,
document: documentReducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
serializableCheck: false,

94
src/types/index.ts Normal file
View File

@ -0,0 +1,94 @@
export interface Document {
id: string
name: string
type: string
size: number
uploadDate: Date
status: 'uploading' | 'processing' | 'completed' | 'error'
}
export interface Identity {
id: string
type: 'person' | 'company'
firstName?: string
lastName?: string
companyName?: string
birthDate?: string
nationality?: string
address?: Address
confidence: number
}
export interface Address {
street: string
city: string
postalCode: string
country: string
coordinates?: { lat: number; lng: number }
}
export interface Property {
id: string
type: 'house' | 'apartment' | 'land' | 'commercial'
address: Address
surface?: number
cadastralReference?: string
value?: number
}
export interface Contract {
id: string
type: 'sale' | 'rent' | 'inheritance' | 'donation'
parties: Identity[]
property?: Property
amount?: number
date?: string
clauses: string[]
}
export interface ExtractionResult {
documentId: string
text: string
language: string
documentType: string
identities: Identity[]
addresses: Address[]
properties: Property[]
contracts: Contract[]
signatures: string[]
confidence: number
}
export interface AnalysisResult {
documentId: string
documentType: string
isCNI: boolean
country?: string
verificationResult?: {
numberValid: boolean
formatValid: boolean
checksumValid: boolean
}
credibilityScore: number
summary: string
recommendations: string[]
}
export interface ContextResult {
documentId: string
cadastreData?: any
georisquesData?: any
geofoncierData?: any
bodaccData?: any
infogreffeData?: any
lastUpdated: Date
}
export interface ConseilResult {
documentId: string
analysis: string
recommendations: string[]
risks: string[]
nextSteps: string[]
generatedAt: Date
}

View File

@ -1,8 +1,254 @@
import { useEffect } from 'react'
import {
Box,
Typography,
Paper,
Card,
CardContent,
Chip,
List,
ListItem,
ListItemText,
ListItemIcon,
Alert,
LinearProgress,
} from '@mui/material'
import {
CheckCircle,
Error,
Warning,
Flag,
Security,
Assessment,
Info,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { analyzeDocument } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function AnalyseView() {
return (
<div style={{ padding: 16 }}>
<h1>Analyse</h1>
<p>Cas CNI: pays, vérification numéro, score de vraisemblance, avis. Autres: synthèse et score.</p>
</div>
const dispatch = useAppDispatch()
const { currentDocument, analysisResult, loading } = useAppSelector(
(state) => state.document
)
}
useEffect(() => {
if (currentDocument && !analysisResult) {
dispatch(analyzeDocument(currentDocument.id))
}
}, [currentDocument, analysisResult, dispatch])
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
</Layout>
)
}
if (loading) {
return (
<Layout>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', mt: 4 }}>
<LinearProgress sx={{ width: '100%', mb: 2 }} />
<Typography>Analyse en cours...</Typography>
</Box>
</Layout>
)
}
if (!analysisResult) {
return (
<Layout>
<Alert severity="warning">
Aucun résultat d'analyse disponible.
</Alert>
</Layout>
)
}
const getScoreColor = (score: number) => {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'error'
}
const getScoreIcon = (score: number) => {
if (score >= 0.8) return <CheckCircle color="success" />
if (score >= 0.6) return <Warning color="warning" />
return <Error color="error" />
}
return (
<Layout>
<Typography variant="h4" gutterBottom>
Analyse du document
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Résumé général */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Résumé de l'analyse
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
icon={<Assessment />}
label={`Score de vraisemblance: ${(analysisResult.credibilityScore * 100).toFixed(1)}%`}
color={getScoreColor(analysisResult.credibilityScore) as any}
variant="filled"
/>
<Chip
icon={<Info />}
label={`Type: ${analysisResult.documentType}`}
color="primary"
variant="outlined"
/>
{analysisResult.isCNI && (
<Chip
icon={<Flag />}
label={`Pays: ${analysisResult.country}`}
color="secondary"
variant="outlined"
/>
)}
</Box>
</Paper>
{/* Cas CNI */}
{analysisResult.isCNI && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Security sx={{ mr: 1, verticalAlign: 'middle' }} />
Vérification CNI
</Typography>
{analysisResult.verificationResult && (
<List>
<ListItem>
<ListItemIcon>
{analysisResult.verificationResult.numberValid ? (
<CheckCircle color="success" />
) : (
<Error color="error" />
)}
</ListItemIcon>
<ListItemText
primary="Numéro valide"
secondary={
analysisResult.verificationResult.numberValid
? 'Le numéro de CNI est valide'
: 'Le numéro de CNI est invalide'
}
/>
</ListItem>
<ListItem>
<ListItemIcon>
{analysisResult.verificationResult.formatValid ? (
<CheckCircle color="success" />
) : (
<Error color="error" />
)}
</ListItemIcon>
<ListItemText
primary="Format valide"
secondary={
analysisResult.verificationResult.formatValid
? 'Le format du numéro est correct'
: 'Le format du numéro est incorrect'
}
/>
</ListItem>
<ListItem>
<ListItemIcon>
{analysisResult.verificationResult.checksumValid ? (
<CheckCircle color="success" />
) : (
<Error color="error" />
)}
</ListItemIcon>
<ListItemText
primary="Checksum valide"
secondary={
analysisResult.verificationResult.checksumValid
? 'La somme de contrôle est correcte'
: 'La somme de contrôle est incorrecte'
}
/>
</ListItem>
</List>
)}
</CardContent>
</Card>
)}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Score de vraisemblance */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Score de vraisemblance
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
{getScoreIcon(analysisResult.credibilityScore)}
<Typography variant="h4" sx={{ ml: 2 }}>
{(analysisResult.credibilityScore * 100).toFixed(1)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={analysisResult.credibilityScore * 100}
color={getScoreColor(analysisResult.credibilityScore) as any}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{analysisResult.credibilityScore >= 0.8
? 'Document très fiable'
: analysisResult.credibilityScore >= 0.6
? 'Document moyennement fiable'
: 'Document peu fiable - vérification recommandée'}
</Typography>
</CardContent>
</Card>
</Box>
{/* Synthèse */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Synthèse
</Typography>
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
{analysisResult.summary}
</Typography>
</CardContent>
</Card>
</Box>
</Box>
{/* Recommandations */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Recommandations
</Typography>
<List>
{analysisResult.recommendations.map((recommendation, index) => (
<ListItem key={index}>
<ListItemIcon>
<Info color="primary" />
</ListItemIcon>
<ListItemText primary={recommendation} />
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
</Layout>
)
}

View File

@ -1,8 +1,243 @@
import { useEffect } from 'react'
import {
Box,
Typography,
Paper,
Card,
CardContent,
List,
ListItem,
ListItemText,
ListItemIcon,
Alert,
Chip,
Button,
CircularProgress,
} from '@mui/material'
import {
Lightbulb,
Warning,
CheckCircle,
TrendingUp,
Schedule,
Psychology,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { getConseil } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function ConseilView() {
return (
<div style={{ padding: 16 }}>
<h1>Conseil</h1>
<p>Restitution LLM d'une analyse du document et recommandations.</p>
</div>
const dispatch = useAppDispatch()
const { currentDocument, conseilResult, loading } = useAppSelector(
(state) => state.document
)
}
useEffect(() => {
if (currentDocument && !conseilResult) {
dispatch(getConseil(currentDocument.id))
}
}, [currentDocument, conseilResult, dispatch])
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
</Layout>
)
}
if (loading) {
return (
<Layout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Génération des conseils LLM...</Typography>
</Box>
</Layout>
)
}
if (!conseilResult) {
return (
<Layout>
<Alert severity="warning">
Aucun conseil disponible.
</Alert>
</Layout>
)
}
const getRiskColor = (risk: string) => {
if (risk.toLowerCase().includes('élevé') || risk.toLowerCase().includes('critique')) {
return 'error'
}
if (risk.toLowerCase().includes('moyen') || risk.toLowerCase().includes('modéré')) {
return 'warning'
}
return 'info'
}
return (
<Layout>
<Typography variant="h4" gutterBottom>
<Psychology sx={{ mr: 1, verticalAlign: 'middle' }} />
Conseil LLM
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Analyse LLM */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Lightbulb sx={{ mr: 1, verticalAlign: 'middle' }} />
Analyse LLM
</Typography>
<Paper
sx={{
p: 2,
bgcolor: 'grey.50',
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
{conseilResult.analysis}
</Typography>
</Paper>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Généré le {new Date(conseilResult.generatedAt).toLocaleString()}
</Typography>
</CardContent>
</Card>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Recommandations */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<CheckCircle sx={{ mr: 1, verticalAlign: 'middle' }} />
Recommandations ({conseilResult.recommendations.length})
</Typography>
<List dense>
{conseilResult.recommendations.map((recommendation, index) => (
<ListItem key={index}>
<ListItemIcon>
<CheckCircle color="success" />
</ListItemIcon>
<ListItemText primary={recommendation} />
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
{/* Risques identifiés */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Warning sx={{ mr: 1, verticalAlign: 'middle' }} />
Risques identifiés ({conseilResult.risks.length})
</Typography>
<List dense>
{conseilResult.risks.map((risk, index) => (
<ListItem key={index}>
<ListItemIcon>
<Warning color={getRiskColor(risk) as any} />
</ListItemIcon>
<ListItemText
primary={risk}
primaryTypographyProps={{
color: getRiskColor(risk) === 'error' ? 'error.main' :
getRiskColor(risk) === 'warning' ? 'warning.main' : 'info.main'
}}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
</Box>
{/* Prochaines étapes */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<TrendingUp sx={{ mr: 1, verticalAlign: 'middle' }} />
Prochaines étapes recommandées
</Typography>
<List>
{conseilResult.nextSteps.map((step, index) => (
<ListItem key={index}>
<ListItemIcon>
<Schedule color="primary" />
</ListItemIcon>
<ListItemText
primary={`Étape ${index + 1}`}
secondary={step}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Actions
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="contained"
onClick={() => dispatch(getConseil(currentDocument.id))}
disabled={loading}
>
Régénérer les conseils
</Button>
<Button variant="outlined">
Exporter le rapport
</Button>
<Button variant="outlined">
Partager avec l'équipe
</Button>
</Box>
</CardContent>
</Card>
{/* Résumé exécutif */}
<Paper sx={{ p: 2, bgcolor: 'primary.50' }}>
<Typography variant="h6" gutterBottom>
Résumé exécutif
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={`${conseilResult.recommendations.length} recommandations`}
color="success"
variant="outlined"
/>
<Chip
label={`${conseilResult.risks.length} risques identifiés`}
color="warning"
variant="outlined"
/>
<Chip
label={`${conseilResult.nextSteps.length} étapes suivantes`}
color="info"
variant="outlined"
/>
</Box>
<Typography variant="body2" color="text.secondary">
Cette analyse LLM a é générée automatiquement et doit être validée par un expert notarial.
</Typography>
</Paper>
</Box>
</Layout>
)
}

View File

@ -1,8 +1,298 @@
import { useEffect } from 'react'
import {
Box,
Typography,
Paper,
Card,
CardContent,
Chip,
Alert,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
} from '@mui/material'
import {
ExpandMore,
LocationOn,
Warning,
CheckCircle,
Error,
Public,
Business,
Home,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { getContextData } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function ContexteView() {
return (
<div style={{ padding: 16 }}>
<h1>Contexte</h1>
<p>Recherche d'informations contextuelles (cadastre, géorisques, géofoncier, etc.).</p>
</div>
const dispatch = useAppDispatch()
const { currentDocument, contextResult, loading } = useAppSelector(
(state) => state.document
)
}
useEffect(() => {
if (currentDocument && !contextResult) {
dispatch(getContextData(currentDocument.id))
}
}, [currentDocument, contextResult, dispatch])
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
</Layout>
)
}
if (loading) {
return (
<Layout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Recherche d'informations contextuelles...</Typography>
</Box>
</Layout>
)
}
if (!contextResult) {
return (
<Layout>
<Alert severity="warning">
Aucune donnée contextuelle disponible.
</Alert>
</Layout>
)
}
const getStatusIcon = (hasData: boolean) => {
return hasData ? <CheckCircle color="success" /> : <Error color="error" />
}
const getStatusColor = (hasData: boolean) => {
return hasData ? 'success' : 'error'
}
return (
<Layout>
<Typography variant="h4" gutterBottom>
Informations contextuelles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Résumé des sources */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Sources de données consultées
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
icon={getStatusIcon(!!contextResult.cadastreData)}
label="Cadastre"
color={getStatusColor(!!contextResult.cadastreData) as any}
variant="outlined"
/>
<Chip
icon={getStatusIcon(!!contextResult.georisquesData)}
label="Géorisques"
color={getStatusColor(!!contextResult.georisquesData) as any}
variant="outlined"
/>
<Chip
icon={getStatusIcon(!!contextResult.geofoncierData)}
label="Géofoncier"
color={getStatusColor(!!contextResult.geofoncierData) as any}
variant="outlined"
/>
<Chip
icon={getStatusIcon(!!contextResult.bodaccData)}
label="BODACC"
color={getStatusColor(!!contextResult.bodaccData) as any}
variant="outlined"
/>
<Chip
icon={getStatusIcon(!!contextResult.infogreffeData)}
label="Infogreffe"
color={getStatusColor(!!contextResult.infogreffeData) as any}
variant="outlined"
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Dernière mise à jour: {new Date(contextResult.lastUpdated).toLocaleString()}
</Typography>
</Paper>
{/* Données cadastrales */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Home sx={{ mr: 1 }} />
<Typography variant="h6">Données cadastrales</Typography>
<Chip
label={contextResult.cadastreData ? 'Disponible' : 'Non disponible'}
color={getStatusColor(!!contextResult.cadastreData) as any}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
{contextResult.cadastreData ? (
<Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{JSON.stringify(contextResult.cadastreData, null, 2)}
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée cadastrale trouvée pour ce document.
</Alert>
)}
</AccordionDetails>
</Accordion>
{/* Données Géorisques */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Warning sx={{ mr: 1 }} />
<Typography variant="h6">Données Géorisques</Typography>
<Chip
label={contextResult.georisquesData ? 'Disponible' : 'Non disponible'}
color={getStatusColor(!!contextResult.georisquesData) as any}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
{contextResult.georisquesData ? (
<Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{JSON.stringify(contextResult.georisquesData, null, 2)}
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée Géorisques trouvée pour ce document.
</Alert>
)}
</AccordionDetails>
</Accordion>
{/* Données Géofoncier */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<LocationOn sx={{ mr: 1 }} />
<Typography variant="h6">Données Géofoncier</Typography>
<Chip
label={contextResult.geofoncierData ? 'Disponible' : 'Non disponible'}
color={getStatusColor(!!contextResult.geofoncierData) as any}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
{contextResult.geofoncierData ? (
<Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{JSON.stringify(contextResult.geofoncierData, null, 2)}
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée Géofoncier trouvée pour ce document.
</Alert>
)}
</AccordionDetails>
</Accordion>
{/* Données BODACC */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Public sx={{ mr: 1 }} />
<Typography variant="h6">Données BODACC</Typography>
<Chip
label={contextResult.bodaccData ? 'Disponible' : 'Non disponible'}
color={getStatusColor(!!contextResult.bodaccData) as any}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
{contextResult.bodaccData ? (
<Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{JSON.stringify(contextResult.bodaccData, null, 2)}
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée BODACC trouvée pour ce document.
</Alert>
)}
</AccordionDetails>
</Accordion>
{/* Données Infogreffe */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Business sx={{ mr: 1 }} />
<Typography variant="h6">Données Infogreffe</Typography>
<Chip
label={contextResult.infogreffeData ? 'Disponible' : 'Non disponible'}
color={getStatusColor(!!contextResult.infogreffeData) as any}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
{contextResult.infogreffeData ? (
<Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{JSON.stringify(contextResult.infogreffeData, null, 2)}
</Typography>
</Box>
) : (
<Alert severity="info">
Aucune donnée Infogreffe trouvée pour ce document.
</Alert>
)}
</AccordionDetails>
</Accordion>
{/* Actions */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Actions
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="contained"
onClick={() => dispatch(getContextData(currentDocument.id))}
disabled={loading}
>
Actualiser les données
</Button>
<Button variant="outlined">
Exporter le rapport
</Button>
</Box>
</CardContent>
</Card>
</Box>
</Layout>
)
}

View File

@ -1,8 +1,288 @@
import { useEffect } from 'react'
import {
Box,
Typography,
Paper,
Card,
CardContent,
Chip,
List,
ListItem,
ListItemText,
Alert,
CircularProgress,
} from '@mui/material'
import {
Person,
LocationOn,
Home,
Description,
Language,
Verified,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { extractDocument } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function ExtractionView() {
return (
<div style={{ padding: 16 }}>
<h1>Extraction</h1>
<p>Affichage des informations extraites, identités, lieux, biens, clauses, signatures, langue, tags.</p>
</div>
const dispatch = useAppDispatch()
const { currentDocument, extractionResult, loading } = useAppSelector(
(state) => state.document
)
}
useEffect(() => {
if (currentDocument && !extractionResult) {
dispatch(extractDocument(currentDocument.id))
}
}, [currentDocument, extractionResult, dispatch])
if (!currentDocument) {
return (
<Layout>
<Alert severity="info">
Veuillez d'abord téléverser et sélectionner un document.
</Alert>
</Layout>
)
}
if (loading) {
return (
<Layout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Extraction en cours...</Typography>
</Box>
</Layout>
)
}
if (!extractionResult) {
return (
<Layout>
<Alert severity="warning">
Aucun résultat d'extraction disponible.
</Alert>
</Layout>
)
}
return (
<Layout>
<Typography variant="h4" gutterBottom>
Extraction des données
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Informations générales */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Informations générales
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
icon={<Language />}
label={`Langue: ${extractionResult.language}`}
color="primary"
variant="outlined"
/>
<Chip
icon={<Description />}
label={`Type: ${extractionResult.documentType}`}
color="secondary"
variant="outlined"
/>
<Chip
icon={<Verified />}
label={`Confiance: ${(extractionResult.confidence * 100).toFixed(1)}%`}
color={extractionResult.confidence > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Box>
</Paper>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Identités */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Person sx={{ mr: 1, verticalAlign: 'middle' }} />
Identités ({extractionResult.identities.length})
</Typography>
<List dense>
{extractionResult.identities.map((identity, index) => (
<ListItem key={index}>
<ListItemText
primary={
identity.type === 'person'
? `${identity.firstName} ${identity.lastName}`
: identity.companyName
}
secondary={
<Box>
<Typography variant="caption" display="block">
Type: {identity.type}
</Typography>
{identity.birthDate && (
<Typography variant="caption" display="block">
Naissance: {identity.birthDate}
</Typography>
)}
{identity.nationality && (
<Typography variant="caption" display="block">
Nationalité: {identity.nationality}
</Typography>
)}
<Typography variant="caption" display="block">
Confiance: {(identity.confidence * 100).toFixed(1)}%
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
{/* Adresses */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
Adresses ({extractionResult.addresses.length})
</Typography>
<List dense>
{extractionResult.addresses.map((address, index) => (
<ListItem key={index}>
<ListItemText
primary={`${address.street}, ${address.city}`}
secondary={`${address.postalCode} ${address.country}`}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{/* Biens */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Home sx={{ mr: 1, verticalAlign: 'middle' }} />
Biens ({extractionResult.properties.length})
</Typography>
<List dense>
{extractionResult.properties.map((property, index) => (
<ListItem key={index}>
<ListItemText
primary={`${property.type} - ${property.address.city}`}
secondary={
<Box>
<Typography variant="caption" display="block">
{property.address.street}
</Typography>
{property.surface && (
<Typography variant="caption" display="block">
Surface: {property.surface} m²
</Typography>
)}
{property.cadastralReference && (
<Typography variant="caption" display="block">
Cadastre: {property.cadastralReference}
</Typography>
)}
</Box>
}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
{/* Contrats */}
<Box sx={{ flex: '1 1 300px' }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<Description sx={{ mr: 1, verticalAlign: 'middle' }} />
Contrats ({extractionResult.contracts.length})
</Typography>
<List dense>
{extractionResult.contracts.map((contract, index) => (
<ListItem key={index}>
<ListItemText
primary={`${contract.type} - ${contract.amount ? `${contract.amount}` : 'Montant non spécifié'}`}
secondary={
<Box>
<Typography variant="caption" display="block">
Parties: {contract.parties.length}
</Typography>
{contract.date && (
<Typography variant="caption" display="block">
Date: {contract.date}
</Typography>
)}
<Typography variant="caption" display="block">
Clauses: {contract.clauses.length}
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
</Box>
{/* Signatures */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Signatures détectées ({extractionResult.signatures.length})
</Typography>
<List dense>
{extractionResult.signatures.map((signature, index) => (
<ListItem key={index}>
<ListItemText primary={signature} />
</ListItem>
))}
</List>
</CardContent>
</Card>
{/* Texte extrait */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Texte extrait
</Typography>
<Paper
sx={{
p: 2,
bgcolor: 'grey.50',
maxHeight: 300,
overflow: 'auto',
}}
>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{extractionResult.text}
</Typography>
</Paper>
</CardContent>
</Card>
</Box>
</Layout>
)
}

View File

@ -1,25 +1,159 @@
import { useState } from 'react'
import { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemText,
ListItemIcon,
CircularProgress,
Alert,
Button,
Chip,
} from '@mui/material'
import {
CloudUpload,
CheckCircle,
Error,
HourglassEmpty,
} from '@mui/icons-material'
import { useAppDispatch, useAppSelector } from '../store'
import { uploadDocument, setCurrentDocument } from '../store/documentSlice'
import { Layout } from '../components/Layout'
export default function UploadView() {
const [files, setFiles] = useState<File[]>([])
const dispatch = useAppDispatch()
const { documents, error } = useAppSelector((state) => state.document)
function onFilesSelected(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files) {
setFiles(Array.from(event.target.files))
const onDrop = useCallback(
(acceptedFiles: File[]) => {
acceptedFiles.forEach((file) => {
dispatch(uploadDocument(file))
})
},
[dispatch]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'image/*': ['.png', '.jpg', '.jpeg', '.tiff'],
},
multiple: true,
})
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle color="success" />
case 'error':
return <Error color="error" />
case 'processing':
return <CircularProgress size={20} />
default:
return <HourglassEmpty color="action" />
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'success'
case 'error':
return 'error'
case 'processing':
return 'warning'
default:
return 'default'
}
}
return (
<div style={{ padding: 16 }}>
<h1>Téléversement de documents</h1>
<input type="file" multiple onChange={onFilesSelected} />
{files.length > 0 && (
<ul>
{files.map((file) => (
<li key={file.name}>{file.name}</li>
))}
</ul>
<Layout>
<Typography variant="h4" gutterBottom>
Téléversement de documents
</Typography>
<Paper
{...getRootProps()}
sx={{
p: 4,
textAlign: 'center',
cursor: 'pointer',
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
bgcolor: isDragActive ? 'action.hover' : 'background.paper',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
},
}}
>
<input {...getInputProps()} />
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{isDragActive
? 'Déposez les fichiers ici...'
: 'Glissez-déposez vos documents ou cliquez pour sélectionner'}
</Typography>
<Typography variant="body2" color="text.secondary">
Formats acceptés: PDF, PNG, JPG, JPEG, TIFF
</Typography>
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</div>
{documents.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Documents téléversés ({documents.length})
</Typography>
<List>
{documents.map((doc) => (
<ListItem
key={doc.id}
secondaryAction={
<Button
onClick={() => dispatch(setCurrentDocument(doc))}
disabled={doc.status !== 'completed'}
>
Analyser
</Button>
}
>
<ListItemIcon>{getStatusIcon(doc.status)}</ListItemIcon>
<ListItemText
primary={doc.name}
secondary={
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Chip
label={doc.type}
size="small"
variant="outlined"
/>
<Chip
label={doc.status}
size="small"
color={getStatusColor(doc.status) as any}
/>
<Typography variant="caption" color="text.secondary">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</Box>
)}
</Layout>
)
}