deploy: notesfrais — 2026-04-29 09:57:19

This commit is contained in:
deploy
2026-04-29 09:57:19 +02:00
commit abbc9b16e1
56 changed files with 6872 additions and 0 deletions
+129
View File
@@ -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 };
}
+105
View File
@@ -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 };
}
+24
View File
@@ -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;
}