fix: resolver DNS nginx pour éviter cache IP stale après restart backend
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
@@ -1,25 +0,0 @@
|
||||
# ── 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;"]
|
||||
@@ -1,18 +0,0 @@
|
||||
<!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>
|
||||
+7
-9
@@ -5,6 +5,10 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Résolveur Docker interne — re-résolution DNS à chaque requête (évite le cache IP stale)
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# ── Compression ───────────────────────────────────────────
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -22,7 +26,8 @@ server {
|
||||
|
||||
# ── API → backend (service Docker interne) ────────────────
|
||||
location /api/ {
|
||||
proxy_pass http://notesfrais-backend:3001/api/;
|
||||
set $upstream_backend http://notesfrais-backend:3001;
|
||||
proxy_pass $upstream_backend/api/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -34,11 +39,4 @@ server {
|
||||
client_max_body_size 20m;
|
||||
|
||||
# Timeout généreux pour la génération PDF
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# ── SPA fallback (React Router) ───────────────────────────
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
pr
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
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 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="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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,100 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
/**
|
||||
* Page "Mes factures" — Étape 9
|
||||
* Listing filtrable et triable, récapitulatif, export CSV.
|
||||
*/
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
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();
|
||||
|
||||
// ── 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">
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,135 +0,0 @@
|
||||
// ─── 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;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/** @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: [],
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user