deploy: notesfrais — 2026-04-29 09:57:19
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Hook OCR avec Tesseract.js (100% local, compatible offline).
|
||||
*
|
||||
* Stratégie de parsing :
|
||||
* 1. Montant — cherche le plus grand nombre décimal de la page
|
||||
* 2. Date — cherche une date dans les formats courants (JJ/MM/AAAA, AAAA-MM-JJ, etc.)
|
||||
* 3. Fournisseur — première ligne significative du texte
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
export interface OcrResult {
|
||||
rawText: string;
|
||||
amount: string; // ex: "42.50"
|
||||
date: string; // ex: "2026-04-15" (ISO)
|
||||
supplier: string;
|
||||
}
|
||||
|
||||
interface OcrState {
|
||||
loading: boolean;
|
||||
progress: number; // 0-100
|
||||
result: OcrResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ─── Regex helpers ────────────────────────────────────────────
|
||||
|
||||
function extractAmount(text: string): string {
|
||||
// Cherche les montants : 1 234,56 / 1234.56 / 42,50 / €12.50
|
||||
const matches = text.match(/\b\d{1,6}[.,]\d{2}\b/g) ?? [];
|
||||
if (!matches.length) return '';
|
||||
|
||||
// Retourne le plus grand (probablement le total)
|
||||
const values = matches.map((m) => parseFloat(m.replace(',', '.')));
|
||||
const max = Math.max(...values);
|
||||
return max > 0 ? max.toFixed(2) : '';
|
||||
}
|
||||
|
||||
function extractDate(text: string): string {
|
||||
// Formats : DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, DD.MM.YYYY
|
||||
const patterns = [
|
||||
/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})\b/,
|
||||
/\b(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})\b/,
|
||||
];
|
||||
|
||||
for (const pat of patterns) {
|
||||
const m = text.match(pat);
|
||||
if (!m) continue;
|
||||
|
||||
let year: number, month: number, day: number;
|
||||
|
||||
if (parseInt(m[1]) > 31) {
|
||||
// Format YYYY-MM-DD
|
||||
[year, month, day] = [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
||||
} else {
|
||||
// Format DD/MM/YYYY
|
||||
[day, month, year] = [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
||||
}
|
||||
|
||||
if (year < 2000 || year > 2100) continue;
|
||||
if (month < 1 || month > 12) continue;
|
||||
if (day < 1 || day > 31) continue;
|
||||
|
||||
return `${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
return new Date().toISOString().split('T')[0]; // fallback: aujourd'hui
|
||||
}
|
||||
|
||||
function extractSupplier(text: string): string {
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 3 && !/^\d/.test(l) && !/^[€$£]/.test(l));
|
||||
|
||||
return lines[0]?.substring(0, 60) ?? '';
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────
|
||||
|
||||
export function useOCR() {
|
||||
const [state, setState] = useState<OcrState>({
|
||||
loading: false,
|
||||
progress: 0,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Ref pour garder le worker Tesseract en mémoire (évite de le recharger)
|
||||
const workerRef = useRef<any>(null);
|
||||
|
||||
const run = useCallback(async (imageDataUrl: string) => {
|
||||
setState({ loading: true, progress: 0, result: null, error: null });
|
||||
|
||||
try {
|
||||
// Chargement lazy de Tesseract.js
|
||||
const Tesseract = await import('tesseract.js');
|
||||
|
||||
if (!workerRef.current) {
|
||||
workerRef.current = await Tesseract.createWorker('fra+eng', 1, {
|
||||
logger: (m: any) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
setState((s) => ({ ...s, progress: Math.round(m.progress * 100) }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { data } = await workerRef.current.recognize(imageDataUrl);
|
||||
const rawText = data.text;
|
||||
|
||||
const result: OcrResult = {
|
||||
rawText,
|
||||
amount: extractAmount(rawText),
|
||||
date: extractDate(rawText),
|
||||
supplier: extractSupplier(rawText),
|
||||
};
|
||||
|
||||
setState({ loading: false, progress: 100, result, error: null });
|
||||
} catch (err: any) {
|
||||
setState({ loading: false, progress: 0, result: null, error: err.message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ loading: false, progress: 0, result: null, error: null });
|
||||
}, []);
|
||||
|
||||
return { ...state, run, reset };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Hook — File d'attente hors-ligne
|
||||
*
|
||||
* Expose :
|
||||
* count — nombre d'envois en attente (pour le badge nav)
|
||||
* processing — vrai pendant le traitement de la file
|
||||
* addToQueue — ajoute un invoice_id à la file + toast
|
||||
* processQueue — rejoue tous les envois en attente
|
||||
* refresh — resynchronise le count depuis IndexedDB
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
import {
|
||||
queueGetAll,
|
||||
queueAdd,
|
||||
queueRemove,
|
||||
queueBumpRetries,
|
||||
QUEUE_EVENT,
|
||||
} from '../utils/offlineQueue';
|
||||
|
||||
export function useOfflineQueue() {
|
||||
const [count, setCount] = useState(0);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// ── Synchronise le count depuis IndexedDB ─────────────────
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const items = await queueGetAll();
|
||||
setCount(items.length);
|
||||
} catch {
|
||||
// IndexedDB indisponible (ex: mode privé sur certains navigateurs)
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Charge au montage
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Écoute les changements émis par d'autres instances du hook
|
||||
useEffect(() => {
|
||||
window.addEventListener(QUEUE_EVENT, refresh);
|
||||
return () => window.removeEventListener(QUEUE_EVENT, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
// ── Ajoute à la file ──────────────────────────────────────
|
||||
|
||||
const addToQueue = useCallback(async (invoiceId: string) => {
|
||||
try {
|
||||
await queueAdd(invoiceId);
|
||||
toast('Facture créée — envoi mis en attente', {
|
||||
icon: '📶',
|
||||
duration: 5000,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Impossible de mettre en file d\'attente');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Traite la file ────────────────────────────────────────
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processing) return;
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = await queueGetAll();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await api.post(`/invoices/${item.invoiceId}/send`);
|
||||
await queueRemove(item.invoiceId);
|
||||
successCount++;
|
||||
} catch {
|
||||
await queueBumpRetries(item.invoiceId);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
await refresh();
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount} facture${successCount > 1 ? 's' : ''} envoyée${successCount > 1 ? 's' : ''} depuis la file d'attente`
|
||||
);
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast.error(`${failCount} envoi${failCount > 1 ? 's' : ''} toujours en échec — nouvelle tentative à la prochaine connexion`);
|
||||
}
|
||||
}, [processing, refresh]);
|
||||
|
||||
return { count, processing, addToQueue, processQueue, refresh };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook qui retourne le statut de connexion réseau en temps réel.
|
||||
* Utilisé pour l'indicateur visuel hors ligne et la gestion de la queue offline.
|
||||
*/
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
Reference in New Issue
Block a user