InvoiceScanner.jsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { useEffect, useState } from 'react'
  2. import api from '../api'
  3. import toast from 'react-hot-toast'
  4. const STATUS_LABELS = {
  5. pending: { label: 'En attente', class: 'bg-yellow-100 text-yellow-800' },
  6. approved: { label: 'Approuvée', class: 'bg-green-100 text-green-800' },
  7. rejected: { label: 'Rejetée', class: 'bg-red-100 text-red-800' },
  8. }
  9. function WideModal({ title, onClose, children }) {
  10. return (
  11. <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
  12. <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-5xl max-h-[92vh] flex flex-col">
  13. <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0">
  14. <h2 className="text-lg font-semibold">{title}</h2>
  15. <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
  16. </div>
  17. <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
  18. </div>
  19. </div>
  20. )
  21. }
  22. function Modal({ title, onClose, children }) {
  23. return (
  24. <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
  25. <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] flex flex-col">
  26. <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0">
  27. <h2 className="text-lg font-semibold">{title}</h2>
  28. <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
  29. </div>
  30. <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
  31. </div>
  32. </div>
  33. )
  34. }
  35. export default function InvoiceScanner() {
  36. const [properties, setProperties] = useState([])
  37. const [invoices, setInvoices] = useState([])
  38. const [selectedProperty, setSelectedProperty] = useState('')
  39. const [filterStatus, setFilterStatus] = useState('pending')
  40. const [filterYear, setFilterYear] = useState('')
  41. const [scanning, setScanning] = useState(false)
  42. const [scanResult, setScanResult] = useState(null)
  43. const [stats, setStats] = useState(null)
  44. const [editInvoice, setEditInvoice] = useState(null)
  45. const [editForm, setEditForm] = useState({})
  46. const [previewId, setPreviewId] = useState(null)
  47. const [previewUrl, setPreviewUrl] = useState(null)
  48. const [categories, setCategories] = useState([])
  49. const [selected, setSelected] = useState(new Set())
  50. const [filterText, setFilterText] = useState('')
  51. const [editPreviewUrl, setEditPreviewUrl] = useState(null)
  52. const loadProperties = () => api.get('/properties').then(r => setProperties(r.data))
  53. const loadStats = () => api.get('/scan/stats').then(r => setStats(r.data))
  54. const loadCategories = () => api.get('/categories').then(r => setCategories(r.data))
  55. const loadInvoices = () => {
  56. const params = new URLSearchParams()
  57. if (selectedProperty) params.set('property_id', selectedProperty)
  58. if (filterStatus) params.set('status', filterStatus)
  59. if (filterYear) params.set('year', filterYear)
  60. api.get(`/scan/invoices?${params}`).then(r => setInvoices(r.data))
  61. }
  62. useEffect(() => { loadProperties(); loadStats(); loadCategories() }, [])
  63. useEffect(() => { loadInvoices(); setSelected(new Set()) }, [selectedProperty, filterStatus, filterYear])
  64. const propertiesWithScan = properties.filter(p => p.scan_directory)
  65. const handleScan = async (propertyId) => {
  66. setScanning(true)
  67. setScanResult(null)
  68. try {
  69. const r = await api.post(`/scan/${propertyId}`)
  70. setScanResult(r.data)
  71. toast.success(`Scan terminé : ${r.data.new} nouvelle(s) facture(s)`)
  72. loadInvoices()
  73. loadStats()
  74. } catch (err) {
  75. toast.error(err.response?.data?.error || 'Erreur lors du scan')
  76. } finally {
  77. setScanning(false)
  78. }
  79. }
  80. const handleScanAll = async () => {
  81. setScanning(true)
  82. let totalNew = 0
  83. for (const p of propertiesWithScan) {
  84. try {
  85. const r = await api.post(`/scan/${p.id}`)
  86. totalNew += r.data.new
  87. } catch {}
  88. }
  89. toast.success(`Scan global terminé : ${totalNew} nouvelle(s) facture(s)`)
  90. setScanning(false)
  91. loadInvoices()
  92. loadStats()
  93. }
  94. const openEdit = async (inv) => {
  95. setEditForm({
  96. detected_supplier: inv.detected_supplier || '',
  97. detected_category: inv.detected_category || 'autre',
  98. detected_amount: inv.detected_amount || '',
  99. detected_label: inv.detected_label || '',
  100. year: inv.year || new Date().getFullYear(),
  101. notes: inv.notes || '',
  102. recoverable_type: 'recoverable',
  103. })
  104. setEditInvoice(inv)
  105. // Load preview for edit modal
  106. try {
  107. const res = await api.get(`/scan/invoices/${inv.id}/preview`, { responseType: 'blob' })
  108. const url = URL.createObjectURL(res.data)
  109. setEditPreviewUrl(url)
  110. } catch {
  111. setEditPreviewUrl(null)
  112. }
  113. }
  114. const closeEdit = () => {
  115. if (editPreviewUrl) URL.revokeObjectURL(editPreviewUrl)
  116. setEditPreviewUrl(null)
  117. setEditInvoice(null)
  118. }
  119. const handleDownload = async (inv) => {
  120. try {
  121. const res = await api.get(`/scan/invoices/${inv.id}/preview`, { responseType: 'blob' })
  122. const url = URL.createObjectURL(res.data)
  123. const a = document.createElement('a')
  124. a.href = url
  125. a.download = inv.file_path.split('/').pop()
  126. document.body.appendChild(a)
  127. a.click()
  128. document.body.removeChild(a)
  129. URL.revokeObjectURL(url)
  130. } catch {
  131. toast.error('Impossible de télécharger le fichier')
  132. }
  133. }
  134. const handleSave = async () => {
  135. try {
  136. await api.put(`/scan/invoices/${editInvoice.id}`, editForm)
  137. toast.success('Facture mise à jour')
  138. setEditInvoice(null)
  139. loadInvoices()
  140. } catch (err) {
  141. toast.error(err.response?.data?.error || 'Erreur')
  142. }
  143. }
  144. const handleApprove = async (inv) => {
  145. const target = editInvoice || inv
  146. const form = editInvoice ? editForm : {}
  147. if (!target.detected_amount && !form.detected_amount) {
  148. toast.error('Veuillez renseigner le montant avant d\'approuver')
  149. return
  150. }
  151. try {
  152. if (editInvoice) {
  153. await api.put(`/scan/invoices/${target.id}`, editForm)
  154. }
  155. await api.post(`/scan/invoices/${target.id}/approve`, { recoverable_type: form.recoverable_type || 'recoverable' })
  156. toast.success('Facture approuvée et intégrée aux charges !')
  157. setEditInvoice(null)
  158. loadInvoices()
  159. loadStats()
  160. } catch (err) {
  161. toast.error(err.response?.data?.error || 'Erreur')
  162. }
  163. }
  164. const handleReject = async (inv) => {
  165. if (!confirm('Rejeter cette facture ?')) return
  166. try {
  167. await api.post(`/scan/invoices/${inv.id}/reject`)
  168. toast.success('Facture rejetée')
  169. loadInvoices()
  170. loadStats()
  171. } catch (err) {
  172. toast.error(err.response?.data?.error || 'Erreur')
  173. }
  174. }
  175. const handleDelete = async (inv) => {
  176. if (!confirm('Supprimer cette entrée ?')) return
  177. try {
  178. await api.delete(`/scan/invoices/${inv.id}`)
  179. toast.success('Entrée supprimée')
  180. loadInvoices()
  181. loadStats()
  182. } catch (err) {
  183. toast.error(err.response?.data?.error || 'Erreur')
  184. }
  185. }
  186. const openPreview = async (id) => {
  187. try {
  188. const res = await api.get(`/scan/invoices/${id}/preview`, { responseType: 'blob' })
  189. const url = URL.createObjectURL(res.data)
  190. setPreviewUrl(url)
  191. setPreviewId(id)
  192. } catch {
  193. toast.error('Impossible de charger l\'aperçu')
  194. }
  195. }
  196. const closePreview = () => {
  197. if (previewUrl) URL.revokeObjectURL(previewUrl)
  198. setPreviewUrl(null)
  199. setPreviewId(null)
  200. }
  201. const years = [...new Set(invoices.map(i => i.year).filter(Boolean))].sort((a, b) => b - a)
  202. const allYears = years.length > 0 ? years : [new Date().getFullYear()]
  203. // Client-side text filter on filename
  204. const filteredInvoices = filterText
  205. ? invoices.filter(inv => inv.file_path.toLowerCase().includes(filterText.toLowerCase()))
  206. : invoices
  207. // Selection helpers
  208. const toggleSelect = (id) => {
  209. setSelected(prev => {
  210. const next = new Set(prev)
  211. next.has(id) ? next.delete(id) : next.add(id)
  212. return next
  213. })
  214. }
  215. const toggleSelectAll = () => {
  216. if (selected.size === filteredInvoices.length) {
  217. setSelected(new Set())
  218. } else {
  219. setSelected(new Set(filteredInvoices.map(i => i.id)))
  220. }
  221. }
  222. const selectedInvoices = filteredInvoices.filter(i => selected.has(i.id))
  223. const handleBulkReject = async () => {
  224. const pending = selectedInvoices.filter(i => i.status === 'pending')
  225. if (pending.length === 0) return toast.error('Aucune facture en attente sélectionnée')
  226. if (!confirm(`Rejeter ${pending.length} facture(s) ?`)) return
  227. let ok = 0
  228. for (const inv of pending) {
  229. try { await api.post(`/scan/invoices/${inv.id}/reject`); ok++ } catch {}
  230. }
  231. toast.success(`${ok} facture(s) rejetée(s)`)
  232. setSelected(new Set())
  233. loadInvoices()
  234. loadStats()
  235. }
  236. const handleBulkDelete = async () => {
  237. const deletable = selectedInvoices.filter(i => i.status !== 'approved')
  238. if (deletable.length === 0) return toast.error('Aucune facture supprimable sélectionnée')
  239. if (!confirm(`Supprimer ${deletable.length} facture(s) ?`)) return
  240. let ok = 0
  241. for (const inv of deletable) {
  242. try { await api.delete(`/scan/invoices/${inv.id}`); ok++ } catch {}
  243. }
  244. toast.success(`${ok} facture(s) supprimée(s)`)
  245. setSelected(new Set())
  246. loadInvoices()
  247. loadStats()
  248. }
  249. return (
  250. <div>
  251. <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-8 gap-4">
  252. <div>
  253. <h1 className="text-2xl font-bold text-gray-900">📄 Scanner de factures</h1>
  254. <p className="text-gray-500 mt-1">Détection automatique et approbation des factures</p>
  255. </div>
  256. {propertiesWithScan.length > 0 && (
  257. <button onClick={handleScanAll} disabled={scanning} className="btn-primary whitespace-nowrap">
  258. {scanning ? '⏳ Scan en cours...' : '🔍 Scanner tous les biens'}
  259. </button>
  260. )}
  261. </div>
  262. {/* Stats */}
  263. {stats && (
  264. <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
  265. <div className="card text-center">
  266. <p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
  267. <p className="text-xs text-gray-500">En attente</p>
  268. </div>
  269. <div className="card text-center">
  270. <p className="text-2xl font-bold text-green-600">{stats.approved}</p>
  271. <p className="text-xs text-gray-500">Approuvées</p>
  272. </div>
  273. <div className="card text-center">
  274. <p className="text-2xl font-bold text-red-600">{stats.rejected}</p>
  275. <p className="text-xs text-gray-500">Rejetées</p>
  276. </div>
  277. <div className="card text-center">
  278. <p className="text-2xl font-bold text-gray-700">{stats.total}</p>
  279. <p className="text-xs text-gray-500">Total</p>
  280. </div>
  281. </div>
  282. )}
  283. {/* Scan buttons per property */}
  284. {propertiesWithScan.length > 0 && (
  285. <div className="card mb-6">
  286. <h2 className="font-semibold text-gray-800 mb-3">Biens avec dossier de scan configuré</h2>
  287. <div className="space-y-2">
  288. {propertiesWithScan.map(p => (
  289. <div key={p.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2">
  290. <div>
  291. <span className="font-medium text-gray-800">{p.name}</span>
  292. <span className="text-xs text-gray-400 ml-2">{p.scan_directory}</span>
  293. {p.scan_cron_enabled ? <span className="badge-blue ml-2 text-xs">Auto</span> : null}
  294. </div>
  295. <button onClick={() => handleScan(p.id)} disabled={scanning} className="text-sm text-blue-600 hover:text-blue-800 font-medium">
  296. 🔍 Scanner
  297. </button>
  298. </div>
  299. ))}
  300. </div>
  301. {scanResult && (
  302. <div className="mt-3 p-3 bg-blue-50 rounded-lg text-sm">
  303. Résultat : {scanResult.scanned} fichier(s) analysé(s), <strong>{scanResult.new} nouveau(x)</strong>, {scanResult.duplicates} doublon(s), {scanResult.errors} erreur(s)
  304. </div>
  305. )}
  306. </div>
  307. )}
  308. {propertiesWithScan.length === 0 && (
  309. <div className="card text-center py-16 mb-6">
  310. <div className="text-5xl mb-4">📂</div>
  311. <p className="text-gray-500">Aucun bien n'a de dossier de scan configuré.</p>
  312. <p className="text-gray-400 text-sm mt-1">Rendez-vous dans la page Biens pour configurer un dossier de scan.</p>
  313. </div>
  314. )}
  315. {/* Filters */}
  316. <div className="flex flex-wrap gap-3 mb-4">
  317. <select className="input w-auto" value={selectedProperty} onChange={e => setSelectedProperty(e.target.value)}>
  318. <option value="">Tous les biens</option>
  319. {properties.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
  320. </select>
  321. <select className="input w-auto" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
  322. <option value="">Tous les statuts</option>
  323. <option value="pending">En attente</option>
  324. <option value="approved">Approuvées</option>
  325. <option value="rejected">Rejetées</option>
  326. </select>
  327. <select className="input w-auto" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
  328. <option value="">Toutes les années</option>
  329. {allYears.map(y => <option key={y} value={y}>{y}</option>)}
  330. </select>
  331. <input className="input w-auto" type="text" value={filterText} onChange={e => setFilterText(e.target.value)} placeholder="🔍 Filtrer par nom de fichier..." />
  332. </div>
  333. {/* Selection toolbar */}
  334. {selected.size > 0 && (
  335. <div className="flex items-center gap-3 mb-4 p-3 bg-blue-50 rounded-lg">
  336. <span className="text-sm font-medium text-blue-800">{selected.size} sélectionnée(s)</span>
  337. <button onClick={handleBulkReject} className="text-sm text-red-600 hover:text-red-800 font-medium">❌ Rejeter</button>
  338. <button onClick={handleBulkDelete} className="text-sm text-red-600 hover:text-red-800 font-medium">🗑️ Supprimer</button>
  339. <button onClick={() => setSelected(new Set())} className="text-sm text-gray-500 hover:text-gray-700 ml-auto">Désélectionner</button>
  340. </div>
  341. )}
  342. {/* Invoice list */}
  343. {filteredInvoices.length === 0 ? (
  344. <div className="card text-center py-12">
  345. <p className="text-gray-400">Aucune facture scannée pour les filtres sélectionnés</p>
  346. </div>
  347. ) : (
  348. <div className="space-y-3">
  349. {/* Select all header */}
  350. <div className="flex items-center gap-3 px-1">
  351. <input type="checkbox" checked={selected.size === filteredInvoices.length && filteredInvoices.length > 0}
  352. onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600" />
  353. <span className="text-xs text-gray-500">Tout sélectionner ({filteredInvoices.length})</span>
  354. </div>
  355. {filteredInvoices.map(inv => (
  356. <div key={inv.id} className={`card hover:shadow-md transition-shadow ${selected.has(inv.id) ? 'ring-2 ring-blue-300' : ''}`}>
  357. <div className="flex flex-col sm:flex-row sm:items-center gap-3">
  358. <input type="checkbox" checked={selected.has(inv.id)} onChange={() => toggleSelect(inv.id)}
  359. className="rounded border-gray-300 text-blue-600 shrink-0 mt-1 sm:mt-0" />
  360. <div className="flex-1 min-w-0">
  361. <div className="flex items-center gap-2 mb-1">
  362. <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[inv.status]?.class}`}>
  363. {STATUS_LABELS[inv.status]?.label}
  364. </span>
  365. <span className="text-sm font-medium text-gray-800 truncate">{inv.detected_label || 'Facture à identifier'}</span>
  366. {inv.year && <span className="text-xs text-gray-400">{inv.year}</span>}
  367. </div>
  368. <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
  369. <span>🏠 {inv.property_name}</span>
  370. {inv.detected_supplier && <span>🏢 {inv.detected_supplier}</span>}
  371. {inv.detected_category && <span>📁 {categories.find(c => c.value === inv.detected_category)?.label || inv.detected_category}</span>}
  372. {inv.detected_amount != null && <span className="font-medium text-gray-700">💰 {inv.detected_amount.toFixed(2)} €</span>}
  373. </div>
  374. <p className="text-xs text-gray-400 mt-1 truncate" title={inv.file_path}>{inv.file_path.split('/').slice(-2).join('/')}</p>
  375. </div>
  376. <div className="flex items-center gap-2 shrink-0">
  377. <button onClick={() => openPreview(inv.id)} className="text-sm text-gray-500 hover:text-blue-600" title="Prévisualiser">👁️</button>
  378. {inv.status === 'pending' && (
  379. <>
  380. <button onClick={() => openEdit(inv)} className="text-sm text-blue-600 hover:text-blue-800" title="Modifier et approuver">✏️</button>
  381. <button onClick={() => handleApprove(inv)} className="text-sm text-green-600 hover:text-green-800" title="Approuver">✅</button>
  382. <button onClick={() => handleReject(inv)} className="text-sm text-red-600 hover:text-red-800" title="Rejeter">❌</button>
  383. </>
  384. )}
  385. {inv.status !== 'approved' && (
  386. <button onClick={() => handleDelete(inv)} className="text-sm text-gray-400 hover:text-red-600" title="Supprimer">🗑️</button>
  387. )}
  388. </div>
  389. </div>
  390. </div>
  391. ))}
  392. </div>
  393. )}
  394. {/* Preview modal */}
  395. {previewId && previewUrl && (
  396. <Modal title="Prévisualisation" onClose={closePreview}>
  397. <iframe
  398. src={previewUrl}
  399. className="w-full h-[70vh] border rounded-lg"
  400. title="Aperçu facture"
  401. />
  402. </Modal>
  403. )}
  404. {/* Edit/Approve modal - split layout */}
  405. {editInvoice && (
  406. <WideModal title="Vérifier et approuver la facture" onClose={closeEdit}>
  407. <div className="flex flex-col md:flex-row gap-6">
  408. {/* Left: PDF preview + download */}
  409. <div className="md:w-1/2 flex flex-col gap-3">
  410. {editPreviewUrl ? (
  411. <iframe
  412. src={editPreviewUrl}
  413. className="w-full h-[65vh] border rounded-lg bg-gray-50"
  414. title="Aperçu facture"
  415. />
  416. ) : (
  417. <div className="w-full h-[65vh] border rounded-lg bg-gray-50 flex items-center justify-center text-gray-400">
  418. Chargement de l'aperçu...
  419. </div>
  420. )}
  421. <div className="flex items-center gap-3 text-xs text-gray-500">
  422. <p className="truncate flex-1" title={editInvoice.file_path}>📄 {editInvoice.file_path.split('/').slice(-2).join('/')}</p>
  423. <button onClick={() => handleDownload(editInvoice)} className="text-blue-600 hover:text-blue-800 font-medium whitespace-nowrap">⬇️ Télécharger</button>
  424. </div>
  425. </div>
  426. {/* Right: form */}
  427. <div className="md:w-1/2 space-y-4">
  428. <div className="grid grid-cols-2 gap-4">
  429. <div className="col-span-2">
  430. <label className="label">Libellé</label>
  431. <input className="input" value={editForm.detected_label} onChange={e => setEditForm(f => ({ ...f, detected_label: e.target.value }))} />
  432. </div>
  433. <div>
  434. <label className="label">Fournisseur</label>
  435. <input className="input" value={editForm.detected_supplier} onChange={e => setEditForm(f => ({ ...f, detected_supplier: e.target.value }))} placeholder="Ex: EDF" />
  436. </div>
  437. <div>
  438. <label className="label">Catégorie</label>
  439. <select className="input" value={editForm.detected_category} onChange={e => setEditForm(f => ({ ...f, detected_category: e.target.value }))}>
  440. {categories.map(c => <option key={c.value} value={c.value}>{c.icon} {c.label}</option>)}
  441. </select>
  442. </div>
  443. <div>
  444. <label className="label">Montant (€)</label>
  445. <input className="input" type="number" step="0.01" min="0" value={editForm.detected_amount} onChange={e => setEditForm(f => ({ ...f, detected_amount: parseFloat(e.target.value) || '' }))} />
  446. </div>
  447. <div>
  448. <label className="label">Année</label>
  449. <input className="input" type="number" min="2000" max="2099" value={editForm.year} onChange={e => setEditForm(f => ({ ...f, year: parseInt(e.target.value) || '' }))} />
  450. </div>
  451. <div>
  452. <label className="label">Type de charge</label>
  453. <select className="input" value={editForm.recoverable_type} onChange={e => setEditForm(f => ({ ...f, recoverable_type: e.target.value }))}>
  454. <option value="recoverable">Récupérable</option>
  455. <option value="deductible">Déductible</option>
  456. <option value="none">Non récupérable</option>
  457. </select>
  458. </div>
  459. <div>
  460. <label className="label">Notes</label>
  461. <input className="input" value={editForm.notes} onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))} placeholder="Optionnel" />
  462. </div>
  463. </div>
  464. <div className="flex gap-3 pt-2">
  465. <button onClick={closeEdit} className="btn-secondary flex-1">Annuler</button>
  466. <button onClick={handleSave} className="btn-secondary flex-1">💾 Sauvegarder</button>
  467. <button onClick={() => handleApprove(editInvoice)} className="btn-primary flex-1">✅ Approuver</button>
  468. </div>
  469. </div>
  470. </div>
  471. </WideModal>
  472. )}
  473. </div>
  474. )
  475. }