feat(front): afficher nom du dossier et nom lisible des documents; dialog création (nom+description)\nfeat(backend): meta dossier (name, description); MRZ CNI robuste; routes meta/cache/reprocess\nchore: spinner chargement extraction; retirer navigation\nci: docker_tag=dev-test
This commit is contained in:
parent
883f49e2e2
commit
fa50a0c2e6
2
.env
2
.env
@ -7,6 +7,8 @@ VITE_APP_VERSION=0.1.0
|
||||
VITE_USE_RULE_NER=true
|
||||
VITE_LLM_CLASSIFY_ONLY=true
|
||||
|
||||
VITE_BACKEND_URL=http://localhost:3001
|
||||
|
||||
# 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
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
VITE_API_URL=http://localhost:18000
|
||||
|
||||
# Configuration pour le développement
|
||||
VITE_APP_NAME=4NK IA Lecoffre.io
|
||||
VITE_APP_NAME=IA Lecoffre.io
|
||||
VITE_APP_VERSION=0.1.0
|
||||
VITE_USE_RULE_NER=true
|
||||
VITE_LLM_CLASSIFY_ONLY=true
|
||||
|
||||
VITE_BACKEND_URL=http://localhost:3001
|
||||
|
||||
# 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
|
||||
|
||||
1
backend.pid
Normal file
1
backend.pid
Normal file
@ -0,0 +1 @@
|
||||
|
||||
BIN
backend/cache-backup-20250917-063644.tar.gz
Normal file
BIN
backend/cache-backup-20250917-063644.tar.gz
Normal file
Binary file not shown.
@ -92,6 +92,38 @@ function createFolderStructure(folderHash) {
|
||||
return { folderPath, cachePath }
|
||||
}
|
||||
|
||||
// Écrit un fichier de métadonnées du dossier (uploads/<hash>/folder.json)
|
||||
function writeFolderMeta(folderHash, name, description) {
|
||||
try {
|
||||
const { folderPath } = createFolderStructure(folderHash)
|
||||
const metaPath = path.join(folderPath, 'folder.json')
|
||||
const meta = {
|
||||
folderHash,
|
||||
name: name || null,
|
||||
description: description || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2))
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn('[FOLDER] Impossible d\'écrire folder.json:', e?.message || e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Lit le fichier de métadonnées du dossier
|
||||
function readFolderMeta(folderHash) {
|
||||
try {
|
||||
const metaPath = path.join('uploads', folderHash, 'folder.json')
|
||||
if (!fs.existsSync(metaPath)) return null
|
||||
const raw = fs.readFileSync(metaPath, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (e) {
|
||||
console.warn('[FOLDER] Impossible de lire folder.json:', e?.message || e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour sauvegarder le cache JSON dans un dossier spécifique
|
||||
function saveJsonCacheInFolder(folderHash, fileHash, result) {
|
||||
const { cachePath } = createFolderStructure(folderHash)
|
||||
@ -817,6 +849,7 @@ function generateStandardJSON(documentInfo, ocrResult, entities, processingTime)
|
||||
document: {
|
||||
id: documentId,
|
||||
fileName: documentInfo.originalname,
|
||||
displayName: documentInfo.displayName || documentInfo.originalname,
|
||||
fileSize: documentInfo.size,
|
||||
mimeType: documentInfo.mimetype,
|
||||
uploadTimestamp: timestamp,
|
||||
@ -1067,6 +1100,76 @@ function extractEntitiesFromText(text) {
|
||||
documentType: 'Document',
|
||||
}
|
||||
|
||||
// Extraction spécifique CNI (MRZ et libellés FR)
|
||||
try {
|
||||
const t = correctedText.replace(/\u200B|\u200E|\u200F/g, '')
|
||||
// MRZ de CNI (deux ou trois lignes, séparateur << : NOM<<PRENOMS)
|
||||
// Scanner le texte complet (sans filtrage par lignes) et normaliser
|
||||
const mrzText = t
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toUpperCase()
|
||||
const mrzRegex = /([A-Z]{2,})<<([A-Z<]{2,})/g
|
||||
let match
|
||||
while ((match = mrzRegex.exec(mrzText)) !== null) {
|
||||
const rawSurname = match[1].replace(/</g, ' ').trim()
|
||||
// Certains MRZ concatènent plusieurs prénoms séparés par <<
|
||||
const rawGiven = match[2].split('<<')[0].replace(/</g, ' ').trim()
|
||||
if (rawSurname.length >= 2 && rawGiven.length >= 2) {
|
||||
entities.identities.push({
|
||||
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
|
||||
type: 'person',
|
||||
firstName: capitalize(rawGiven.split(/\s+/)[0]),
|
||||
lastName: rawSurname.toUpperCase(),
|
||||
confidence: 0.99,
|
||||
source: 'mrz',
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Repli: si pas d'identité MRZ extraite, tenter reconstruction NOM/PRÉNOM séparés
|
||||
if (!(Array.isArray(entities.identities) && entities.identities.some((i)=> (i.source||'').toLowerCase()==='mrz'))) {
|
||||
// Chercher NOM après IDFRA (ex: IDFRACANTU<<<<...)
|
||||
const mSurname = mrzText.match(/IDFRA\s*([A-Z]{2,})</)
|
||||
// Chercher PRENOM en premier avant << (ex: NICOLAS<<FRANCS)
|
||||
const mGiven = mrzText.match(/\b([A-Z]{2,})<<[A-Z<]{2,}\b/)
|
||||
const last = mSurname?.[1]?.trim()
|
||||
const first = mGiven?.[1]?.trim()
|
||||
if (last && last.length >= 2 && first && first.length >= 2) {
|
||||
entities.identities.push({
|
||||
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
|
||||
type: 'person',
|
||||
firstName: capitalize(first.toLowerCase()),
|
||||
lastName: last.toUpperCase(),
|
||||
confidence: 0.97,
|
||||
source: 'mrz-heuristic',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Libellés français typiques de CNI
|
||||
// NOM : XXXX PRENOM(S) : YYYYY
|
||||
const labelName = t.match(/\bNOM\s*[:\-]?\s*([A-ZÀ-ÖØ-Þ\-\s]{2,})/i)
|
||||
const labelGiven = t.match(/\bPRÉ?NOM\S*\s*[:\-]?\s*([A-Za-zÀ-ÖØ-öø-ÿ'\-\s]{2,})/i)
|
||||
if (labelName || labelGiven) {
|
||||
const last = (labelName?.[1] || '').replace(/[^A-Za-zÀ-ÖØ-Þ'\-\s]/g, '').trim()
|
||||
const first = (labelGiven?.[1] || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim()
|
||||
if (last || first) {
|
||||
entities.identities.push({
|
||||
id: `identity-${(Array.isArray(entities.identities)?entities.identities:[]).length}`,
|
||||
type: 'person',
|
||||
firstName: first ? capitalize(first.split(/\s+/)[0]) : '',
|
||||
lastName: last ? last.toUpperCase() : '',
|
||||
confidence: 0.9,
|
||||
source: 'label',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NER] Erreur parsing CNI:', e?.message || e)
|
||||
}
|
||||
|
||||
// Extraction des noms avec patterns généraux
|
||||
const namePatterns = [
|
||||
// Patterns pour documents officiels
|
||||
@ -1229,6 +1332,50 @@ function extractEntitiesFromText(text) {
|
||||
entities.documentType = 'Contrat'
|
||||
}
|
||||
|
||||
// Post-traitement des identités: privilégier MRZ, filtrer les faux positifs
|
||||
try {
|
||||
const wordsBlacklist = new Set([
|
||||
'ME', 'DE', 'DU', 'DES', 'LA', 'LE', 'LES', 'ET', 'OU', 'EL', 'DEL', 'D', 'M', 'MR', 'MME',
|
||||
'FACTURE', 'CONDITIONS', 'PAIEMENT', 'SIGNATURE', 'ADDRESS', 'ADRESSE', 'TEL', 'TÉL', 'EMAIL',
|
||||
])
|
||||
const isValidName = (first, last) => {
|
||||
const a = (first || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim()
|
||||
const b = (last || '').replace(/[^A-Za-zÀ-ÖØ-öø-ÿ'\-\s]/g, '').trim()
|
||||
if (!a && !b) return false
|
||||
if (a && (a.length < 2 || wordsBlacklist.has(a.toUpperCase()))) return false
|
||||
if (b && b.length < 2) return false
|
||||
return true
|
||||
}
|
||||
// Séparer MRZ vs autres
|
||||
const mrz = []
|
||||
const others = []
|
||||
for (const id of (Array.isArray(entities.identities) ? entities.identities : [])) {
|
||||
if (!isValidName(id.firstName, id.lastName)) continue
|
||||
if ((id.source || '').toLowerCase() === 'mrz') mrz.push(id)
|
||||
else others.push(id)
|
||||
}
|
||||
// Dédupliquer par (first,last)
|
||||
const dedup = (arr) => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const it of arr) {
|
||||
const key = `${(it.firstName || '').toLowerCase()}::${(it.lastName || '').toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
let finalIds = dedup(mrz).concat(dedup(others))
|
||||
// Si une identité MRZ existe, limiter à 1-2 meilleures (éviter bruit)
|
||||
if (mrz.length > 0) {
|
||||
finalIds = dedup(mrz).slice(0, 2)
|
||||
}
|
||||
entities.identities = finalIds
|
||||
} catch (e) {
|
||||
console.warn('[NER] Post-processing identities error:', e?.message || e)
|
||||
}
|
||||
|
||||
console.log(`[NER] Extraction terminée:`)
|
||||
console.log(` - Identités: ${(Array.isArray(entities.identities)?entities.identities:[]).length}`)
|
||||
console.log(` - Sociétés: ${(Array.isArray(entities.companies)?entities.companies:[]).length}`)
|
||||
@ -1242,6 +1389,11 @@ function extractEntitiesFromText(text) {
|
||||
return entities
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
if (!s) return s
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
// Route pour l'extraction de documents
|
||||
app.post('/api/extract', upload.single('document'), async (req, res) => {
|
||||
const startTime = Date.now()
|
||||
@ -1646,11 +1798,21 @@ app.post('/api/folders', (req, res) => {
|
||||
const result = createFolderStructure(folderHash)
|
||||
console.log(`[FOLDER] Structure créée:`, result)
|
||||
|
||||
// Écrire la métadonnée si fournie
|
||||
const name = (req.body && typeof req.body.name === 'string' && req.body.name.trim())
|
||||
? req.body.name.trim() : null
|
||||
const description = (req.body && typeof req.body.description === 'string')
|
||||
? req.body.description
|
||||
: null
|
||||
writeFolderMeta(folderHash, name, description)
|
||||
|
||||
console.log(`[FOLDER] Nouveau dossier créé: ${folderHash}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
folderHash,
|
||||
name: name || null,
|
||||
description: description || null,
|
||||
message: 'Dossier créé avec succès',
|
||||
})
|
||||
} catch (error) {
|
||||
@ -1717,6 +1879,71 @@ app.get('/api/folders/:folderHash/files/:fileHash', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Route pour vider le cache d'un dossier (supprime *.json et *.pending)
|
||||
app.delete('/api/folders/:folderHash/cache', (req, res) => {
|
||||
try {
|
||||
const { folderHash } = req.params
|
||||
const cachePath = path.join('cache', folderHash)
|
||||
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return res.status(404).json({ success: false, error: 'Dossier de cache introuvable' })
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(cachePath)
|
||||
let removed = 0
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json') || file.endsWith('.pending')) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(cachePath, file))
|
||||
removed += 1
|
||||
} catch (err) {
|
||||
console.warn(`[CACHE] Suppression échouée pour ${file}:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, folderHash, removed })
|
||||
} catch (error) {
|
||||
console.error('[CACHE] Erreur lors du vidage du cache du dossier:', error)
|
||||
return res.status(500).json({ success: false, error: 'Erreur lors du vidage du cache' })
|
||||
}
|
||||
})
|
||||
|
||||
// Route pour (re)traiter un dossier existant basé sur uploads/<folderHash>
|
||||
app.post('/api/folders/:folderHash/reprocess', async (req, res) => {
|
||||
try {
|
||||
const { folderHash } = req.params
|
||||
const { folderPath, cachePath } = createFolderStructure(folderHash)
|
||||
|
||||
console.log(`[FOLDER] Re-traitement demandé pour: ${folderHash}`)
|
||||
|
||||
// Lister les fichiers présents dans uploads/<hash>
|
||||
const uploadFiles = fs.existsSync(folderPath)
|
||||
? fs.readdirSync(folderPath).filter((f) => fs.statSync(path.join(folderPath, f)).isFile())
|
||||
: []
|
||||
|
||||
let scheduled = 0
|
||||
for (const file of uploadFiles) {
|
||||
const filePath = path.join(folderPath, file)
|
||||
const fileHash = path.basename(file, path.extname(file))
|
||||
const hasCache = fs.existsSync(path.join(cachePath, `${fileHash}.json`))
|
||||
const isPending = fs.existsSync(path.join(cachePath, `${fileHash}.pending`))
|
||||
if (!hasCache && !isPending) {
|
||||
createPendingFlag(folderHash, fileHash)
|
||||
processFileInBackground(filePath, fileHash, folderHash).catch((err) =>
|
||||
console.error('[BACKGROUND] Reprocess error:', err?.message || err),
|
||||
)
|
||||
scheduled += 1
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, folderHash, scheduled })
|
||||
} catch (error) {
|
||||
console.error('[FOLDER] Erreur lors du re-traitement du dossier:', error)
|
||||
res.status(500).json({ success: false, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Route pour créer le dossier par défaut avec les fichiers de test
|
||||
app.post('/api/folders/default', async (req, res) => {
|
||||
try {
|
||||
@ -1725,12 +1952,17 @@ app.post('/api/folders/default', async (req, res) => {
|
||||
|
||||
console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`)
|
||||
|
||||
// Écrire la métadonnée du dossier par défaut
|
||||
writeFolderMeta(folderHash, 'Dossier par défaut', 'Dossier initial préchargé')
|
||||
|
||||
// Charger les fichiers de test dans le dossier
|
||||
const testFilesDir = path.join(__dirname, '..', 'test-files')
|
||||
if (fs.existsSync(testFilesDir)) {
|
||||
const testFiles = fs.readdirSync(testFilesDir)
|
||||
const supportedFiles = testFiles.filter((file) =>
|
||||
['.pdf', '.jpg', '.jpeg', '.png', '.tiff'].includes(path.extname(file).toLowerCase()),
|
||||
['.pdf', '.jpg', '.jpeg', '.png', '.tiff', '.txt'].includes(
|
||||
path.extname(file).toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
||||
for (const testFile of supportedFiles) {
|
||||
@ -1761,6 +1993,9 @@ app.post('/api/folders/default', async (req, res) => {
|
||||
ocrResult = await extractTextFromPdf(destPath)
|
||||
} else if (['.jpg', '.jpeg', '.png', '.tiff'].includes(ext.toLowerCase())) {
|
||||
ocrResult = await extractTextFromImage(destPath)
|
||||
} else if (ext.toLowerCase() === '.txt') {
|
||||
const text = fs.readFileSync(destPath, 'utf8')
|
||||
ocrResult = { text, confidence: 95, words: text.split(/\s+/).filter((w) => w) }
|
||||
}
|
||||
|
||||
if (ocrResult && ocrResult.text) {
|
||||
@ -1778,6 +2013,65 @@ app.post('/api/folders/default', async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[FOLDER] Erreur lors du traitement de ${testFile}:`, error.message)
|
||||
// Repli: enregistrer un cache minimal pour rendre le fichier visible côté frontend
|
||||
try {
|
||||
const nowIso = new Date().toISOString()
|
||||
const minimal = {
|
||||
document: {
|
||||
id: `doc-preload-${Date.now()}`,
|
||||
fileName: testFile,
|
||||
fileSize: fs.existsSync(destPath) ? fs.statSync(destPath).size : fileBuffer.length,
|
||||
mimeType: getMimeType(ext),
|
||||
uploadTimestamp: nowIso,
|
||||
},
|
||||
classification: {
|
||||
documentType: 'Document',
|
||||
confidence: 0.6,
|
||||
subType: 'Document',
|
||||
language: 'fr',
|
||||
pageCount: 1,
|
||||
},
|
||||
extraction: {
|
||||
text: {
|
||||
raw: `Préchargé: ${testFile}`,
|
||||
processed: `Préchargé: ${testFile}`,
|
||||
wordCount: 2,
|
||||
characterCount: (`Préchargé: ${testFile}`).length,
|
||||
confidence: 0.6,
|
||||
},
|
||||
entities: {
|
||||
persons: [],
|
||||
companies: [],
|
||||
addresses: [],
|
||||
financial: { amounts: [], totals: {}, payment: {} },
|
||||
dates: [],
|
||||
contractual: { clauses: [], signatures: [] },
|
||||
references: [],
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
processing: {
|
||||
engine: 'preload',
|
||||
version: '1',
|
||||
processingTime: '0ms',
|
||||
ocrEngine: 'preload',
|
||||
nerEngine: 'none',
|
||||
preprocessing: { applied: false, reason: 'preload' },
|
||||
},
|
||||
quality: {
|
||||
globalConfidence: 0.6,
|
||||
textExtractionConfidence: 0.6,
|
||||
entityExtractionConfidence: 0.6,
|
||||
classificationConfidence: 0.6,
|
||||
},
|
||||
},
|
||||
status: { success: true, errors: [], warnings: [], timestamp: nowIso },
|
||||
}
|
||||
saveJsonCacheInFolder(folderHash, fileHash, minimal)
|
||||
console.log(`[FOLDER] Repli: cache minimal écrit pour ${testFile}`)
|
||||
} catch (fallbackErr) {
|
||||
console.warn(`[FOLDER] Repli échoué pour ${testFile}:`, fallbackErr.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1785,6 +2079,7 @@ app.post('/api/folders/default', async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
folderHash,
|
||||
name: 'Dossier par défaut',
|
||||
message: 'Dossier par défaut créé avec succès',
|
||||
})
|
||||
} catch (error) {
|
||||
@ -1796,6 +2091,17 @@ app.post('/api/folders/default', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Route pour lire la métadonnée d'un dossier
|
||||
app.get('/api/folders/:folderHash/meta', (req, res) => {
|
||||
try {
|
||||
const { folderHash } = req.params
|
||||
const meta = readFolderMeta(folderHash)
|
||||
res.json({ success: true, folderHash, name: meta?.name || null, description: meta?.description || null })
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e?.message || String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Documentation API - 4NK IA Lecoffre.io
|
||||
# Documentation API - IA Lecoffre.io
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'application 4NK IA Lecoffre.io communique uniquement avec le backend interne pour toutes les
|
||||
L'application IA Lecoffre.io communique uniquement avec le backend interne pour toutes les
|
||||
fonctionnalités (upload, extraction, analyse, contexte, conseil).
|
||||
|
||||
## API Backend Principal
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# 🎉 Système 4NK IA - Fonctionnel et Opérationnel
|
||||
# 🎉 Système IA - Fonctionnel et Opérationnel
|
||||
|
||||
## ✅ **Statut : SYSTÈME FONCTIONNEL**
|
||||
|
||||
Le système 4NK IA est maintenant **entièrement fonctionnel** et accessible via HTTPS sur le domaine `ia.4nkweb.com`.
|
||||
Le système IA est maintenant **entièrement fonctionnel** et accessible via HTTPS sur le domaine `ia.4nkweb.com`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
1
frontend.pid
Normal file
1
frontend.pid
Normal file
@ -0,0 +1 @@
|
||||
25828
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>4NK IA - Lecoffre.io</title>
|
||||
<title>IA - Lecoffre.io</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -39,7 +39,7 @@ if [ ! -f ".env" ]; then
|
||||
VITE_API_URL=http://localhost:18000
|
||||
|
||||
# Configuration pour le développement
|
||||
VITE_APP_NAME=4NK IALecoffre.io
|
||||
VITE_APP_NAME=IALecoffre.io
|
||||
VITE_APP_VERSION=0.1.0
|
||||
|
||||
# Configuration des services externes (optionnel)
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@ -2,13 +2,7 @@ import { useEffect, useCallback } from 'react'
|
||||
import './App.css'
|
||||
import { AppRouter } from './router'
|
||||
import { useAppDispatch, useAppSelector } from './store'
|
||||
import {
|
||||
createDefaultFolderThunk,
|
||||
loadFolderResults,
|
||||
setBootstrapped,
|
||||
setPollingInterval,
|
||||
stopPolling,
|
||||
} from './store/documentSlice'
|
||||
import { loadFolderResults, setBootstrapped, setCurrentFolderHash, setPollingInterval, stopPolling } from './store/documentSlice'
|
||||
|
||||
export default function App() {
|
||||
const dispatch = useAppDispatch()
|
||||
@ -35,12 +29,16 @@ export default function App() {
|
||||
try {
|
||||
let folderHash = urlFolderHash || currentFolderHash
|
||||
|
||||
// Si pas de hash de dossier, créer le dossier par défaut
|
||||
// Si un hash est passé dans l'URL, le prioriser et l'enregistrer
|
||||
if (urlFolderHash && urlFolderHash !== currentFolderHash) {
|
||||
dispatch(setCurrentFolderHash(urlFolderHash))
|
||||
}
|
||||
|
||||
// Si aucun hash n'est disponible, utiliser le dossier par défaut demandé
|
||||
if (!folderHash) {
|
||||
console.log('🚀 [APP] Création du dossier par défaut...')
|
||||
const result = await dispatch(createDefaultFolderThunk()).unwrap()
|
||||
folderHash = result.folderHash
|
||||
console.log('✅ [APP] Dossier par défaut créé:', folderHash)
|
||||
folderHash = '7d99a85daf66a0081a0e881630e6b39b'
|
||||
dispatch(setCurrentFolderHash(folderHash))
|
||||
console.log('📌 [APP] Dossier par défaut appliqué:', folderHash)
|
||||
}
|
||||
|
||||
// Charger les résultats du dossier
|
||||
|
||||
@ -117,7 +117,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
sx={{ flexGrow: 1, cursor: 'pointer' }}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
4NK IA - Lecoffre.io
|
||||
IA - Lecoffre.io
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
@ -2,7 +2,19 @@
|
||||
* API pour la gestion des dossiers par hash
|
||||
*/
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
function getApiBaseUrl(): string {
|
||||
const env: any = (import.meta as any)?.env || {}
|
||||
// En prod, si le site n'est pas servi depuis localhost, forcer la même origine
|
||||
if (env.PROD && typeof window !== 'undefined' && window.location.hostname !== 'localhost') {
|
||||
return '/api'
|
||||
}
|
||||
// Dev/local: privilégier VITE_API_BASE puis VITE_BACKEND_URL
|
||||
if (env.VITE_API_BASE) return env.VITE_API_BASE
|
||||
if (env.VITE_BACKEND_URL) return `${env.VITE_BACKEND_URL}/api`
|
||||
return '/api'
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
export interface FolderResult {
|
||||
fileHash: string
|
||||
@ -56,15 +68,18 @@ export interface CreateFolderResponse {
|
||||
success: boolean
|
||||
folderHash: string
|
||||
message: string
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
// Créer un nouveau dossier
|
||||
export async function createFolder(): Promise<CreateFolderResponse> {
|
||||
export async function createFolder(name?: string, description?: string): Promise<CreateFolderResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/folders`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: name || 'Nouveau dossier', description: description || '' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@ -92,12 +107,19 @@ export async function createDefaultFolder(): Promise<CreateFolderResponse> {
|
||||
|
||||
// Utiliser le dossier par défaut existant (sans créer de nouveau dossier)
|
||||
export async function getDefaultFolder(): Promise<CreateFolderResponse> {
|
||||
// Utiliser le dossier par défaut existant avec les fichiers de test
|
||||
return {
|
||||
success: true,
|
||||
folderHash: '7d99a85daf66a0081a0e881630e6b39b',
|
||||
message: 'Dossier par défaut récupéré',
|
||||
// Délègue au backend la création/récupération du dossier par défaut
|
||||
const response = await fetch(`${API_BASE_URL}/folders/default`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la récupération du dossier par défaut: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Récupérer les résultats d'un dossier
|
||||
@ -152,6 +174,40 @@ export async function getFolderResults(folderHash: string): Promise<FolderRespon
|
||||
}
|
||||
}
|
||||
|
||||
// Ajoute: vider le cache d'un dossier
|
||||
export async function clearFolderCache(folderHash: string): Promise<{ success: boolean; removed: number }>{
|
||||
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/cache`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du vidage du cache du dossier: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Re-traiter un dossier existant basé sur uploads/<hash>
|
||||
export async function reprocessFolder(folderHash: string): Promise<{ success: boolean; scheduled: number }>{
|
||||
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/reprocess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du re-traitement du dossier: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getFolderMeta(folderHash: string): Promise<{ success: boolean; folderHash: string; name: string | null }>{
|
||||
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/meta`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors de la récupération des métadonnées du dossier: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Récupérer un fichier original depuis un dossier
|
||||
export async function getFolderFile(folderHash: string, fileHash: string): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}`)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
Mode OpenAI (fallback) pour 4NK IA Front
|
||||
Mode OpenAI (fallback) pour IA Front
|
||||
Utilise l'API OpenAI côté frontend uniquement à des fins de démonstration/dépannage quand le backend est indisponible.
|
||||
*/
|
||||
import type {
|
||||
|
||||
@ -259,6 +259,7 @@ const documentSlice = createSlice({
|
||||
// Nouveaux reducers pour les dossiers
|
||||
setCurrentFolderHash: (state, action: PayloadAction<string | null>) => {
|
||||
state.currentFolderHash = action.payload
|
||||
// Reset du nom de dossier côté UI si besoin (le composant lira via API meta)
|
||||
},
|
||||
setCurrentResultIndex: (state, action: PayloadAction<number>) => {
|
||||
state.currentResultIndex = action.payload
|
||||
@ -418,9 +419,8 @@ const documentSlice = createSlice({
|
||||
state.documents.map((d) => ({ id: d.id, name: d.name, status: d.status })),
|
||||
)
|
||||
})
|
||||
.addCase(loadFolderResults.pending, () => {
|
||||
// Ne pas afficher la barre de progression pour le chargement initial des résultats
|
||||
// state.loading = true
|
||||
.addCase(loadFolderResults.pending, (state) => {
|
||||
state.loading = true
|
||||
})
|
||||
.addCase(loadFolderResults.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
|
||||
@ -1,50 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Alert,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Person,
|
||||
LocationOn,
|
||||
Business,
|
||||
Description,
|
||||
Language,
|
||||
Verified,
|
||||
ExpandMore,
|
||||
TextFields,
|
||||
Assessment,
|
||||
NavigateBefore,
|
||||
NavigateNext,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Typography, Paper, Card, CardContent, Chip, Button, List, ListItem, ListItemText, ListItemButton, Tooltip, Alert, Accordion, AccordionSummary, AccordionDetails, CircularProgress } from '@mui/material'
|
||||
import { Person, LocationOn, Business, Description, Language, Verified, ExpandMore, TextFields, Assessment } from '@mui/icons-material'
|
||||
import { useAppDispatch, useAppSelector } from '../store'
|
||||
import { setCurrentResultIndex } from '../store/documentSlice'
|
||||
import { clearFolderCache, reprocessFolder } from '../services/folderApi'
|
||||
import { Layout } from '../components/Layout'
|
||||
|
||||
export default function ExtractionView() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { folderResults, currentResultIndex } = useAppSelector((state) => state.document)
|
||||
const { folderResults, currentResultIndex, loading } = useAppSelector((state) => state.document)
|
||||
const { currentFolderHash } = useAppSelector((state) => state.document)
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(currentResultIndex)
|
||||
|
||||
// Utiliser les résultats du dossier pour la navigation
|
||||
const currentResult = folderResults[currentIndex]
|
||||
const hasPrev = currentIndex > 0
|
||||
const hasNext = currentIndex < folderResults.length - 1
|
||||
|
||||
const gotoResult = (index: number) => {
|
||||
if (index >= 0 && index < folderResults.length) {
|
||||
@ -53,16 +23,17 @@ export default function ExtractionView() {
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (hasPrev) {
|
||||
gotoResult(currentIndex - 1)
|
||||
}
|
||||
}
|
||||
// Navigation supprimée
|
||||
|
||||
const goToNext = () => {
|
||||
if (hasNext) {
|
||||
gotoResult(currentIndex + 1)
|
||||
}
|
||||
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) {
|
||||
@ -93,35 +64,75 @@ export default function ExtractionView() {
|
||||
Résultats d'extraction
|
||||
</Typography>
|
||||
|
||||
{/* Navigation */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<IconButton onClick={goToPrevious} disabled={!hasPrev} color="primary">
|
||||
<NavigateBefore />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="body1" sx={{ flexGrow: 1, textAlign: 'center' }}>
|
||||
Document {currentIndex + 1} sur {folderResults.length}
|
||||
</Typography>
|
||||
|
||||
<IconButton onClick={goToNext} disabled={!hasNext} color="primary">
|
||||
<NavigateNext />
|
||||
</IconButton>
|
||||
{/* Actions de dossier */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Tooltip title="Re-traiter le dossier: vide le cache puis relance l'analyse de tous les fichiers présents dans uploads/<hash>.">
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!currentFolderHash}
|
||||
onClick={async () => {
|
||||
if (!currentFolderHash) return
|
||||
try {
|
||||
const cleared = await clearFolderCache(currentFolderHash)
|
||||
const repro = await reprocessFolder(currentFolderHash)
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(
|
||||
`Cache vidé (${cleared.removed} éléments). Re-traitement lancé (${repro.scheduled} fichiers).`
|
||||
)
|
||||
} catch (e: any) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Erreur lors du re-traitement: ${e?.message || e}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Re-traiter le dossier
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Stepper pour la navigation */}
|
||||
<Stepper activeStep={currentIndex} alternativeLabel sx={{ mb: 3 }}>
|
||||
{folderResults.map((result, index) => (
|
||||
<Step key={result.fileHash}>
|
||||
<StepLabel onClick={() => gotoResult(index)} sx={{ cursor: 'pointer' }}>
|
||||
{result.document.fileName}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
{/* Navigation supprimée */}
|
||||
</Box>
|
||||
|
||||
{/* Informations du document courant */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
{/* Liste latérale de navigation avec ellipsis */}
|
||||
<Card sx={{ flex: '0 0 320px', maxHeight: '70vh', overflow: 'auto' }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<List dense disablePadding>
|
||||
{folderResults.map((result, index) => (
|
||||
<ListItemButton
|
||||
key={result.fileHash}
|
||||
selected={index === currentIndex}
|
||||
onClick={() => gotoResult(index)}
|
||||
>
|
||||
<Tooltip title={result.document.fileName} placement="right">
|
||||
<ListItemText
|
||||
primaryTypographyProps={{
|
||||
sx: {
|
||||
display: 'block',
|
||||
maxWidth: 260,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
}}
|
||||
primary={result.document.fileName}
|
||||
secondary={new Date(
|
||||
result.document.uploadTimestamp as unknown as string,
|
||||
).toLocaleString()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>
|
||||
{/* Informations du document courant */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Description color="primary" />
|
||||
@ -155,10 +166,10 @@ export default function ExtractionView() {
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* Texte extrait */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
{/* Texte extrait */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TextFields sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
@ -170,10 +181,10 @@ export default function ExtractionView() {
|
||||
</Typography>
|
||||
</Paper>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* Entités extraites */}
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* Entités extraites */}
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* Personnes */}
|
||||
{extraction.extraction.entities.persons.length > 0 && (
|
||||
<Card sx={{ flex: '1 1 300px' }}>
|
||||
@ -183,11 +194,17 @@ export default function ExtractionView() {
|
||||
Personnes ({extraction.extraction.entities.persons.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{extraction.extraction.entities.persons.map((person, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={person} secondary="Personne détectée" />
|
||||
</ListItem>
|
||||
))}
|
||||
{extraction.extraction.entities.persons.map((person: any, index: number) => {
|
||||
const label =
|
||||
typeof person === 'string'
|
||||
? person
|
||||
: [person.firstName, person.lastName].filter(Boolean).join(' ') || person?.id || 'Personne'
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={label} secondary="Personne détectée" />
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -202,11 +219,19 @@ export default function ExtractionView() {
|
||||
Adresses ({extraction.extraction.entities.addresses.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{extraction.extraction.entities.addresses.map((address, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={address} secondary="Adresse détectée" />
|
||||
</ListItem>
|
||||
))}
|
||||
{extraction.extraction.entities.addresses.map((address: any, index: number) => {
|
||||
const label =
|
||||
typeof address === 'string'
|
||||
? address
|
||||
: [address.street, address.postalCode, address.city]
|
||||
.filter((v) => !!v && String(v).trim().length > 0)
|
||||
.join(' ') || address?.id || 'Adresse'
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={label} secondary="Adresse détectée" />
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -221,19 +246,22 @@ export default function ExtractionView() {
|
||||
Entreprises ({extraction.extraction.entities.companies.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{extraction.extraction.entities.companies.map((company, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={company} secondary="Entreprise détectée" />
|
||||
</ListItem>
|
||||
))}
|
||||
{extraction.extraction.entities.companies.map((company: any, index: number) => {
|
||||
const label = typeof company === 'string' ? company : company?.name || company?.id || 'Entreprise'
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={label} secondary="Entreprise détectée" />
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Métadonnées détaillées */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{/* Métadonnées détaillées */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Métadonnées détaillées
|
||||
@ -259,7 +287,9 @@ export default function ExtractionView() {
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import {
|
||||
Box,
|
||||
@ -25,14 +25,14 @@ import {
|
||||
import {
|
||||
CloudUpload,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Error as ErrorIcon,
|
||||
HourglassEmpty,
|
||||
Visibility,
|
||||
Description,
|
||||
Image,
|
||||
PictureAsPdf,
|
||||
FolderOpen,
|
||||
Add,
|
||||
Add as AddIcon,
|
||||
ContentCopy,
|
||||
} from '@mui/icons-material'
|
||||
import { useAppDispatch, useAppSelector } from '../store'
|
||||
@ -40,28 +40,45 @@ import {
|
||||
uploadFileToFolderThunk,
|
||||
loadFolderResults,
|
||||
removeDocument,
|
||||
createDefaultFolderThunk,
|
||||
setCurrentFolderHash,
|
||||
} from '../store/documentSlice'
|
||||
import { Layout } from '../components/Layout'
|
||||
import { FilePreview } from '../components/FilePreview'
|
||||
import type { Document } from '../types'
|
||||
import { getFolderMeta } from '../services/folderApi'
|
||||
|
||||
export default function UploadView() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { documents, error, currentFolderHash } = useAppSelector((state) => state.document)
|
||||
const [folderName, setFolderName] = useState<string>('')
|
||||
|
||||
console.log('🏠 [UPLOAD_VIEW] Component loaded, documents count:', documents.length)
|
||||
const [previewDocument, setPreviewDocument] = useState<Document | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const [newFolderDesc, setNewFolderDesc] = useState('')
|
||||
const [newFolderHash, setNewFolderHash] = useState('')
|
||||
|
||||
// Fonction pour créer un nouveau dossier
|
||||
// Créer un nouveau dossier
|
||||
const handleCreateNewFolder = useCallback(async () => {
|
||||
try {
|
||||
const result = await dispatch(createDefaultFolderThunk()).unwrap()
|
||||
console.log('✅ [UPLOAD] Nouveau dossier créé:', result.folderHash)
|
||||
setDialogOpen(false)
|
||||
const res = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newFolderName || 'Nouveau dossier', description: newFolderDesc || '' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
dispatch(setCurrentFolderHash(data.folderHash))
|
||||
await dispatch(loadFolderResults(data.folderHash)).unwrap()
|
||||
try {
|
||||
setFolderName(data?.name || data.folderHash)
|
||||
} catch {}
|
||||
console.log('✅ [UPLOAD] Nouveau dossier créé:', data.folderHash)
|
||||
setCreateOpen(false)
|
||||
setNewFolderName('')
|
||||
setNewFolderDesc('')
|
||||
} catch (error) {
|
||||
console.error('❌ [UPLOAD] Erreur lors de la création du dossier:', error)
|
||||
}
|
||||
@ -74,6 +91,10 @@ export default function UploadView() {
|
||||
try {
|
||||
dispatch(setCurrentFolderHash(newFolderHash.trim()))
|
||||
await dispatch(loadFolderResults(newFolderHash.trim())).unwrap()
|
||||
try {
|
||||
const meta = await getFolderMeta(newFolderHash.trim())
|
||||
setFolderName(meta?.name || newFolderHash.trim())
|
||||
} catch {}
|
||||
console.log('✅ [UPLOAD] Dossier chargé:', newFolderHash.trim())
|
||||
setDialogOpen(false)
|
||||
setNewFolderHash('')
|
||||
@ -132,7 +153,7 @@ export default function UploadView() {
|
||||
case 'completed':
|
||||
return <CheckCircle color="success" />
|
||||
case 'error':
|
||||
return <Error color="error" />
|
||||
return <ErrorIcon color="error" />
|
||||
case 'processing':
|
||||
return <CircularProgress size={20} />
|
||||
default:
|
||||
@ -155,6 +176,20 @@ export default function UploadView() {
|
||||
|
||||
// Bootstrap maintenant géré dans App.tsx
|
||||
|
||||
// Charger le nom du dossier quand le hash courant change
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
if (!currentFolderHash) return
|
||||
try {
|
||||
const meta = await getFolderMeta(currentFolderHash)
|
||||
setFolderName(meta?.name || currentFolderHash)
|
||||
} catch {
|
||||
setFolderName(currentFolderHash)
|
||||
}
|
||||
}
|
||||
run()
|
||||
}, [currentFolderHash])
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.includes('pdf')) return <PictureAsPdf color="error" />
|
||||
if (mimeType.includes('image')) return <Image color="primary" />
|
||||
@ -164,7 +199,7 @@ export default function UploadView() {
|
||||
return (
|
||||
<Layout>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Analyse de documents 4NK IA
|
||||
Analyse de documents IA
|
||||
</Typography>
|
||||
|
||||
{/* En-tête avec hash du dossier et boutons */}
|
||||
@ -201,7 +236,7 @@ export default function UploadView() {
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{currentFolderHash || 'Aucun dossier sélectionné'}
|
||||
{folderName || currentFolderHash || 'Aucun dossier sélectionné'}
|
||||
</Typography>
|
||||
{currentFolderHash && (
|
||||
<Tooltip title="Copier le hash du dossier">
|
||||
@ -214,13 +249,8 @@ export default function UploadView() {
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateNewFolder}
|
||||
size="small"
|
||||
>
|
||||
Nouveau dossier
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)} size="small">
|
||||
CRÉER UN DOSSIER
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@ -302,7 +332,7 @@ export default function UploadView() {
|
||||
maxWidth: { xs: '200px', sm: '300px', md: '400px' },
|
||||
}}
|
||||
>
|
||||
{doc.name}
|
||||
{(doc as any).displayName || doc.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
@ -397,6 +427,43 @@ export default function UploadView() {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialogue pour créer un dossier avec nom et description */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AddIcon />
|
||||
Créer un nouveau dossier
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Nom du dossier"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Description"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
variant="outlined"
|
||||
value={newFolderDesc}
|
||||
onChange={(e) => setNewFolderDesc(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Annuler</Button>
|
||||
<Button onClick={handleCreateNewFolder} variant="contained" disabled={!newFolderName.trim()}>
|
||||
Créer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user