feat: vue detail facture + restauration tracking git complet

This commit is contained in:
deploy
2026-04-30 23:19:22 +02:00
parent 018fb1d70f
commit 5de20a2fcf
58 changed files with 7958 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log
+25
View File
@@ -0,0 +1,25 @@
# ── Stage 1 : Build Vite ─────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install --include=dev
COPY . .
# En prod le frontend est servi par nginx qui proxifie /api → backend
# VITE_API_URL est laissé vide : le client utilise le chemin relatif /api
RUN npm run build
# ── Stage 2 : Serveur nginx ───────────────────────────────────
FROM nginx:1.27-alpine AS runtime
# SPA + proxy API
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Assets du build Vite
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#4f46e5" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NotesFrais" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NotesFrais</title>
</head>
<body class="bg-gray-50">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8 -1
View File
@@ -39,4 +39,11 @@ server {
client_max_body_size 20m;
# Timeout généreux pour la génération PDF
pr
proxy_read_timeout 120s;
}
# ── SPA fallback (React Router) ───────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "notesfrais-frontend",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.32.1",
"axios": "^1.6.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-image-crop": "^11.0.5",
"react-router-dom": "^6.23.1",
"tesseract.js": "^5.0.5",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+76
View File
@@ -0,0 +1,76 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { useAuthStore } from './store/auth';
import Login from './pages/Login';
import Layout from './components/Layout';
import NewInvoice from './pages/NewInvoice';
import MyInvoices from './pages/MyInvoices';
import InvoiceDetail from './pages/InvoiceDetail';
import Settings from './pages/Settings';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
},
},
});
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user } = useAuthStore();
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{/* Page de connexion — accessible sans auth */}
<Route path="/login" element={<Login />} />
{/* Pages protégées — layout avec nav du bas */}
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/new" replace />} />
<Route path="new" element={<NewInvoice />} />
<Route path="invoices" element={<MyInvoices />} />
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
{/* Toasts globaux */}
<Toaster
position="top-center"
gutter={8}
toastOptions={{
duration: 4000,
style: {
borderRadius: '12px',
padding: '12px 16px',
fontSize: '14px',
fontWeight: '500',
},
success: { iconTheme: { primary: '#4f46e5', secondary: '#fff' } },
}}
/>
</QueryClientProvider>
);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Client Axios centralisé.
* - Ajoute automatiquement le Bearer token à chaque requête
* - Rafraîchit le token silencieusement en cas de 401
* - Redirige vers /login si le refresh échoue
*/
import axios from 'axios';
import { useAuthStore } from '../store/auth';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api',
timeout: 30_000,
});
// ─── Request interceptor — injection du token ────────────────
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ─── Response interceptor — refresh automatique ──────────────
let isRefreshing = false;
type QueueItem = { resolve: (t: string) => void; reject: (e: unknown) => void };
let failedQueue: QueueItem[] = [];
function processQueue(error: unknown, token: string | null) {
for (const item of failedQueue) {
if (error) item.reject(error);
else item.resolve(token!);
}
failedQueue = [];
}
api.interceptors.response.use(
(res) => res,
async (error) => {
const orig = error.config as typeof error.config & { _retry?: boolean };
if (error.response?.status === 401 && !orig._retry) {
// Si déjà en train de rafraîchir, mettre en file d'attente
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
orig.headers.Authorization = `Bearer ${token}`;
return api(orig);
});
}
orig._retry = true;
isRefreshing = true;
const { refreshToken, setAccessToken, logout } = useAuthStore.getState();
if (!refreshToken) {
logout();
window.location.href = '/login';
return Promise.reject(error);
}
try {
// Appel direct axios (pas l'instance api) pour éviter la boucle
const resp = await axios.post('/api/auth/refresh', { refreshToken });
const newToken: string = resp.data.accessToken;
setAccessToken(newToken);
processQueue(null, newToken);
orig.headers.Authorization = `Bearer ${newToken}`;
return api(orig);
} catch (refreshErr) {
processQueue(refreshErr, null);
logout();
window.location.href = '/login';
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
+62
View File
@@ -0,0 +1,62 @@
/**
* Composant capture photo.
* - Sur mobile : ouvre la caméra native (capture="environment")
* - Sur desktop : ouvre la galerie de fichiers
* Retourne un DataURL de l'image capturée.
*/
import { useRef } from 'react';
interface Props {
onCapture: (dataUrl: string, mimeType: string) => void;
disabled?: boolean;
}
export default function Camera({ onCapture, disabled }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const result = ev.target?.result as string;
onCapture(result, file.type || 'image/jpeg');
};
reader.readAsDataURL(file);
// Reset input pour permettre de re-sélectionner le même fichier
e.target.value = '';
}
return (
<>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleFileChange}
disabled={disabled}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
className="flex flex-col items-center justify-center gap-3 w-full py-10
border-2 border-dashed border-indigo-200 rounded-2xl
bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200
text-indigo-600 transition-colors disabled:opacity-50"
>
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="font-semibold text-base">Photographier la facture</span>
<span className="text-xs text-indigo-400">ou sélectionner une image</span>
</button>
</>
);
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Gestion de la liste d'invités pour une facture.
* - Autocomplete : suggestions depuis le répertoire contacts au fur et à mesure de la frappe
* - Panneau de sélection multiple : bouton "Choisir dans le répertoire" → liste cochable
* - Saisie libre toujours possible (invité hors répertoire)
*/
import { useState, useImperativeHandle, forwardRef, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
import type { Guest, Contact } from '../types';
export interface GuestManagerHandle {
/**
* Si un invité est en cours de saisie (champ non validé), l'ajoute à la liste
* et retourne la liste complète (incluant ce nouvel invité).
* Retourne null si rien à flusher.
*/
flushPending: () => Guest[] | null;
}
interface Props {
guests: Guest[];
onChange: (guests: Guest[]) => void;
}
const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager({ guests, onChange }, ref) {
const [name, setName] = useState('');
const [company, setCompany] = useState('');
const [showAC, setShowAC] = useState(false); // autocomplete dropdown visible
const [showPanel, setShowPanel] = useState(false); // panneau de sélection multiple
const [panelSearch, setPanelSearch] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const acRef = useRef<HTMLDivElement>(null);
// ── Chargement du répertoire ─────────────────────────────────
const { data: allContacts = [] } = useQuery<Contact[]>({
queryKey: ['contacts'],
queryFn: () => api.get('/contacts').then(r => r.data),
staleTime: 5 * 60 * 1000,
});
// ── Autocomplete ─────────────────────────────────────────────
const acSuggestions: Contact[] = name.trim().length >= 1
? allContacts.filter(c =>
c.name.toLowerCase().includes(name.toLowerCase()) &&
!guests.some(g => g.name.toLowerCase() === c.name.toLowerCase())
).slice(0, 6)
: [];
// Ferme le dropdown si clic dehors
useEffect(() => {
function handleClick(e: MouseEvent) {
if (acRef.current && !acRef.current.contains(e.target as Node)) {
setShowAC(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// ── Panel : contacts déjà dans la liste (pour l'état coché) ──
const alreadyAdded = new Set(guests.map(g => g.name.toLowerCase()));
const panelFiltered = panelSearch.trim()
? allContacts.filter(c =>
c.name.toLowerCase().includes(panelSearch.toLowerCase()) ||
(c.company ?? '').toLowerCase().includes(panelSearch.toLowerCase())
)
: allContacts;
// ── Implémentation de flushPending (ref parent) ───────────────
useImperativeHandle(ref, () => ({
flushPending() {
const trimmed = name.trim();
if (!trimmed) return null;
const updated: Guest[] = [
...guests,
{ name: trimmed, company: company.trim() || null, sort_order: guests.length },
];
onChange(updated);
setName('');
setCompany('');
return updated;
},
}));
// ── Actions ──────────────────────────────────────────────────
function addGuest(overrideName?: string, overrideCompany?: string | null) {
const n = (overrideName ?? name).trim();
if (!n) return;
if (guests.some(g => g.name.toLowerCase() === n.toLowerCase())) {
setName(''); setCompany('');
return;
}
onChange([
...guests,
{ name: n, company: overrideCompany !== undefined ? overrideCompany : company.trim() || null, sort_order: guests.length },
]);
setName('');
setCompany('');
setShowAC(false);
}
function removeGuest(index: number) {
onChange(guests.filter((_, i) => i !== index));
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); addGuest(); }
if (e.key === 'Escape') setShowAC(false);
}
function selectFromAC(c: Contact) {
addGuest(c.name, c.company ?? null);
nameInputRef.current?.focus();
}
function toggleFromPanel(c: Contact) {
const lower = c.name.toLowerCase();
if (alreadyAdded.has(lower)) {
// retirer
onChange(guests.filter(g => g.name.toLowerCase() !== lower));
} else {
// ajouter
onChange([
...guests,
{ name: c.name, company: c.company ?? null, sort_order: guests.length },
]);
}
}
return (
<div className="space-y-3">
{/* Invités déjà ajoutés */}
{guests.length > 0 && (
<ul className="space-y-2">
{guests.map((g, i) => (
<li key={i} className="flex items-center gap-3 bg-indigo-50 rounded-xl px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
{g.company && <p className="text-xs text-gray-500 truncate">{g.company}</p>}
</div>
<button type="button" onClick={() => removeGuest(i)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</li>
))}
</ul>
)}
{/* Formulaire + autocomplete */}
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
{/* Champ nom avec autocomplete */}
<div className="relative" ref={acRef}>
<input
ref={nameInputRef}
type="text"
value={name}
onChange={e => { setName(e.target.value); setShowAC(true); }}
onFocus={() => setShowAC(true)}
onKeyDown={handleKeyDown}
placeholder="Nom de l'invité *"
className="form-input text-sm py-2"
autoComplete="off"
/>
{/* Dropdown autocomplete */}
{showAC && acSuggestions.length > 0 && (
<div className="absolute z-40 left-0 right-0 top-full mt-1 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
{acSuggestions.map(c => (
<button
key={c.id}
type="button"
onMouseDown={e => { e.preventDefault(); selectFromAC(c); }}
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 transition-colors flex items-center gap-3">
<div className="w-7 h-7 rounded-full bg-indigo-100 flex items-center justify-center text-xs font-bold text-indigo-600 shrink-0">
{c.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
</div>
</button>
))}
</div>
)}
</div>
<input
type="text"
value={company}
onChange={e => setCompany(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Entreprise (optionnel)"
className="form-input text-sm py-2"
/>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => addGuest()}
disabled={!name.trim()}
className="flex items-center gap-2 text-sm font-semibold text-indigo-600
disabled:text-gray-300 hover:text-indigo-700 transition-colors py-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Ajouter l'invité
</button>
{allContacts.length > 0 && (
<button
type="button"
onClick={() => { setShowPanel(true); setPanelSearch(''); }}
className="ml-auto flex items-center gap-1.5 text-xs font-medium text-violet-600 hover:text-violet-800 transition-colors py-1 px-2 bg-violet-50 rounded-lg">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Répertoire ({allContacts.length})
</button>
)}
</div>
</div>
{/* Panneau de sélection multiple (overlay) */}
{showPanel && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40"
onClick={e => { if (e.target === e.currentTarget) setShowPanel(false); }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm flex flex-col max-h-[80vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<p className="font-semibold text-gray-900">Choisir des invités</p>
<button type="button" onClick={() => setShowPanel(false)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/* Recherche */}
<div className="px-4 py-2">
<input
type="text"
autoFocus
value={panelSearch}
onChange={e => setPanelSearch(e.target.value)}
placeholder="Rechercher…"
className="form-input text-sm py-2"
/>
</div>
{/* Liste */}
<div className="flex-1 overflow-y-auto px-2 pb-2">
{panelFiltered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">Aucun contact trouvé</p>
) : (
panelFiltered.map(c => {
const checked = alreadyAdded.has(c.name.toLowerCase());
return (
<button
key={c.id}
type="button"
onClick={() => toggleFromPanel(c)}
className={`w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors mb-0.5
${checked ? 'bg-indigo-50' : 'hover:bg-gray-50'}`}>
{/* Checkbox visuelle */}
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors
${checked ? 'bg-indigo-600 border-indigo-600' : 'border-gray-300'}`}>
{checked && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
</div>
</button>
);
})
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100">
<button
type="button"
onClick={() => setShowPanel(false)}
className="btn-primary w-full py-2.5">
Valider ({guests.length} invité{guests.length !== 1 ? 's' : ''})
</button>
</div>
</div>
</div>
)}
</div>
);
});
export default GuestManager;
+103
View File
@@ -0,0 +1,103 @@
/**
* Recadrage d'image avec react-image-crop.
* Permet à l'utilisateur de sélectionner la zone utile de la facture.
*/
import { useState, useRef, useCallback } from 'react';
import ReactCrop, { type Crop, type PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
interface Props {
src: string;
onConfirm: (croppedDataUrl: string) => void;
onCancel: () => void;
}
function cropImageToDataUrl(
image: HTMLImageElement,
crop: PixelCrop,
mimeType = 'image/jpeg'
): string {
const canvas = document.createElement('canvas');
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(
image,
crop.x * scaleX, crop.y * scaleY,
crop.width * scaleX, crop.height * scaleY,
0, 0,
canvas.width, canvas.height
);
return canvas.toDataURL(mimeType, 0.92);
}
export default function ImageCropper({ src, onConfirm, onCancel }: Props) {
const imgRef = useRef<HTMLImageElement>(null);
const [crop, setCrop] = useState<Crop>();
const [completed, setCompleted] = useState<PixelCrop>();
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth: w, naturalHeight: h } = e.currentTarget;
// Crop initial centré — pas de ratio imposé (factures de toutes formes)
const initial = centerCrop(
makeAspectCrop({ unit: '%', width: 90 }, w / h, w, h),
w, h
);
setCrop(initial);
}, []);
function handleConfirm() {
if (!completed || !imgRef.current) return;
const dataUrl = cropImageToDataUrl(imgRef.current, completed);
onConfirm(dataUrl);
}
return (
<div className="fixed inset-0 z-50 bg-black/80 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-black/60">
<button onClick={onCancel} className="text-white/70 hover:text-white p-2">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<span className="text-white font-semibold text-sm">Recadrer la facture</span>
<button
onClick={handleConfirm}
disabled={!completed}
className="bg-indigo-600 disabled:opacity-40 text-white text-sm font-semibold px-4 py-2 rounded-xl"
>
Valider
</button>
</div>
{/* Cropper */}
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
<ReactCrop
crop={crop}
onChange={(c) => setCrop(c)}
onComplete={(c) => setCompleted(c)}
minWidth={50}
minHeight={50}
>
<img
ref={imgRef}
src={src}
alt="Facture"
className="max-h-[70vh] max-w-full object-contain"
onLoad={onImageLoad}
/>
</ReactCrop>
</div>
<p className="text-center text-white/50 text-xs pb-4">
Faites glisser les poignées pour ajuster le recadrage
</p>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useEffect, useRef } from 'react';
import { useAuthStore } from '../store/auth';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { useOfflineQueue } from '../hooks/useOfflineQueue';
import api from '../api/client';
import toast from 'react-hot-toast';
// ─── Icônes ─────────────────────────────────────────────────
function IconPlus({ active }: { active: boolean }) {
return (
<svg className={`w-6 h-6 ${active ? 'text-indigo-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
);
}
function IconList({ active }: { active: boolean }) {
return (
<svg className={`w-6 h-6 ${active ? 'text-indigo-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
);
}
function IconSettings({ active }: { active: boolean }) {
return (
<svg className={`w-6 h-6 ${active ? 'text-indigo-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
// ─── Layout principal ────────────────────────────────────────
export default function Layout() {
const { user, refreshToken, logout } = useAuthStore();
const navigate = useNavigate();
const isOnline = useOnlineStatus();
const { count: queueCount, processing, processQueue } = useOfflineQueue();
// Auto-traitement de la file dès la reconnexion
const wasOnline = useRef(isOnline);
useEffect(() => {
if (!wasOnline.current && isOnline) {
// Petite temporisation pour laisser le réseau se stabiliser
const timer = setTimeout(() => processQueue(), 2000);
return () => clearTimeout(timer);
}
wasOnline.current = isOnline;
}, [isOnline, processQueue]);
async function handleLogout() {
try {
await api.post('/auth/logout', { refreshToken });
} catch {
// Ignore silencieusement
}
logout();
navigate('/login', { replace: true });
}
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`flex flex-col items-center gap-0.5 py-2 px-4 min-w-[64px] transition-colors ${
isActive ? 'text-indigo-600' : 'text-gray-400'
}`;
return (
<div className="flex flex-col min-h-screen bg-gray-50">
{/* ── Header ── */}
<header className="sticky top-0 z-40 bg-white border-b border-gray-100 shadow-sm">
<div className="max-w-2xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-gray-900 text-lg">NotesFrais</span>
<div className="flex items-center gap-3">
{/* Traitement file en cours */}
{processing && (
<span className="inline-flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full font-medium">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Envoi
</span>
)}
{/* Indicateur hors ligne */}
{!isOnline && (
<span className="inline-flex items-center gap-1 text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 inline-block" />
Hors ligne
{queueCount > 0 && (
<span className="ml-0.5 bg-amber-500 text-white rounded-full px-1 text-[10px] font-bold">
{queueCount}
</span>
)}
</span>
)}
{/* Nom utilisateur + déconnexion */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 font-medium">{user?.name}</span>
<button
onClick={handleLogout}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
title="Se déconnecter"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</div>
</header>
{/* ── Contenu principal ── */}
<main className="flex-1 max-w-2xl w-full mx-auto px-4 py-6 pb-24">
<Outlet />
</main>
{/* ── Barre de navigation du bas ── */}
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-white border-t border-gray-100 shadow-[0_-1px_3px_rgba(0,0,0,0.06)]"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="max-w-2xl mx-auto flex justify-around items-center">
<NavLink to="/new" className={navLinkClass}>
{({ isActive }) => (
<>
<IconPlus active={isActive} />
<span className={`text-xs font-medium ${isActive ? 'text-indigo-600' : 'text-gray-400'}`}>
Nouvelle
</span>
</>
)}
</NavLink>
<NavLink to="/invoices" className={navLinkClass}>
{({ isActive }) => (
<>
<div className="relative">
<IconList active={isActive} />
{queueCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-0.5 rounded-full bg-red-500
text-white text-[10px] font-bold flex items-center justify-center">
{queueCount > 9 ? '9+' : queueCount}
</span>
)}
</div>
<span className={`text-xs font-medium ${isActive ? 'text-indigo-600' : 'text-gray-400'}`}>
Mes factures
</span>
</>
)}
</NavLink>
<NavLink to="/settings" className={navLinkClass}>
{({ isActive }) => (
<>
<IconSettings active={isActive} />
<span className={`text-xs font-medium ${isActive ? 'text-indigo-600' : 'text-gray-400'}`}>
Paramètres
</span>
</>
)}
</NavLink>
</div>
</nav>
</div>
);
}
+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;
}
+66
View File
@@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Import Inter depuis Google Fonts (fallback: system-ui) */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@layer base {
html {
-webkit-tap-highlight-color: transparent;
-webkit-font-smoothing: antialiased;
}
body {
@apply font-sans text-gray-900;
}
/* Prevent scroll bounce on iOS but allow scrolling in containers */
#root {
@apply min-h-screen;
}
}
@layer components {
/* Bouton primaire réutilisable */
.btn-primary {
@apply flex items-center justify-center gap-2 w-full py-3 px-4
bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800
disabled:opacity-50 disabled:cursor-not-allowed
text-white font-semibold rounded-xl transition-colors
text-base select-none;
}
/* Bouton secondaire */
.btn-secondary {
@apply flex items-center justify-center gap-2 w-full py-3 px-4
bg-gray-100 hover:bg-gray-200 active:bg-gray-300
text-gray-700 font-semibold rounded-xl transition-colors
text-base select-none;
}
/* Champ de formulaire */
.form-input {
@apply w-full px-4 py-3 rounded-xl border border-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent
text-gray-900 placeholder-gray-400 bg-white transition text-base;
}
/* Label de formulaire */
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
/* Card */
.card {
@apply bg-white rounded-2xl border border-gray-100 shadow-sm;
}
}
/* Loader spinner */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 0.75s linear infinite;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+352
View File
@@ -0,0 +1,352 @@
/**
* Page détail d'une facture.
* Accessible via /invoices/:id
* Affiche : images du ticket, invités, métadonnées, statut, PDF, email.
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import api from '../api/client';
import type { Invoice, InvoiceStatus } from '../types';
// ─── Helpers ──────────────────────────────────────────────────
function fmt(iso: string | undefined): string {
if (!iso) return '—';
const d = iso.split('T')[0];
const [y, m, day] = d.split('-');
return `${day}/${m}/${y}`;
}
function fmtAmount(n: number): string {
return new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n) + ' €';
}
// ─── Composant image authentifiée ─────────────────────────────
function AuthImage({ filename, alt }: { filename: string; alt?: string }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
api
.get(`/uploads/images/${encodeURIComponent(filename)}`, { responseType: 'blob' })
.then((res) => {
objectUrl = URL.createObjectURL(res.data);
setSrc(objectUrl);
})
.catch(() => setSrc('error'));
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [filename]);
if (!src) {
return (
<div className="w-full aspect-[3/4] bg-gray-100 rounded-xl flex items-center justify-center animate-pulse">
<svg className="w-8 h-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
);
}
if (src === 'error') {
return (
<div className="w-full aspect-[3/4] bg-gray-100 rounded-xl flex flex-col items-center justify-center gap-2 text-gray-400">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-xs">Image indisponible</span>
</div>
);
}
return (
<img
src={src}
alt={alt ?? 'Ticket'}
className="w-full rounded-xl object-contain bg-gray-50 border border-gray-100"
/>
);
}
// ─── Main ──────────────────────────────────────────────────────
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const [sending, setSending] = useState(false);
const [togglingId, setTogglingId] = useState(false);
// ── Fetch de la facture ──────────────────────────────────────
const { data: invoice, isLoading, isError } = useQuery<Invoice>({
queryKey: ['invoice', id],
queryFn: () => api.get(`/invoices/${id}`).then((r) => r.data),
enabled: !!id,
});
// ── Mutation statut ──────────────────────────────────────────
const statusMut = useMutation({
mutationFn: (status: InvoiceStatus) =>
api.patch(`/invoices/${id}/status`, { status }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoice', id] });
qc.invalidateQueries({ queryKey: ['invoices'] });
qc.invalidateQueries({ queryKey: ['invoices-summary'] });
setTogglingId(false);
toast.success('Statut mis à jour');
},
onError: () => {
setTogglingId(false);
toast.error('Erreur lors du changement de statut');
},
});
// ── Télécharger PDF ──────────────────────────────────────────
async function handleDownloadPDF() {
if (!invoice) return;
try {
const res = await api.get(`/invoices/${id}/pdf`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
const a = Object.assign(document.createElement('a'), {
href: url,
download: invoice.pdf_filename ?? `facture-${id}.pdf`,
});
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
} catch {
toast.error('Erreur lors du téléchargement');
}
}
// ── Renvoyer email ───────────────────────────────────────────
async function handleResendEmail() {
if (!invoice) return;
setSending(true);
const toastId = toast.loading('Envoi en cours…');
try {
await api.post(`/invoices/${id}/send`);
qc.invalidateQueries({ queryKey: ['invoice', id] });
qc.invalidateQueries({ queryKey: ['invoices'] });
toast.success('Email envoyé !', { id: toastId });
} catch (err: any) {
toast.error(err?.response?.data?.error ?? 'Erreur lors de l\'envoi', { id: toastId });
} finally {
setSending(false);
}
}
// ── Toggle statut ────────────────────────────────────────────
function handleToggleStatus() {
if (!invoice) return;
const next: InvoiceStatus = invoice.status === 'pending' ? 'reimbursed' : 'pending';
setTogglingId(true);
statusMut.mutate(next);
}
// ── Render ───────────────────────────────────────────────────
if (isLoading) {
return (
<div className="py-20 flex flex-col items-center gap-3 text-gray-400">
<svg className="w-6 h-6 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Chargement</span>
</div>
);
}
if (isError || !invoice) {
return (
<div className="py-20 flex flex-col items-center gap-4 text-gray-400">
<svg className="w-12 h-12 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm">Facture introuvable</p>
<button onClick={() => navigate('/invoices')} className="btn-secondary text-sm">
Retour à la liste
</button>
</div>
);
}
const isReimbursed = invoice.status === 'reimbursed';
return (
<div className="space-y-4 pb-8">
{/* ── En-tête ── */}
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/invoices')}
className="p-2 rounded-xl text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex-1 min-w-0">
<h1 className="text-base font-semibold text-gray-900 truncate">
{invoice.supplier || invoice.company_name}
</h1>
<p className="text-sm text-gray-500">{fmtAmount(invoice.amount)} · {fmt(invoice.invoice_date)}</p>
</div>
</div>
{/* ── Image(s) du ticket ── */}
{invoice.images && invoice.images.length > 0 && (
<div className="space-y-3">
{[...invoice.images]
.sort((a, b) => a.order - b.order)
.map((img, i) => (
<AuthImage key={i} filename={img.path} alt={`Ticket ${i + 1}`} />
))}
</div>
)}
{/* ── Métadonnées ── */}
<div className="card space-y-3">
{/* Société + Catégorie */}
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Société</p>
<p className="text-sm font-semibold text-gray-800">{invoice.company_name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Catégorie</p>
<p className="text-sm font-semibold text-gray-800">{invoice.category_name}</p>
</div>
</div>
{/* Fournisseur + Date */}
{(invoice.supplier || invoice.invoice_date) && (
<div className="grid grid-cols-2 gap-3 border-t border-gray-50 pt-3">
{invoice.supplier && (
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Fournisseur</p>
<p className="text-sm text-gray-700">{invoice.supplier}</p>
</div>
)}
{invoice.invoice_date && (
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Date</p>
<p className="text-sm text-gray-700">{fmt(invoice.invoice_date)}</p>
</div>
)}
</div>
)}
{/* Montant */}
<div className="border-t border-gray-50 pt-3 flex items-center justify-between">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">Montant</p>
<p className="text-lg font-bold text-gray-900">{fmtAmount(invoice.amount)}</p>
</div>
{/* Commentaire */}
{invoice.comment && (
<div className="border-t border-gray-50 pt-3">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">Commentaire</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.comment}</p>
</div>
)}
</div>
{/* ── Statut ── */}
<div className="card flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-0.5">Statut</p>
<p className="text-sm text-gray-700">
{isReimbursed ? 'Remboursé' : 'En attente de remboursement'}
</p>
</div>
<button
onClick={handleToggleStatus}
disabled={togglingId}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold
transition-all disabled:opacity-50
${isReimbursed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-amber-100 text-amber-700 hover:bg-amber-200'}`}
>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isReimbursed ? 'bg-green-500' : 'bg-amber-500'}`} />
{isReimbursed ? 'Remboursé' : 'En attente'}
</button>
</div>
{/* ── Invités ── */}
{invoice.guests && invoice.guests.length > 0 && (
<div className="card">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-3">
Invités ({invoice.guests.length})
</p>
<ul className="space-y-2">
{invoice.guests.map((g, i) => (
<li key={i} className="flex items-center gap-3 bg-indigo-50 rounded-xl px-3 py-2.5">
<div className="w-8 h-8 rounded-full bg-indigo-200 flex items-center justify-center
text-xs font-bold text-indigo-700 flex-shrink-0">
{g.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
{g.company && <p className="text-xs text-gray-500 truncate">{g.company}</p>}
</div>
</li>
))}
</ul>
</div>
)}
{/* ── Actions ── */}
<div className="space-y-2">
{/* Télécharger PDF */}
{invoice.pdf_path && (
<button
onClick={handleDownloadPDF}
className="w-full flex items-center justify-center gap-2 bg-indigo-600 text-white
font-semibold py-3 px-4 rounded-xl hover:bg-indigo-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10 13l2 2 2-2m-2 2V9" />
</svg>
Télécharger le PDF
</button>
)}
{/* Renvoyer l'email */}
<button
onClick={handleResendEmail}
disabled={sending}
className="w-full flex items-center justify-center gap-2 bg-white border border-gray-200
text-gray-700 font-semibold py-3 px-4 rounded-xl
hover:border-indigo-300 hover:text-indigo-600 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{sending ? 'Envoi en cours…' : invoice.email_sent ? 'Renvoyer l\'email' : 'Envoyer l\'email'}
</button>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import api from '../api/client';
import { useAuthStore } from '../store/auth';
export default function Login() {
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const res = await api.post('/auth/login', { email, password });
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken);
navigate('/new', { replace: true });
} catch (err: any) {
toast.error(err.response?.data?.error ?? 'Connexion impossible');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-white flex flex-col items-center justify-center px-4">
{/* Logo / titre */}
<div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-600 shadow-lg mb-4">
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900">NotesFrais</h1>
<p className="text-gray-500 mt-1 text-sm">Gestion de notes de frais</p>
</div>
{/* Formulaire */}
<form
onSubmit={handleSubmit}
className="w-full max-w-sm bg-white rounded-2xl shadow-sm border border-gray-100 p-6 space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="votre@email.com"
required
autoComplete="email"
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2 text-base"
>
{loading ? (
<>
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Connexion
</>
) : (
'Se connecter'
)}
</button>
</form>
</div>
);
}
+815
View File
@@ -0,0 +1,815 @@
/**
* Page "Mes factures" — Étape 9
* Listing filtrable et triable, récapitulatif, export CSV.
*/
import { useState, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import api from '../api/client';
import type {
Invoice,
InvoiceFilters,
InvoiceSummaryItem,
InvoiceStatus,
Company,
Category,
} from '../types';
// ─── API helpers ──────────────────────────────────────────────
function buildParams(filters: InvoiceFilters): URLSearchParams {
const p = new URLSearchParams();
filters.company_ids?.forEach((id) => p.append('company_ids[]', String(id)));
filters.category_ids?.forEach((id) => p.append('category_ids[]', String(id)));
if (filters.status) p.set('status', filters.status);
if (filters.date_from) p.set('date_from', filters.date_from);
if (filters.date_to) p.set('date_to', filters.date_to);
if (filters.search) p.set('search', filters.search);
if (filters.sort_by) p.set('sort_by', filters.sort_by);
if (filters.sort_dir) p.set('sort_dir', filters.sort_dir);
if (filters.page != null) p.set('page', String(filters.page));
if (filters.limit != null) p.set('limit', String(filters.limit));
return p;
}
async function fetchInvoices(filters: InvoiceFilters) {
const res = await api.get(`/invoices?${buildParams(filters)}`);
return res.data as { data: Invoice[]; total: number; page: number; limit: number };
}
async function fetchSummary() {
const res = await api.get('/invoices/summary');
return res.data as InvoiceSummaryItem[];
}
async function fetchCompanies() {
const res = await api.get('/companies');
return res.data as Company[];
}
async function fetchCategories() {
const res = await api.get('/categories');
return res.data as Category[];
}
// ─── Format helpers ───────────────────────────────────────────
function fmt(iso: string | undefined): string {
if (!iso) return '—';
const d = iso.split('T')[0];
const [y, m, day] = d.split('-');
return `${day}/${m}/${y}`;
}
function fmtAmount(n: number): string {
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n) + ' €';
}
// ─── SortHeader ───────────────────────────────────────────────
interface SortHeaderProps {
label: string;
field: string;
current: string;
dir: 'asc' | 'desc';
onSort: (field: string) => void;
right?: boolean;
}
function SortHeader({ label, field, current, dir, onSort, right }: SortHeaderProps) {
const active = current === field;
return (
<th
onClick={() => onSort(field)}
className={`px-3 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide cursor-pointer select-none
whitespace-nowrap hover:text-indigo-600 transition-colors ${right ? 'text-right' : 'text-left'}`}
>
<span className={`inline-flex items-center gap-1 ${right ? 'flex-row-reverse' : ''}`}>
{label}
{active ? (
<svg className="w-3 h-3 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
{dir === 'asc'
? <path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
: <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />}
</svg>
) : (
<svg className="w-3 h-3 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
)}
</span>
</th>
);
}
// ─── StatusBadge ─────────────────────────────────────────────
interface StatusBadgeProps {
status: InvoiceStatus;
invoiceId: string;
onToggle: (id: string, next: InvoiceStatus) => void;
toggling: boolean;
}
function StatusBadge({ status, invoiceId, onToggle, toggling }: StatusBadgeProps) {
const next: InvoiceStatus = status === 'pending' ? 'reimbursed' : 'pending';
const label = status === 'reimbursed' ? 'Remboursé' : 'En attente';
const colors = status === 'reimbursed'
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-amber-100 text-amber-700 hover:bg-amber-200';
const dotColor = status === 'reimbursed' ? 'bg-green-500' : 'bg-amber-500';
return (
<button
onClick={() => onToggle(invoiceId, next)}
disabled={toggling}
title={`Marquer comme "${next === 'reimbursed' ? 'Remboursé' : 'En attente'}"`}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold
transition-all ${colors} ${toggling ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`} />
{label}
</button>
);
}
// ─── DeleteModal ──────────────────────────────────────────────
interface DeleteModalProps {
invoice: Invoice | null;
onConfirm: () => void;
onCancel: () => void;
loading: boolean;
}
function DeleteModal({ invoice, onConfirm, onCancel, loading }: DeleteModalProps) {
if (!invoice) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
<div className="relative bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl p-6 w-full sm:max-w-sm mx-4 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-gray-900">Supprimer la facture</h3>
<p className="text-sm text-gray-500 truncate">
{invoice.supplier || invoice.company_name} {fmtAmount(invoice.amount)}
</p>
</div>
</div>
<p className="text-sm text-gray-600">
Cette action est irréversible. Le PDF et les images associés seront également supprimés.
</p>
<div className="flex gap-3">
<button onClick={onCancel} disabled={loading} className="flex-1 btn-secondary">
Annuler
</button>
<button
onClick={onConfirm}
disabled={loading}
className="flex-1 bg-red-600 text-white font-semibold py-2.5 px-4 rounded-xl
hover:bg-red-700 transition-colors disabled:opacity-50"
>
{loading ? 'Suppression…' : 'Supprimer'}
</button>
</div>
</div>
</div>
);
}
// ─── MultiSelect ──────────────────────────────────────────────
interface MultiSelectOption { id: number; name: string }
interface MultiSelectProps {
label: string;
options: MultiSelectOption[];
selected: number[];
onChange: (ids: number[]) => void;
}
function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) {
const [open, setOpen] = useState(false);
function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
}
const display =
selected.length === 0
? label
: selected.length === 1
? (options.find((o) => o.id === selected[0])?.name ?? label)
: `${selected.length} sélectionnés`;
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className={`form-input text-sm py-2 flex items-center justify-between gap-2 cursor-pointer
${selected.length > 0 ? 'text-indigo-700 bg-indigo-50 border-indigo-300' : 'text-gray-500'}`}
>
<span className="truncate">{display}</span>
<svg
className={`w-4 h-4 flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute z-20 mt-1 w-full bg-white rounded-xl shadow-lg border border-gray-100 py-1 max-h-48 overflow-y-auto">
{options.length === 0 && (
<p className="px-3 py-2 text-sm text-gray-400">Aucune option</p>
)}
{options.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => toggle(opt.id)}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-left hover:bg-indigo-50 transition-colors"
>
<span
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors
${selected.includes(opt.id) ? 'bg-indigo-600 border-indigo-600' : 'border-gray-300'}`}
>
{selected.includes(opt.id) && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</span>
<span className="text-gray-700">{opt.name}</span>
</button>
))}
</div>
</>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────
const PAGE_SIZE = 20;
export default function MyInvoices() {
const qc = useQueryClient();
const navigate = useNavigate();
// ── Filter state ──
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [companyIds, setCompanyIds] = useState<number[]>([]);
const [categoryIds, setCategoryIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | ''>('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [sortBy, setSortBy] = useState('sent_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [filtersOpen, setFiltersOpen] = useState(false);
// ── Modal state ──
const [deleteTarget, setDeleteTarget] = useState<Invoice | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
// ── Debounce search ──
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
function handleSearchChange(val: string) {
setSearch(val);
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
setDebouncedSearch(val);
setPage(1);
}, 400);
}
// ── Filters object ──
const filters: InvoiceFilters = {
company_ids: companyIds.length ? companyIds : undefined,
category_ids: categoryIds.length ? categoryIds : undefined,
status: statusFilter || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
search: debouncedSearch || undefined,
sort_by: sortBy,
sort_dir: sortDir,
page,
limit: PAGE_SIZE,
};
const hasActiveFilters =
companyIds.length > 0 || categoryIds.length > 0 || !!statusFilter ||
!!dateFrom || !!dateTo || !!debouncedSearch;
// ── Queries ──
const { data: listData, isLoading } = useQuery({
queryKey: ['invoices', filters],
queryFn: () => fetchInvoices(filters),
placeholderData: (prev) => prev,
});
const { data: summary = [] } = useQuery({
queryKey: ['invoices-summary'],
queryFn: fetchSummary,
});
const { data: companies = [] } = useQuery({
queryKey: ['companies'],
queryFn: fetchCompanies,
staleTime: 5 * 60_000,
});
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: 5 * 60_000,
});
// ── Mutations ──
const deleteMut = useMutation({
mutationFn: (id: string) => api.delete(`/invoices/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] });
qc.invalidateQueries({ queryKey: ['invoices-summary'] });
setDeleteTarget(null);
toast.success('Facture supprimée');
},
onError: () => toast.error('Erreur lors de la suppression'),
});
const statusMut = useMutation({
mutationFn: ({ id, status }: { id: string; status: InvoiceStatus }) =>
api.patch(`/invoices/${id}/status`, { status }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] });
qc.invalidateQueries({ queryKey: ['invoices-summary'] });
setTogglingId(null);
toast.success('Statut mis à jour');
},
onError: () => {
setTogglingId(null);
toast.error('Erreur lors du changement de statut');
},
});
// ── Handlers ──
function handleSort(field: string) {
if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortBy(field); setSortDir('desc'); }
setPage(1);
}
function handleToggleStatus(id: string, next: InvoiceStatus) {
setTogglingId(id);
statusMut.mutate({ id, status: next });
}
async function handleDownloadPDF(inv: Invoice) {
try {
const res = await api.get(`/invoices/${inv.id}/pdf`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
const a = Object.assign(document.createElement('a'), {
href: url,
download: inv.pdf_filename ?? `facture-${inv.id}.pdf`,
});
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
} catch {
toast.error('Erreur lors du téléchargement');
}
}
async function handleExportCSV() {
try {
const params = buildParams({ ...filters, page: undefined, limit: undefined });
const res = await api.get(`/invoices/export/csv?${params}`, { responseType: 'blob' });
const url = URL.createObjectURL(new Blob([res.data], { type: 'text/csv;charset=utf-8;' }));
const a = Object.assign(document.createElement('a'), {
href: url,
download: `factures-${new Date().toISOString().split('T')[0]}.csv`,
});
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
} catch {
toast.error("Erreur lors de l'export CSV");
}
}
function resetFilters() {
setCompanyIds([]); setCategoryIds([]); setStatusFilter('');
setDateFrom(''); setDateTo('');
setSearch(''); setDebouncedSearch('');
setPage(1);
}
// ── Summary pivot ──
const summaryRows = useMemo(() => {
const map = new Map<string, { pending: number; reimbursed: number }>();
for (const item of summary) {
if (!map.has(item.company_name)) map.set(item.company_name, { pending: 0, reimbursed: 0 });
const row = map.get(item.company_name)!;
const v = parseFloat(item.total) || 0;
if (item.status === 'pending') row.pending += v;
else row.reimbursed += v;
}
return Array.from(map.entries()).map(([name, v]) => ({ name, ...v, total: v.pending + v.reimbursed }));
}, [summary]);
const grandPending = summaryRows.reduce((s, r) => s + r.pending, 0);
const grandReimbursed = summaryRows.reduce((s, r) => s + r.reimbursed, 0);
const grandTotal = grandPending + grandReimbursed;
const invoices = listData?.data ?? [];
const total = listData?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
// Compact page buttons (up to 5 around current)
const pageButtons = useMemo(() => {
if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1);
if (page <= 3) return [1, 2, 3, 4, 5];
if (page >= totalPages - 2) return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [page - 2, page - 1, page, page + 1, page + 2];
}, [page, totalPages]);
// ── Render ────────────────────────────────────────────────────
return (
<div className="space-y-4 pb-8">
{/* ── Récapitulatif ── */}
{summaryRows.length > 0 && (
<div className="card p-0 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
<svg className="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<h3 className="text-sm font-semibold text-gray-700">Récapitulatif</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Société</th>
<th className="text-right px-4 py-2 text-xs font-semibold text-amber-600 uppercase tracking-wide">En attente</th>
<th className="text-right px-4 py-2 text-xs font-semibold text-green-600 uppercase tracking-wide">Remboursé</th>
<th className="text-right px-4 py-2 text-xs font-semibold text-gray-600 uppercase tracking-wide">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{summaryRows.map((row) => (
<tr key={row.name} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5 font-medium text-gray-800">{row.name}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-amber-700">
{row.pending > 0 ? fmtAmount(row.pending) : '—'}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-green-700">
{row.reimbursed > 0 ? fmtAmount(row.reimbursed) : '—'}
</td>
<td className="px-4 py-2.5 text-right tabular-nums font-semibold text-gray-900">
{fmtAmount(row.total)}
</td>
</tr>
))}
{/* Grand total row */}
<tr className="bg-gray-50 border-t border-gray-200">
<td className="px-4 py-2.5 text-xs font-bold text-gray-500 uppercase tracking-wide">Total</td>
<td className="px-4 py-2.5 text-right tabular-nums text-sm font-bold text-amber-700">{fmtAmount(grandPending)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-sm font-bold text-green-700">{fmtAmount(grandReimbursed)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-sm font-bold text-gray-900">{fmtAmount(grandTotal)}</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* ── Barre de filtres ── */}
<div className="card space-y-3">
{/* Ligne principale : recherche + bouton filtres + export CSV */}
<div className="flex gap-2">
<div className="flex-1 relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Rechercher…"
className="form-input pl-9 text-sm py-2"
/>
</div>
{/* Toggle filtres avancés */}
<button
onClick={() => setFiltersOpen((o) => !o)}
title="Filtres avancés"
className={`p-2.5 rounded-xl border transition-colors
${hasActiveFilters
? 'bg-indigo-600 border-indigo-600 text-white'
: 'border-gray-200 text-gray-500 hover:border-indigo-300 hover:text-indigo-600'}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707v4.586a1 1 0 01-.553.894l-4 2A1 1 0 019 21v-7.586a1 1 0 00-.293-.707L2.293 6.707A1 1 0 012 6V4z" />
</svg>
</button>
{/* Export CSV */}
<button
onClick={handleExportCSV}
title="Exporter en CSV"
className="p-2.5 rounded-xl border border-gray-200 text-gray-500
hover:border-indigo-300 hover:text-indigo-600 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
{/* Filtres avancés dépliables */}
{filtersOpen && (
<div className="space-y-2 pt-2 border-t border-gray-100">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<MultiSelect
label="Sociétés"
options={companies.filter((c) => c.is_active).map((c) => ({ id: c.id, name: c.name }))}
selected={companyIds}
onChange={(ids) => { setCompanyIds(ids); setPage(1); }}
/>
<MultiSelect
label="Catégories"
options={categories.filter((c) => c.is_active).map((c) => ({ id: c.id, name: c.name }))}
selected={categoryIds}
onChange={(ids) => { setCategoryIds(ids); setPage(1); }}
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value as InvoiceStatus | ''); setPage(1); }}
className="form-input text-sm py-2"
>
<option value="">Tous les statuts</option>
<option value="pending">En attente</option>
<option value="reimbursed">Remboursé</option>
</select>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
className="form-input text-sm py-2"
/>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
className="form-input text-sm py-2"
/>
</div>
{hasActiveFilters && (
<button
onClick={resetFilters}
className="text-xs font-medium text-indigo-600 hover:text-indigo-800 transition-colors"
>
Réinitialiser les filtres
</button>
)}
</div>
)}
</div>
{/* ── Tableau ── */}
<div className="card p-0 overflow-hidden">
{/* En-tête du tableau : compteur + pagination info */}
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<p className="text-sm text-gray-500">
{isLoading
? 'Chargement…'
: `${total} facture${total !== 1 ? 's' : ''}${hasActiveFilters ? ' (filtrée' + (total !== 1 ? 's' : '') + ')' : ''}`}
</p>
{totalPages > 1 && (
<p className="text-xs text-gray-400">Page {page} / {totalPages}</p>
)}
</div>
{/* État chargement */}
{isLoading ? (
<div className="py-14 flex flex-col items-center gap-3 text-gray-400">
<svg className="w-6 h-6 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Chargement des factures</span>
</div>
) : invoices.length === 0 ? (
/* État vide */
<div className="py-14 flex flex-col items-center gap-3 text-gray-400">
<svg className="w-12 h-12 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm">Aucune facture trouvée</p>
{hasActiveFilters && (
<button
onClick={resetFilters}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
>
Effacer les filtres
</button>
)}
</div>
) : (
/* Tableau des factures */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-100">
<SortHeader label="Date envoi" field="sent_at" current={sortBy} dir={sortDir} onSort={handleSort} />
<SortHeader label="Société" field="company_name" current={sortBy} dir={sortDir} onSort={handleSort} />
<SortHeader label="Catégorie" field="category_name" current={sortBy} dir={sortDir} onSort={handleSort} />
<SortHeader label="Fournisseur" field="supplier" current={sortBy} dir={sortDir} onSort={handleSort} />
<SortHeader label="Montant" field="amount" current={sortBy} dir={sortDir} onSort={handleSort} right />
<th className="px-3 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
Statut
</th>
<th className="px-3 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{invoices.map((inv) => (
<tr key={inv.id} className="hover:bg-gray-50/60 transition-colors">
{/* Date d'envoi */}
<td className="px-3 py-3 text-gray-500 whitespace-nowrap">
{fmt(inv.sent_at)}
</td>
{/* Société */}
<td className="px-3 py-3 font-medium text-gray-800 whitespace-nowrap">
{inv.company_name}
</td>
{/* Catégorie */}
<td className="px-3 py-3 text-gray-500 whitespace-nowrap">
{inv.category_name}
</td>
{/* Fournisseur */}
<td className="px-3 py-3 text-gray-600 max-w-[140px]">
<span className="block truncate">{inv.supplier || '—'}</span>
</td>
{/* Montant */}
<td className="px-3 py-3 text-right font-semibold text-gray-900 tabular-nums whitespace-nowrap">
{fmtAmount(inv.amount)}
</td>
{/* Statut */}
<td className="px-3 py-3 whitespace-nowrap">
<StatusBadge
status={inv.status}
invoiceId={inv.id}
onToggle={handleToggleStatus}
toggling={togglingId === inv.id}
/>
</td>
{/* Actions */}
<td className="px-3 py-3 whitespace-nowrap">
<div className="flex items-center justify-end gap-0.5">
{/* Voir le détail */}
<button
onClick={() => navigate(`/invoices/${inv.id}`)}
title="Voir le détail"
className="p-1.5 rounded-lg text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
{/* Télécharger PDF */}
{inv.pdf_path && (
<button
onClick={() => handleDownloadPDF(inv)}
title="Télécharger le PDF"
className="p-1.5 rounded-lg text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10 13l2 2 2-2m-2 2V9" />
</svg>
</button>
)}
{/* Supprimer */}
<button
onClick={() => setDeleteTarget(inv)}
title="Supprimer"
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{!isLoading && totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-1 text-sm font-medium text-gray-600
disabled:text-gray-300 hover:text-indigo-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Précédent
</button>
<div className="flex items-center gap-1">
{pageButtons.map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors
${page === p
? 'bg-indigo-600 text-white'
: 'text-gray-600 hover:bg-gray-100'}`}
>
{p}
</button>
))}
</div>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-1 text-sm font-medium text-gray-600
disabled:text-gray-300 hover:text-indigo-600 transition-colors"
>
Suivant
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* ── Modal de confirmation suppression ── */}
<DeleteModal
invoice={deleteTarget}
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
loading={deleteMut.isPending}
/>
</div>
);
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Page "Nouvelle facture"
* Flux complet : capture → recadrage → OCR → formulaire → invités → envoi
*/
import { useState, lazy, Suspense, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import api from '../api/client';
import Camera from '../components/Camera';
import GuestManager, { type GuestManagerHandle } from '../components/GuestManager';
import { useOCR } from '../hooks/useOCR';
import { useOfflineQueue } from '../hooks/useOfflineQueue';
import type { Company, Category, Guest, InvoiceImage } from '../types';
// Chargement lazy du cropper (lourd, uniquement quand nécessaire)
const ImageCropper = lazy(() => import('../components/ImageCropper'));
// ─── Types internes ───────────────────────────────────────────
type Step = 'capture' | 'crop' | 'form';
interface CapturedImage {
dataUrl: string;
filename: string; // renvoyé par le backend après upload
order: number;
}
// ─── Composant ────────────────────────────────────────────────
export default function NewInvoice() {
const qc = useQueryClient();
const { addToQueue } = useOfflineQueue();
// Ref pour retenir l'invoice_id créé si le send échoue hors ligne
const createdInvoiceIdRef = useRef<string | null>(null);
// Ref vers GuestManager pour flusher l'invité en cours de saisie
const guestManagerRef = useRef<GuestManagerHandle>(null);
// ── Étape du flux ──────────────────────────────────────────
const [step, setStep] = useState<Step>('capture');
// ── Images capturées ───────────────────────────────────────
const [pendingDataUrl, setPendingDataUrl] = useState<string>('');
const [pendingMime, setPendingMime] = useState<string>('image/jpeg');
const [images, setImages] = useState<CapturedImage[]>([]);
// ── OCR ────────────────────────────────────────────────────
const ocr = useOCR();
// ── Champs formulaire ──────────────────────────────────────
const today = new Date().toISOString().split('T')[0];
const [companyId, setCompanyId] = useState('');
const [categoryId, setCategoryId] = useState('');
const [supplier, setSupplier] = useState('');
const [amount, setAmount] = useState('');
const [invoiceDate, setInvoiceDate] = useState(today);
const [comment, setComment] = useState('');
const [addTracking, setAddTracking] = useState(true);
const [guests, setGuests] = useState<Guest[]>([]);
const [showGuests, setShowGuests] = useState(false);
// ── Data ───────────────────────────────────────────────────
const { data: companies = [] } = useQuery<Company[]>({
queryKey: ['companies'],
queryFn: () => api.get('/companies').then((r) => r.data),
});
const { data: categories = [] } = useQuery<Category[]>({
queryKey: ['categories'],
queryFn: () => api.get('/categories').then((r) => r.data),
});
// Auto-ouvrir la section invités quand la catégorie est "Restaurant"
useEffect(() => {
const selected = categories.find((c) => c.id === parseInt(categoryId));
if (selected?.name?.toLowerCase().includes('restaurant')) {
setShowGuests(true);
}
}, [categoryId, categories]);
// ── Mutation upload image ───────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (dataUrl: string) => {
const blob = await (await fetch(dataUrl)).blob();
const form = new FormData();
form.append('image', blob, 'facture.jpg');
const res = await api.post('/invoices/upload-image', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data.filename as string;
},
});
// ── Mutation créer + envoyer facture ───────────────────────
const sendMutation = useMutation({
mutationFn: async () => {
if (!companyId || !categoryId || !amount || !invoiceDate) {
throw new Error('Veuillez remplir tous les champs obligatoires');
}
if (images.length === 0) {
throw new Error('Aucune image attachée');
}
createdInvoiceIdRef.current = null;
// Auto-ajouter l'invité en cours de saisie s'il n'a pas encore été
// validé via "Ajouter l'invité" (cas fréquent : l'utilisateur tape
// le nom et clique directement sur "Envoyer").
// flushPending() retourne la liste complète si un guest a été flushé,
// null sinon — on utilise cette valeur pour éviter un souci de timing
// avec la mise à jour asynchrone du state React.
const guestsFlushed = guestManagerRef.current?.flushPending() ?? null;
const finalGuests = guestsFlushed ?? guests;
const invoiceImages: InvoiceImage[] = images.map((img) => ({
path: img.filename,
order: img.order,
}));
// Étape 1 : créer la facture
const createRes = await api.post('/invoices', {
company_id: parseInt(companyId),
category_id: parseInt(categoryId),
supplier: supplier || undefined,
amount: parseFloat(amount),
invoice_date: invoiceDate,
comment: comment || undefined,
images: invoiceImages,
add_to_tracking: addTracking,
guests: finalGuests,
});
// Mémoriser l'id en cas d'échec du send hors ligne
createdInvoiceIdRef.current = createRes.data.id;
// Étape 2 : envoyer (génère PDF + email + Excel)
const sendRes = await api.post(`/invoices/${createRes.data.id}/send`);
return sendRes.data;
},
onSuccess: (data: any) => {
toast.success('Facture envoyée avec succès !');
if (data?.tracking_error) {
toast.error(`⚠️ Suivi SharePoint : ${data.tracking_error}`, { duration: 10000 });
}
qc.invalidateQueries({ queryKey: ['invoices'] });
createdInvoiceIdRef.current = null;
resetForm();
},
onError: (err: any) => {
const invoiceId = createdInvoiceIdRef.current;
// Facture créée mais envoi échoué hors ligne → mise en file d'attente
if (invoiceId && !navigator.onLine) {
addToQueue(invoiceId);
qc.invalidateQueries({ queryKey: ['invoices'] });
createdInvoiceIdRef.current = null;
resetForm();
return;
}
toast.error(err.response?.data?.error ?? err.message ?? "Erreur lors de l'envoi");
},
});
// ── Handlers ───────────────────────────────────────────────
function handleCapture(dataUrl: string, mime: string) {
setPendingDataUrl(dataUrl);
setPendingMime(mime);
setStep('crop');
}
async function handleCropConfirm(croppedDataUrl: string) {
setStep('form');
// Upload de l'image
const toastId = toast.loading('Envoi de l\'image…');
try {
const filename = await uploadMutation.mutateAsync(croppedDataUrl);
const newImage: CapturedImage = {
dataUrl: croppedDataUrl,
filename,
order: images.length,
};
setImages((prev) => [...prev, newImage]);
toast.success('Image ajoutée', { id: toastId });
// Lancer l'OCR sur la première image uniquement
if (images.length === 0) {
ocr.run(croppedDataUrl);
}
} catch {
toast.error('Erreur lors de l\'upload', { id: toastId });
}
}
// Quand l'OCR se termine, pré-remplir les champs si vides
if (ocr.result && !amount && ocr.result.amount) {
setAmount(ocr.result.amount);
}
if (ocr.result && invoiceDate === today && ocr.result.date !== today) {
setInvoiceDate(ocr.result.date);
}
if (ocr.result && !supplier && ocr.result.supplier) {
setSupplier(ocr.result.supplier);
}
function removeImage(index: number) {
setImages((prev) => prev.filter((_, i) => i !== index));
}
function resetForm() {
setStep('capture');
setImages([]);
setCompanyId('');
setCategoryId('');
setSupplier('');
setAmount('');
setInvoiceDate(today);
setComment('');
setAddTracking(true);
setGuests([]);
setShowGuests(false);
ocr.reset();
}
// ── Rendu ──────────────────────────────────────────────────
// Écran de recadrage (plein écran)
if (step === 'crop' && pendingDataUrl) {
return (
<Suspense fallback={<div className="min-h-screen bg-black" />}>
<ImageCropper
src={pendingDataUrl}
onConfirm={handleCropConfirm}
onCancel={() => { setStep(images.length > 0 ? 'form' : 'capture'); }}
/>
</Suspense>
);
}
return (
<div className="space-y-5">
<h2 className="text-xl font-bold text-gray-900">Nouvelle facture</h2>
{/* ── Étape capture ── */}
{step === 'capture' && (
<Camera onCapture={handleCapture} />
)}
{/* ── Formulaire (après au moins une image) ── */}
{step === 'form' && (
<div className="space-y-5">
{/* Images capturées */}
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div key={i} className="relative w-20 h-20">
<img
src={img.dataUrl}
alt={`Photo ${i + 1}`}
className="w-full h-full object-cover rounded-xl border border-gray-200"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white rounded-full
flex items-center justify-center text-xs leading-none"
>
×
</button>
</div>
))}
{/* Bouton ajouter une photo supplémentaire */}
<button
type="button"
onClick={() => setStep('capture')}
className="w-20 h-20 border-2 border-dashed border-gray-200 rounded-xl
flex flex-col items-center justify-center gap-1
text-gray-400 hover:border-indigo-300 hover:text-indigo-400 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
<span className="text-xs">Photo</span>
</button>
</div>
{/* Barre de progression OCR */}
{ocr.loading && (
<div className="bg-indigo-50 rounded-xl p-3 flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-indigo-600 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<div className="flex-1">
<div className="text-xs text-indigo-700 font-medium mb-1">
Lecture automatique {ocr.progress}%
</div>
<div className="h-1.5 bg-indigo-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-600 rounded-full transition-all"
style={{ width: `${ocr.progress}%` }}
/>
</div>
</div>
</div>
)}
</div>
{/* ── Formulaire ── */}
<div className="card p-4 space-y-4">
{/* Société */}
<div>
<label className="form-label">Société <span className="text-red-400">*</span></label>
<select
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
required
className="form-input"
>
<option value="">Sélectionner une société</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{/* Catégorie */}
<div>
<label className="form-label">Catégorie <span className="text-red-400">*</span></label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
required
className="form-input"
>
<option value="">Sélectionner une catégorie</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{/* Fournisseur */}
<div>
<label className="form-label">
Fournisseur
{ocr.result?.supplier && !supplier && (
<span className="ml-2 text-xs text-indigo-500 font-normal">(OCR détecté)</span>
)}
</label>
<input
type="text"
value={supplier}
onChange={(e) => setSupplier(e.target.value)}
placeholder="Nom du restaurant, magasin…"
className="form-input"
/>
</div>
{/* Montant + Date */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="form-label">
Montant () <span className="text-red-400">*</span>
{ocr.result?.amount && !amount && (
<span className="ml-1 text-xs text-indigo-500">(OCR)</span>
)}
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
required
className="form-input"
/>
</div>
<div>
<label className="form-label">
Date <span className="text-red-400">*</span>
{ocr.result?.date && ocr.result.date !== today && (
<span className="ml-1 text-xs text-indigo-500">(OCR)</span>
)}
</label>
<input
type="date"
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
required
className="form-input"
/>
</div>
</div>
{/* Commentaire */}
<div>
<label className="form-label">Commentaire</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Objet du repas, contexte…"
rows={2}
className="form-input resize-none"
/>
</div>
{/* Case "Ajouter au fichier de suivi" */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={addTracking}
onChange={(e) => setAddTracking(e.target.checked)}
className="w-5 h-5 rounded accent-indigo-600"
/>
<span className="text-sm text-gray-700">
Ajouter au fichier de suivi SharePoint
</span>
</label>
</div>
{/* ── Section invités ── */}
<div className="card p-4">
<button
type="button"
onClick={() => setShowGuests(!showGuests)}
className="flex items-center justify-between w-full"
>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0" />
</svg>
<span className="font-medium text-gray-900 text-sm">
Invités
{guests.length > 0 && (
<span className="ml-2 bg-indigo-100 text-indigo-600 text-xs px-2 py-0.5 rounded-full font-semibold">
{guests.length}
</span>
)}
</span>
</div>
<svg
className={`w-5 h-5 text-gray-300 transition-transform ${showGuests ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showGuests && (
<div className="mt-3">
<GuestManager ref={guestManagerRef} guests={guests} onChange={setGuests} />
</div>
)}
</div>
{/* ── Bouton envoi ── */}
<button
type="button"
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending || !companyId || !categoryId || !amount || !invoiceDate}
className="btn-primary"
>
{sendMutation.isPending ? (
<>
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Envoi en cours
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Envoyer la facture
</>
)}
</button>
<button type="button" onClick={resetForm} className="btn-secondary">
Recommencer
</button>
</div>
)}
</div>
);
}
+574
View File
@@ -0,0 +1,574 @@
/**
* Page Paramètres — 4 sections :
* 1. SMTP (config email par utilisateur)
* 2. Sociétés (nom + email de remboursement)
* 3. Catégories
* 4. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun)
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { useRef } from 'react';
import api from '../api/client';
import type { Company, Category, SmtpConfig, AppSettings, Contact } from '../types';
// ─── Section SMTP ────────────────────────────────────────────
function SmtpSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<SmtpConfig>({
queryKey: ['smtp'],
queryFn: () => api.get('/settings/smtp').then((r) => r.data),
});
const [form, setForm] = useState<Partial<SmtpConfig> & { smtp_pass?: string }>({});
const [open, setOpen] = useState(false);
const save = useMutation({
mutationFn: (payload: object) => api.put('/settings/smtp', payload),
onSuccess: () => { toast.success('SMTP sauvegardé'); qc.invalidateQueries({ queryKey: ['smtp'] }); setOpen(false); },
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const test = useMutation({
mutationFn: () => api.post('/settings/smtp/test'),
onSuccess: () => toast.success('Email de test envoyé !'),
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Échec SMTP'),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const merged = { ...data, ...form, smtp_port: Number(form.smtp_port ?? data?.smtp_port ?? 587) };
save.mutate(merged);
}
const cfg = { ...data, ...form };
return (
<div className="card">
<button onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Email (SMTP)</p>
<p className="text-xs text-gray-400">{data?.smtp_from_email ?? 'Non configuré'}</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{open && !isLoading && (
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="form-label">Hôte SMTP</label>
<input className="form-input" placeholder="smtp.gmail.com"
value={cfg.smtp_host ?? ''} onChange={e => setForm(f => ({ ...f, smtp_host: e.target.value }))} />
</div>
<div>
<label className="form-label">Port</label>
<input className="form-input" type="number" placeholder="587"
value={cfg.smtp_port ?? 587} onChange={e => setForm(f => ({ ...f, smtp_port: Number(e.target.value) }))} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="w-4 h-4 accent-indigo-600"
checked={!!cfg.smtp_secure} onChange={e => setForm(f => ({ ...f, smtp_secure: e.target.checked }))} />
<span className="text-sm text-gray-700">SSL/TLS</span>
</label>
</div>
<div className="col-span-2">
<label className="form-label">Utilisateur</label>
<input className="form-input" placeholder="votre@email.com"
value={cfg.smtp_user ?? ''} onChange={e => setForm(f => ({ ...f, smtp_user: e.target.value }))} />
</div>
<div className="col-span-2">
<label className="form-label">Mot de passe {data?.has_password && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input" type="password" placeholder={data?.has_password ? '••••••• (laisser vide = conserver)' : 'Mot de passe'}
value={form.smtp_pass ?? ''} onChange={e => setForm(f => ({ ...f, smtp_pass: e.target.value }))} />
</div>
<div>
<label className="form-label">Nom affiché</label>
<input className="form-input" placeholder="Mon Nom"
value={cfg.smtp_from_name ?? ''} onChange={e => setForm(f => ({ ...f, smtp_from_name: e.target.value }))} />
</div>
<div>
<label className="form-label">Email expéditeur</label>
<input className="form-input" type="email" placeholder="moi@email.com"
value={cfg.smtp_from_email ?? ''} onChange={e => setForm(f => ({ ...f, smtp_from_email: e.target.value }))} />
</div>
</div>
<div className="flex gap-2 pt-1">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" onClick={() => test.mutate()} disabled={test.isPending || !data?.has_password}
className="btn-secondary py-2 text-sm">
{test.isPending ? 'Envoi…' : 'Tester'}
</button>
</div>
</form>
)}
</div>
);
}
// ─── Section Sociétés ────────────────────────────────────────
function CompaniesSection() {
const qc = useQueryClient();
const { data: companies = [] } = useQuery<Company[]>({
queryKey: ['companies'],
queryFn: () => api.get('/companies').then(r => r.data),
});
const [editing, setEditing] = useState<Partial<Company> | null>(null);
const [open, setOpen] = useState(false);
const save = useMutation({
mutationFn: (c: Partial<Company>) => c.id
? api.put(`/companies/${c.id}`, c)
: api.post('/companies', c),
onSuccess: () => { toast.success('Société sauvegardée'); qc.invalidateQueries({ queryKey: ['companies'] }); setEditing(null); },
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete(`/companies/${id}`),
onSuccess: () => { toast.success('Société supprimée'); qc.invalidateQueries({ queryKey: ['companies'] }); },
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (editing) save.mutate(editing);
}
return (
<div className="card">
<button onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-emerald-50 flex items-center justify-center">
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Sociétés</p>
<p className="text-xs text-gray-400">{companies.length} société{companies.length !== 1 ? 's' : ''}</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{open && (
<div className="border-t border-gray-50">
{/* Liste */}
{companies.map(c => (
<div key={c.id} className="flex items-center gap-3 px-4 py-3 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
<p className="text-xs text-gray-400 truncate">{c.email}</p>
</div>
<button onClick={() => setEditing(c)} className="p-1.5 text-gray-300 hover:text-indigo-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button onClick={() => { if (confirm(`Supprimer ${c.name} ?`)) del.mutate(c.id); }}
className="p-1.5 text-gray-300 hover:text-red-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
))}
{/* Formulaire ajout/modif */}
{editing !== null ? (
<form onSubmit={handleSubmit} className="p-4 bg-gray-50 space-y-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{editing.id ? 'Modifier la société' : 'Nouvelle société'}
</p>
<input className="form-input" placeholder="Nom *" required
value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} />
<input className="form-input" type="email" placeholder="Email destinataire *" required
value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} />
<div className="flex gap-2">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? '…' : 'Sauvegarder'}
</button>
<button type="button" onClick={() => setEditing(null)} className="btn-secondary py-2 text-sm">Annuler</button>
</div>
</form>
) : (
<div className="p-4">
<button onClick={() => setEditing({})} className="btn-secondary py-2 text-sm">
+ Ajouter une société
</button>
</div>
)}
</div>
)}
</div>
);
}
// ─── Section Catégories ──────────────────────────────────────
function CategoriesSection() {
const qc = useQueryClient();
const { data: categories = [] } = useQuery<Category[]>({
queryKey: ['categories'],
queryFn: () => api.get('/categories').then(r => r.data),
});
const [newName, setNewName] = useState('');
const [open, setOpen] = useState(false);
const add = useMutation({
mutationFn: (name: string) => api.post('/categories', { name }),
onSuccess: () => { toast.success('Catégorie ajoutée'); qc.invalidateQueries({ queryKey: ['categories'] }); setNewName(''); },
});
const del = useMutation({
mutationFn: (id: number) => api.delete(`/categories/${id}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['categories'] }); },
});
return (
<div className="card">
<button onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-amber-50 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a2 2 0 012-2z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Catégories</p>
<p className="text-xs text-gray-400">{categories.length} catégorie{categories.length !== 1 ? 's' : ''}</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{open && (
<div className="border-t border-gray-50 p-4 space-y-2">
{categories.map(c => (
<div key={c.id} className="flex items-center gap-2">
<span className="flex-1 text-sm text-gray-700">{c.name}</span>
<button onClick={() => del.mutate(c.id)} className="p-1 text-gray-300 hover:text-red-400">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
))}
<div className="flex gap-2 pt-2">
<input className="form-input text-sm py-2 flex-1" placeholder="Nouvelle catégorie"
value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (newName.trim()) add.mutate(newName.trim()); }}} />
<button onClick={() => { if (newName.trim()) add.mutate(newName.trim()); }}
disabled={!newName.trim()}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
+
</button>
</div>
</div>
)}
</div>
);
}
// ─── Section Microsoft Graph ─────────────────────────────────
function GraphSection() {
const qc = useQueryClient();
const { data } = useQuery<AppSettings>({
queryKey: ['app-settings'],
queryFn: () => api.get('/settings/app').then(r => r.data),
});
const [form, setForm] = useState<{
tenant?: string; client?: string; secret?: string;
siteId?: string; itemId?: string; sheetName?: string;
}>({});
const [open, setOpen] = useState(false);
const save = useMutation({
mutationFn: (payload: object) => api.put('/settings/app', payload),
onSuccess: () => { toast.success('Configuration Graph sauvegardée'); qc.invalidateQueries({ queryKey: ['app-settings'] }); setOpen(false); },
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const testSp = useMutation({
mutationFn: () => api.post('/settings/sharepoint/test'),
onSuccess: (r: any) => toast.success(r.data?.message ?? 'SharePoint OK'),
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur SharePoint', { duration: 8000 }),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
save.mutate({
graph_tenant_id: form.tenant || undefined,
graph_client_id: form.client || undefined,
graph_client_secret: form.secret || undefined,
sharepoint_site_id: form.siteId || undefined,
sharepoint_item_id: form.itemId || undefined,
sharepoint_sheet_name: form.sheetName || undefined,
});
}
const isConfigured =
data?.graph_tenant_id && data?.graph_client_id && data?.has_secret === 'true' &&
data?.sharepoint_site_id && data?.sharepoint_item_id;
return (
<div className="card">
<button onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-sky-50 flex items-center justify-center">
<svg className="w-5 h-5 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Microsoft 365 / SharePoint</p>
<p className={`text-xs ${isConfigured ? 'text-green-500' : 'text-gray-400'}`}>
{isConfigured ? '✓ Connecté' : 'Non configuré'}
</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{open && (
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
<div className="bg-sky-50 rounded-xl p-3 text-xs text-sky-700 space-y-1">
<p className="font-semibold">Configuration Azure App Registration</p>
<p>Voir le README pour créer l'App Registration et obtenir ces valeurs.</p>
</div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide pt-1">Azure App Registration</p>
{([
{ key: 'tenant', label: 'Tenant ID', ph: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', cur: data?.graph_tenant_id },
{ key: 'client', label: 'Client ID', ph: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', cur: data?.graph_client_id },
] as { key: string; label: string; ph: string; cur?: string }[]).map(({ key, label, ph, cur }) => (
<div key={key}>
<label className="form-label">{label} {cur && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input font-mono text-xs" placeholder={cur ? '(conserver existant)' : ph}
value={(form as any)[key] ?? ''} onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))} />
</div>
))}
<div>
<label className="form-label">Client Secret {data?.has_secret === 'true' && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input" type="password" placeholder={data?.has_secret === 'true' ? '(laisser vide = conserver)' : 'Client Secret'}
value={form.secret ?? ''} onChange={e => setForm(f => ({ ...f, secret: e.target.value }))} />
</div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide pt-2">Fichier Excel SharePoint (partagé entre toutes les sociétés)</p>
<div>
<label className="form-label">Site ID {data?.sharepoint_site_id && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input font-mono text-xs"
placeholder={data?.sharepoint_site_id ? '(conserver existant)' : 'contoso.sharepoint.com,xxxxxxxx,yyyyyyyy'}
value={form.siteId ?? ''} onChange={e => setForm(f => ({ ...f, siteId: e.target.value }))} />
</div>
<div>
<label className="form-label">Item ID du fichier Excel {data?.sharepoint_item_id && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input font-mono text-xs"
placeholder={data?.sharepoint_item_id ? '(conserver existant)' : 'ID de l\'élément (GET .../drive/root:/fichier.xlsx)'}
value={form.itemId ?? ''} onChange={e => setForm(f => ({ ...f, itemId: e.target.value }))} />
</div>
<div>
<label className="form-label">
Nom de la feuille {data?.sharepoint_sheet_name && <span className="text-green-500 text-xs">({data.sharepoint_sheet_name})</span>}
</label>
<input className="form-input text-sm"
placeholder={data?.sharepoint_sheet_name ?? 'Feuil1'}
value={form.sheetName ?? ''} onChange={e => setForm(f => ({ ...f, sheetName: e.target.value }))} />
</div>
<div className="flex gap-2">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" onClick={() => testSp.mutate()}
disabled={testSp.isPending || !isConfigured}
className="btn-secondary py-2 text-sm">
{testSp.isPending ? 'Test…' : 'Tester'}
</button>
</div>
</form>
)}
</div>
);
}
// ─── Section Contacts ────────────────────────────────────────
function ContactsSection() {
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newCompany, setNewCompany] = useState('');
const [importing, setImporting] = useState(false);
const { data: contacts = [] } = useQuery<Contact[]>({
queryKey: ['contacts'],
queryFn: () => api.get('/contacts').then(r => r.data),
});
const add = useMutation({
mutationFn: (payload: { name: string; company: string }) =>
api.post('/contacts', payload),
onSuccess: () => {
toast.success('Contact ajouté');
qc.invalidateQueries({ queryKey: ['contacts'] });
setNewName(''); setNewCompany('');
},
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete(`/contacts/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
async function handleFileImport(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
setImporting(true);
const formData = new FormData();
formData.append('file', file);
try {
const r = await api.post('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const { inserted, skipped, total } = r.data;
toast.success(`${inserted} contact${inserted !== 1 ? 's' : ''} importé${inserted !== 1 ? 's' : ''}${skipped > 0 ? ` (${skipped} doublon${skipped !== 1 ? 's' : ''} ignoré${skipped !== 1 ? 's' : ''})` : ''} sur ${total}`);
qc.invalidateQueries({ queryKey: ['contacts'] });
} catch (err: any) {
toast.error(err.response?.data?.error ?? 'Erreur lors de l\'import');
} finally {
setImporting(false);
}
}
return (
<div className="card">
<button onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-violet-50 flex items-center justify-center">
<svg className="w-5 h-5 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Contacts / Invités</p>
<p className="text-xs text-gray-400">{contacts.length} contact{contacts.length !== 1 ? 's' : ''}</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{open && (
<div className="border-t border-gray-50">
{/* Bouton import */}
<div className="px-4 pt-3 pb-2 flex gap-2">
<input ref={fileRef} type="file" accept=".csv,.xls,.xlsx"
onChange={handleFileImport} className="hidden" />
<button
onClick={() => fileRef.current?.click()}
disabled={importing}
className="btn-secondary py-2 text-sm flex items-center gap-2">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
{importing ? 'Import en cours…' : 'Importer CSV / Excel'}
</button>
<p className="text-xs text-gray-400 self-center">Colonnes : Nom, Société (optionnel)</p>
</div>
{/* Liste des contacts */}
{contacts.length > 0 && (
<div className="px-4 pb-2 max-h-64 overflow-y-auto space-y-1">
{contacts.map(c => (
<div key={c.id} className="flex items-center gap-3 py-1.5 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900">{c.name}</span>
{c.company && <span className="ml-2 text-xs text-gray-400">{c.company}</span>}
</div>
<button onClick={() => del.mutate(c.id)}
className="p-1 text-gray-300 hover:text-red-400 shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
))}
</div>
)}
{/* Ajout manuel */}
<div className="px-4 pb-4 pt-2 space-y-2 border-t border-gray-50">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Ajouter manuellement</p>
<div className="flex gap-2">
<input className="form-input text-sm py-2 flex-1" placeholder="Nom *"
value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
<input className="form-input text-sm py-2 flex-1" placeholder="Société (optionnel)"
value={newCompany} onChange={e => setNewCompany(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
<button
onClick={() => { if (newName.trim()) add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}
disabled={!newName.trim() || add.isPending}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
+
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Page principale ─────────────────────────────────────────
export default function Settings() {
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-900">Paramètres</h2>
<SmtpSection />
<CompaniesSection />
<CategoriesSection />
<ContactsSection />
<GraphSection />
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '../types';
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
setAccessToken: (token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken }),
setAccessToken: (accessToken) =>
set({ accessToken }),
logout: () =>
set({ user: null, accessToken: null, refreshToken: null }),
}),
{
name: 'notesfrais-auth',
// Ne persiste pas le token en clair dans sessionStorage, utilise localStorage
// (acceptable car il expire en 15 min et le refresh token sert au renouvellement)
}
)
);
+135
View File
@@ -0,0 +1,135 @@
// ─── Utilisateur ─────────────────────────────────────────────
export interface User {
id: number;
name: string;
email: string;
}
// ─── Société ─────────────────────────────────────────────────
export interface Company {
id: number;
name: string;
email: string;
is_active: boolean;
created_at: string;
}
// ─── Catégorie ───────────────────────────────────────────────
export interface Category {
id: number;
name: string;
sort_order: number;
is_active: boolean;
}
// ─── Invité ───────────────────────────────────────────────────
export interface Guest {
name: string;
company?: string | null;
sort_order?: number;
}
// ─── Image de facture ────────────────────────────────────────
export interface InvoiceImage {
path: string;
order: number;
}
// ─── Facture ─────────────────────────────────────────────────
export type InvoiceStatus = 'pending' | 'reimbursed';
export interface Invoice {
id: string;
user_id: number;
company_id: number;
company_name: string;
category_id: number;
category_name: string;
supplier?: string;
amount: number;
invoice_date: string; // YYYY-MM-DD
comment?: string;
images: InvoiceImage[];
pdf_path?: string;
pdf_filename?: string;
add_to_tracking: boolean;
tracking_added: boolean;
email_sent: boolean;
sent_at?: string;
status: InvoiceStatus;
guests: Guest[];
created_at: string;
updated_at: string;
}
// ─── Création facture ────────────────────────────────────────
export interface CreateInvoicePayload {
company_id: number;
category_id: number;
supplier?: string;
amount: number;
invoice_date: string;
comment?: string;
images: InvoiceImage[];
add_to_tracking: boolean;
guests: Guest[];
}
// ─── Réponse liste factures ──────────────────────────────────
export interface InvoicesListResponse {
data: Invoice[];
total: number;
page: number;
limit: number;
}
// ─── Récapitulatif par société ───────────────────────────────
export interface InvoiceSummaryItem {
company_name: string;
status: InvoiceStatus;
total: string;
count: number;
}
// ─── Filtres de listing ──────────────────────────────────────
export interface InvoiceFilters {
company_ids?: number[];
category_ids?: number[];
status?: InvoiceStatus | '';
date_from?: string;
date_to?: string;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
page?: number;
limit?: number;
}
// ─── Config SMTP ─────────────────────────────────────────────
export interface SmtpConfig {
smtp_host: string;
smtp_port: number;
smtp_secure: boolean;
smtp_user: string;
smtp_from_name: string;
smtp_from_email: string;
has_password: boolean;
}
// ─── Contact (répertoire d'invités) ─────────────────────────
export interface Contact {
id: number;
name: string;
company: string | null;
sort_order: number;
}
// ─── Config Graph + SharePoint ───────────────────────────────
export interface AppSettings {
graph_tenant_id?: string;
graph_client_id?: string;
sharepoint_site_id?: string;
sharepoint_item_id?: string;
sharepoint_sheet_name?: string;
has_secret?: string;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* File d'attente hors-ligne — IndexedDB
*
* Stocke les invoice_id dont l'envoi a échoué faute de connexion.
* Quand la connexion revient, le hook useOfflineQueue rejoue les envois.
*
* Communication inter-composants : custom event 'offline-queue-changed'
* évite d'avoir à partager l'état via un store global.
*/
const DB_NAME = 'notesfrais-offline';
const DB_VERSION = 1;
const STORE = 'pending_sends';
// ─── Types ───────────────────────────────────────────────────
export interface QueueItem {
invoiceId: string;
addedAt: number;
retries: number;
}
// ─── Ouverture DB ─────────────────────────────────────────────
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'invoiceId' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// ─── CRUD ─────────────────────────────────────────────────────
export async function queueGetAll(): Promise<QueueItem[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result as QueueItem[]);
req.onerror = () => reject(req.error);
});
}
export async function queueAdd(invoiceId: string): Promise<void> {
const db = await openDB();
const item: QueueItem = { invoiceId, addedAt: Date.now(), retries: 0 };
await new Promise<void>((resolve, reject) => {
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).put(item);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
notify();
}
export async function queueRemove(invoiceId: string): Promise<void> {
const db = await openDB();
await new Promise<void>((resolve, reject) => {
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).delete(invoiceId);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
notify();
}
export async function queueBumpRetries(invoiceId: string): Promise<void> {
const db = await openDB();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const get = store.get(invoiceId);
get.onsuccess = () => {
if (!get.result) { resolve(); return; }
const updated = { ...get.result as QueueItem, retries: (get.result as QueueItem).retries + 1 };
const put = store.put(updated);
put.onsuccess = () => resolve();
put.onerror = () => reject(put.error);
};
get.onerror = () => reject(get.error);
});
}
// ─── Event de synchronisation inter-composants ───────────────
export const QUEUE_EVENT = 'offline-queue-changed';
function notify() {
window.dispatchEvent(new CustomEvent(QUEUE_EVENT));
}
+25
View File
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#eef2ff',
100: '#e0e7ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
},
// Safe area pour les téléphones avec notch/home indicator
padding: {
safe: 'env(safe-area-inset-bottom)',
},
},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"types": ["vite/client"]
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+61
View File
@@ -0,0 +1,61 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
manifest: {
name: 'NotesFrais',
short_name: 'NotesFrais',
description: 'Gestion de notes de frais professionnelles',
theme_color: '#4f46e5',
background_color: '#f9fafb',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
// Cache API responses (stale-while-revalidate pour les listes)
urlPattern: /^\/api\/(?:companies|categories)/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-static',
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 },
},
},
{
// Network-first pour les factures (données fraîches prioritaires)
urlPattern: /^\/api\/invoices/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-invoices',
networkTimeoutSeconds: 8,
expiration: { maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 },
},
},
],
},
}),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});