feat(entities): suppression unitaire + édition inline (personnes/adresses/entreprises) avec persistance cache
This commit is contained in:
parent
984c3838ae
commit
43ebc94b5b
@ -2267,6 +2267,48 @@ app.post('/api/folders/:folderHash/files/:fileHash/entities/delete', express.jso
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mettre à jour une entité (personne/adresse/entreprise) dans le cache JSON
|
||||||
|
// Body: { kind: 'person'|'address'|'company', id?: string, index?: number, patch: object }
|
||||||
|
app.post('/api/folders/:folderHash/files/:fileHash/entities/update', express.json(), (req, res) => {
|
||||||
|
try {
|
||||||
|
const { folderHash, fileHash } = req.params
|
||||||
|
const { kind, id, index, patch } = req.body || {}
|
||||||
|
if (!['person', 'address', 'company'].includes(kind)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Paramètre kind invalide' })
|
||||||
|
}
|
||||||
|
if (!patch || typeof patch !== 'object') {
|
||||||
|
return res.status(400).json({ success: false, error: 'Patch invalide' })
|
||||||
|
}
|
||||||
|
const jsonPath = path.join('cache', folderHash, `${fileHash}.json`)
|
||||||
|
if (!fs.existsSync(jsonPath)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Résultat non trouvé' })
|
||||||
|
}
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
||||||
|
const ents = (((data || {}).extraction || {}).entities || {})
|
||||||
|
const map = { person: 'persons', address: 'addresses', company: 'companies' }
|
||||||
|
const key = map[kind]
|
||||||
|
if (!Array.isArray(ents[key])) ents[key] = []
|
||||||
|
|
||||||
|
let targetIndex = -1
|
||||||
|
if (typeof index === 'number' && index >= 0 && index < ents[key].length) {
|
||||||
|
targetIndex = index
|
||||||
|
} else if (typeof id === 'string' && id.length > 0) {
|
||||||
|
targetIndex = ents[key].findIndex((e) => e && e.id === id)
|
||||||
|
}
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Entité non trouvée' })
|
||||||
|
}
|
||||||
|
const before = ents[key][targetIndex] || {}
|
||||||
|
const updated = { ...before, ...patch }
|
||||||
|
ents[key][targetIndex] = updated
|
||||||
|
// Sauvegarder
|
||||||
|
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2))
|
||||||
|
return res.json({ success: true, kind, index: targetIndex, entity: updated })
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ success: false, error: e?.message || String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Suppression d'un fichier d'un dossier (uploads + cache)
|
// Suppression d'un fichier d'un dossier (uploads + cache)
|
||||||
app.delete('/api/folders/:folderHash/files/:fileHash', (req, res) => {
|
app.delete('/api/folders/:folderHash/files/:fileHash', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ module.exports = {
|
|||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 3001,
|
PORT: 3001,
|
||||||
|
OLLAMA_MIN_REVIEW_MS: process.env.OLLAMA_MIN_REVIEW_MS || 0,
|
||||||
},
|
},
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: 'fork',
|
exec_mode: 'fork',
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import {
|
|||||||
import type { Document } from '../types'
|
import type { Document } from '../types'
|
||||||
import { useAppDispatch, useAppSelector } from '../store'
|
import { useAppDispatch, useAppSelector } from '../store'
|
||||||
import { loadFolderResults } from '../store/documentSlice'
|
import { loadFolderResults } from '../store/documentSlice'
|
||||||
import { deleteEntity } from '../services/folderApi'
|
import { deleteEntity, updateEntity } from '../services/folderApi'
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
document: Document
|
document: Document
|
||||||
@ -39,6 +39,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [scale, setScale] = useState(1.0)
|
const [scale, setScale] = useState(1.0)
|
||||||
const [numPages, setNumPages] = useState(0)
|
const [numPages, setNumPages] = useState(0)
|
||||||
|
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -323,8 +324,29 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
<Box>
|
<Box>
|
||||||
{Array.isArray((fullResult?.extraction?.entities?.persons)) && fullResult.extraction.entities.persons.length > 0 ? (
|
{Array.isArray((fullResult?.extraction?.entities?.persons)) && fullResult.extraction.entities.persons.length > 0 ? (
|
||||||
fullResult.extraction.entities.persons.map((p: any, i: number) => (
|
fullResult.extraction.entities.persons.map((p: any, i: number) => (
|
||||||
<Box key={`p-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5 }}>
|
<Box key={`p-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5, gap: 1 }}>
|
||||||
<Typography variant="body2">{p.firstName} {p.lastName}</Typography>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<input style={{ padding: 4 }} defaultValue={p.firstName} onChange={(e) => (p.firstName = e.target.value)} />
|
||||||
|
<input style={{ padding: 4 }} defaultValue={p.lastName} onChange={(e) => (p.lastName = e.target.value)} />
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!currentFolderHash || savingKey === `p-${i}`}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentFolderHash) return
|
||||||
|
try {
|
||||||
|
setSavingKey(`p-${i}`)
|
||||||
|
await updateEntity(currentFolderHash, document.id, 'person', { index: i, id: p.id, patch: { firstName: p.firstName, lastName: p.lastName } })
|
||||||
|
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
|
||||||
|
} finally {
|
||||||
|
setSavingKey(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
@ -342,6 +364,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@ -354,8 +377,31 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
<Box>
|
<Box>
|
||||||
{Array.isArray((fullResult?.extraction?.entities?.addresses)) && fullResult.extraction.entities.addresses.length > 0 ? (
|
{Array.isArray((fullResult?.extraction?.entities?.addresses)) && fullResult.extraction.entities.addresses.length > 0 ? (
|
||||||
fullResult.extraction.entities.addresses.map((a: any, i: number) => (
|
fullResult.extraction.entities.addresses.map((a: any, i: number) => (
|
||||||
<Box key={`a-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5 }}>
|
<Box key={`a-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5, gap: 1 }}>
|
||||||
<Typography variant="body2">{a.street}, {a.postalCode} {a.city} {a.country || ''}</Typography>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<input style={{ padding: 4, width: 220 }} defaultValue={a.street} onChange={(e) => (a.street = e.target.value)} />
|
||||||
|
<input style={{ padding: 4, width: 100 }} defaultValue={a.postalCode} onChange={(e) => (a.postalCode = e.target.value)} />
|
||||||
|
<input style={{ padding: 4, width: 160 }} defaultValue={a.city} onChange={(e) => (a.city = e.target.value)} />
|
||||||
|
<input style={{ padding: 4, width: 120 }} defaultValue={a.country || ''} onChange={(e) => (a.country = e.target.value)} />
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!currentFolderHash || savingKey === `a-${i}`}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentFolderHash) return
|
||||||
|
try {
|
||||||
|
setSavingKey(`a-${i}`)
|
||||||
|
await updateEntity(currentFolderHash, document.id, 'address', { index: i, id: a.id, patch: { street: a.street, city: a.city, postalCode: a.postalCode, country: a.country } })
|
||||||
|
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
|
||||||
|
} finally {
|
||||||
|
setSavingKey(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
@ -372,6 +418,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@ -384,8 +431,28 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
<Box>
|
<Box>
|
||||||
{Array.isArray((fullResult?.extraction?.entities?.companies)) && fullResult.extraction.entities.companies.length > 0 ? (
|
{Array.isArray((fullResult?.extraction?.entities?.companies)) && fullResult.extraction.entities.companies.length > 0 ? (
|
||||||
fullResult.extraction.entities.companies.map((c: any, i: number) => (
|
fullResult.extraction.entities.companies.map((c: any, i: number) => (
|
||||||
<Box key={`c-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5 }}>
|
<Box key={`c-${i}`} display="flex" alignItems="center" justifyContent="space-between" sx={{ py: 0.5, gap: 1 }}>
|
||||||
<Typography variant="body2">{c.name}</Typography>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<input style={{ padding: 4, width: 260 }} defaultValue={c.name} onChange={(e) => (c.name = e.target.value)} />
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!currentFolderHash || savingKey === `c-${i}`}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentFolderHash) return
|
||||||
|
try {
|
||||||
|
setSavingKey(`c-${i}`)
|
||||||
|
await updateEntity(currentFolderHash, document.id, 'company', { index: i, id: c.id, patch: { name: c.name } })
|
||||||
|
await dispatch(loadFolderResults(currentFolderHash)).unwrap()
|
||||||
|
} finally {
|
||||||
|
setSavingKey(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
@ -402,6 +469,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ document, onClose }) =
|
|||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -364,3 +364,24 @@ export async function deleteEntity(
|
|||||||
}
|
}
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mettre à jour une entité dans le cache (merge partiel)
|
||||||
|
export async function updateEntity(
|
||||||
|
folderHash: string,
|
||||||
|
fileHash: string,
|
||||||
|
kind: 'person' | 'address' | 'company',
|
||||||
|
payload: { id?: string; index?: number; patch: Record<string, any> },
|
||||||
|
): Promise<{ success: boolean }>{
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/folders/${folderHash}/files/${fileHash}/entities/update`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ kind, ...payload }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur mise à jour entité: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user