deploy: equitask — 2026-04-28 19:51:14
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1e1b4b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="Mesurez la répartition des tâches domestiques dans votre foyer" />
|
||||
<title>EquiTask</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "equitask-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.28.9",
|
||||
"date-fns": "^3.6.0",
|
||||
"idb": "^8.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"recharts": "^2.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"workbox-precaching": "^7.1.0",
|
||||
"workbox-routing": "^7.1.0",
|
||||
"workbox-strategies": "^7.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
��1�Y�
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,78 @@
|
||||
// src/App.tsx
|
||||
// Routeur principal et initialisation de l'app
|
||||
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AppProvider, useApp } from './context/AppContext';
|
||||
import { Setup } from './pages/Setup';
|
||||
import { SelectionProfil } from './pages/SelectionProfil';
|
||||
import { Saisie } from './pages/Saisie';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Parametres } from './pages/Parametres';
|
||||
import { Layout } from './components/ui/Layout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { foyerApi } from './api';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, staleTime: 30_000 },
|
||||
},
|
||||
});
|
||||
|
||||
function AppRoutes() {
|
||||
const { foyer, setFoyer } = useApp();
|
||||
|
||||
// Charger le foyer au démarrage
|
||||
const { isLoading } = useQuery({
|
||||
queryKey: ['foyer'],
|
||||
queryFn: async () => {
|
||||
const f = await foyerApi.get();
|
||||
setFoyer(f);
|
||||
return f;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl mb-4">⚖️</div>
|
||||
<div className="text-slate-400 animate-pulse">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Si pas de foyer → setup
|
||||
if (foyer === null) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="*" element={<Setup />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<SelectionProfil />} />
|
||||
<Route path="/saisie" element={<Layout><Saisie /></Layout>} />
|
||||
<Route path="/dashboard" element={<Layout><Dashboard /></Layout>} />
|
||||
<Route path="/parametres" element={<Layout><Parametres /></Layout>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// src/api/client.ts
|
||||
// Client HTTP de base pour les appels API
|
||||
|
||||
const BASE_URL = import.meta.env.DEV ? '/api' : '/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE_URL + url, {
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ erreur: res.statusText }));
|
||||
throw new ApiError(res.status, body.erreur || res.statusText);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) => fetchApi<T>(url),
|
||||
post: <T>(url: string, data: unknown) => fetchApi<T>(url, {
|
||||
method: 'POST', body: JSON.stringify(data),
|
||||
}),
|
||||
put: <T>(url: string, data: unknown) => fetchApi<T>(url, {
|
||||
method: 'PUT', body: JSON.stringify(data),
|
||||
}),
|
||||
delete: <T>(url: string) => fetchApi<T>(url, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// src/api/index.ts
|
||||
// Toutes les fonctions d'appel API regroupées
|
||||
|
||||
import { api } from './client';
|
||||
import type { Foyer, Membre, Categorie, TacheRecurrente, Saisie, SaisieFormData, DashboardStats } from '../types';
|
||||
|
||||
// ─── Foyer ────────────────────────────────────────────────────────────────────
|
||||
export const foyerApi = {
|
||||
get: () => api.get<{ foyer: Foyer | null }>('/foyer').then(r => r.foyer),
|
||||
create: (nom: string) => api.post<{ foyer: Foyer }>('/foyer', { nom }).then(r => r.foyer),
|
||||
update: (id: number, nom: string) => api.put<{ foyer: Foyer }>(`/foyer/${id}`, { nom }).then(r => r.foyer),
|
||||
};
|
||||
|
||||
// ─── Membres ──────────────────────────────────────────────────────────────────
|
||||
export const membresApi = {
|
||||
list: () => api.get<{ membres: Membre[] }>('/membres').then(r => r.membres),
|
||||
create: (data: Omit<Membre, 'id' | 'cree_le'>) =>
|
||||
api.post<{ membre: Membre }>('/membres', data).then(r => r.membre),
|
||||
update: (id: number, data: Partial<Membre>) =>
|
||||
api.put<{ membre: Membre }>(`/membres/${id}`, data).then(r => r.membre),
|
||||
delete: (id: number) => api.delete<{ ok: boolean }>(`/membres/${id}`),
|
||||
};
|
||||
|
||||
// ─── Catégories ───────────────────────────────────────────────────────────────
|
||||
export const categoriesApi = {
|
||||
list: () => api.get<{ categories: Categorie[] }>('/categories').then(r => r.categories),
|
||||
create: (data: Omit<Categorie, 'id'>) =>
|
||||
api.post<{ categorie: Categorie }>('/categories', data).then(r => r.categorie),
|
||||
update: (id: number, data: Partial<Categorie>) =>
|
||||
api.put<{ categorie: Categorie }>(`/categories/${id}`, data).then(r => r.categorie),
|
||||
delete: (id: number) => api.delete<{ ok: boolean }>(`/categories/${id}`),
|
||||
};
|
||||
|
||||
// ─── Tâches récurrentes ───────────────────────────────────────────────────────
|
||||
export const tachesApi = {
|
||||
list: () => api.get<{ taches: TacheRecurrente[] }>('/taches').then(r => r.taches),
|
||||
create: (data: Omit<TacheRecurrente, 'id' | 'score_calcule'>) =>
|
||||
api.post<{ tache: TacheRecurrente }>('/taches', data).then(r => r.tache),
|
||||
update: (id: number, data: Partial<TacheRecurrente>) =>
|
||||
api.put<{ tache: TacheRecurrente }>(`/taches/${id}`, data).then(r => r.tache),
|
||||
delete: (id: number) => api.delete<{ ok: boolean }>(`/taches/${id}`),
|
||||
};
|
||||
|
||||
// ─── Saisies ──────────────────────────────────────────────────────────────────
|
||||
export const saisiesApi = {
|
||||
list: (params?: {
|
||||
debut?: string; fin?: string; membre_id?: number;
|
||||
categorie_id?: number; type?: string; page?: number; limit?: number;
|
||||
}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.debut) qs.set('debut', params.debut);
|
||||
if (params?.fin) qs.set('fin', params.fin);
|
||||
if (params?.membre_id) qs.set('membre_id', String(params.membre_id));
|
||||
if (params?.categorie_id) qs.set('categorie_id', String(params.categorie_id));
|
||||
if (params?.type) qs.set('type', params.type);
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
const q = qs.toString();
|
||||
return api.get<{ saisies: Saisie[]; total: number; page: number; limit: number }>(`/saisies${q ? '?' + q : ''}`);
|
||||
},
|
||||
create: (data: SaisieFormData) => api.post<{ saisie: Saisie }>('/saisies', data).then(r => r.saisie),
|
||||
batch: (saisiesData: SaisieFormData[]) =>
|
||||
api.post<{ ok: boolean; count: number }>('/saisies/batch', { saisiesData }),
|
||||
update: (id: number, data: Partial<Saisie>) =>
|
||||
api.put<{ saisie: Saisie }>(`/saisies/${id}`, data).then(r => r.saisie),
|
||||
delete: (id: number) => api.delete<{ ok: boolean }>(`/saisies/${id}`),
|
||||
};
|
||||
|
||||
// ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
export const dashboardApi = {
|
||||
stats: (params: { debut: string; fin: string; inclure_enfants?: boolean; categorie_id?: number }) => {
|
||||
const qs = new URLSearchParams({
|
||||
debut: params.debut,
|
||||
fin: params.fin,
|
||||
inclure_enfants: String(params.inclure_enfants ?? false),
|
||||
});
|
||||
if (params.categorie_id) qs.set('categorie_id', String(params.categorie_id));
|
||||
return api.get<DashboardStats>(`/saisies/stats?${qs.toString()}`);
|
||||
},
|
||||
export: (format: 'json' | 'csv') => {
|
||||
window.open(`/api/saisies/export?format=${format}`, '_blank');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
// src/components/ui/Layout.tsx
|
||||
// Layout principal avec nav bottom (mobile) et nav top (desktop)
|
||||
|
||||
import React from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useOfflineSync } from '../../hooks/useOfflineSync';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/saisie', label: 'Saisie', icon: '✏️' },
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: '📊' },
|
||||
{ path: '/parametres', label: 'Réglages', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { membreActif, setMembreActif, isOnline, queueCount } = useApp();
|
||||
const navigate = useNavigate();
|
||||
useOfflineSync();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-slate-900 border-b border-slate-800 px-4 py-3 flex items-center justify-between sticky top-0 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">⚖️</span>
|
||||
<span className="font-bold text-white tracking-tight">EquiTask</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Indicateur offline */}
|
||||
{!isOnline && (
|
||||
<span className="flex items-center gap-1 text-xs bg-amber-900/50 text-amber-300 border border-amber-700 px-2 py-1 rounded-full">
|
||||
📵 Offline
|
||||
{queueCount > 0 && <span className="font-bold">({queueCount})</span>}
|
||||
</span>
|
||||
)}
|
||||
{/* Membre actif */}
|
||||
{membreActif && (
|
||||
<button
|
||||
onClick={() => { setMembreActif(null); navigate('/'); }}
|
||||
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 rounded-full px-3 py-1.5 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ backgroundColor: membreActif.couleur }}
|
||||
>
|
||||
{membreActif.nom[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-slate-300 hidden sm:block">{membreActif.nom}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Contenu */}
|
||||
<main className="flex-1 pb-20 sm:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Nav bottom (mobile) */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-slate-900 border-t border-slate-800 flex sm:hidden z-30">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex-1 flex flex-col items-center py-2.5 gap-0.5 text-xs transition-colors ${
|
||||
isActive ? 'text-indigo-400' : 'text-slate-500 hover:text-slate-300'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Nav top desktop (en supplément) */}
|
||||
<nav className="hidden sm:flex fixed top-[57px] left-0 right-0 bg-slate-900/80 backdrop-blur border-b border-slate-800 justify-center gap-1 px-4 z-20">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 text-sm rounded-lg m-1 transition-colors ${
|
||||
isActive ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// src/components/ui/Modal.tsx
|
||||
// Composant modal réutilisable
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (isOpen) document.body.style.overflow = 'hidden';
|
||||
else document.body.style.overflow = '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
{/* Contenu */}
|
||||
<div className={`relative w-full ${maxWidth} bg-slate-900 rounded-t-2xl sm:rounded-2xl shadow-2xl border border-slate-700 animate-slide-up max-h-[90vh] overflow-y-auto`}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700">
|
||||
<h2 className="font-semibold text-white text-lg">{title}</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white text-2xl leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// src/components/ui/PenibiliteSelector.tsx
|
||||
// Sélecteur de coefficient de pénibilité (1-5)
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LABELS = ['', 'Facile', 'Léger', 'Modéré', 'Pénible', 'Épuisant'];
|
||||
const COLORS = ['', 'bg-green-600', 'bg-lime-600', 'bg-yellow-600', 'bg-orange-600', 'bg-red-600'];
|
||||
|
||||
export function PenibiliteSelector({ value, onChange, disabled }: Props) {
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(n)}
|
||||
title={LABELS[n]}
|
||||
className={`flex-1 h-10 rounded-lg font-bold text-sm transition-all ${
|
||||
value === n
|
||||
? `${COLORS[n]} text-white scale-105 shadow-lg`
|
||||
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// src/components/ui/ScoreBadge.tsx
|
||||
// Badge affichant un score avec mise en forme
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ScoreBadgeProps {
|
||||
score: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreBadge({ score, size = 'md', className = '' }: ScoreBadgeProps) {
|
||||
const sizes = { sm: 'text-xs px-2 py-0.5', md: 'text-sm px-3 py-1', lg: 'text-base px-4 py-1.5' };
|
||||
const color = score >= 200 ? 'bg-red-900/50 text-red-300 border-red-700'
|
||||
: score >= 100 ? 'bg-orange-900/50 text-orange-300 border-orange-700'
|
||||
: 'bg-indigo-900/50 text-indigo-300 border-indigo-700';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border font-bold ${sizes[size]} ${color} ${className}`}>
|
||||
⚡ {score} pts
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// src/components/ui/Toast.tsx
|
||||
// Composant toast notification
|
||||
|
||||
import React from 'react';
|
||||
import type { Toast as ToastType } from '../../hooks/useToast';
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastType[];
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const ICONS = { success: '✅', error: '❌', info: 'ℹ️' };
|
||||
const COLORS = {
|
||||
success: 'bg-emerald-900 border-emerald-500 text-emerald-100',
|
||||
error: 'bg-red-900 border-red-500 text-red-100',
|
||||
info: 'bg-indigo-900 border-indigo-500 text-indigo-100',
|
||||
};
|
||||
|
||||
export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border shadow-xl pointer-events-auto animate-slide-up ${COLORS[toast.type]}`}
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
>
|
||||
<span className="text-lg">{ICONS[toast.type]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{toast.message}</p>
|
||||
{toast.score !== undefined && (
|
||||
<p className="text-xs opacity-75 mt-0.5">+{toast.score} points</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// src/context/AppContext.tsx
|
||||
// Contexte global : membre actif, état de connexion, foyer
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import type { Membre, Foyer } from '../types';
|
||||
|
||||
interface AppContextType {
|
||||
/** Foyer courant (null si non configuré) */
|
||||
foyer: Foyer | null;
|
||||
setFoyer: (f: Foyer | null) => void;
|
||||
/** Membre actuellement connecté pour la saisie */
|
||||
membreActif: Membre | null;
|
||||
setMembreActif: (m: Membre | null) => void;
|
||||
/** Indicateur offline */
|
||||
isOnline: boolean;
|
||||
/** Nombre de saisies en queue offline */
|
||||
queueCount: number;
|
||||
setQueueCount: (n: number) => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [foyer, setFoyer] = useState<Foyer | null>(null);
|
||||
const [membreActif, setMembreActifState] = useState<Membre | null>(() => {
|
||||
// Restaurer le membre actif depuis sessionStorage
|
||||
const saved = sessionStorage.getItem('equitask_membre_actif');
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
});
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [queueCount, setQueueCount] = useState(0);
|
||||
|
||||
const setMembreActif = useCallback((m: Membre | null) => {
|
||||
setMembreActifState(m);
|
||||
if (m) sessionStorage.setItem('equitask_membre_actif', JSON.stringify(m));
|
||||
else sessionStorage.removeItem('equitask_membre_actif');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true);
|
||||
const onOffline = () => setIsOnline(false);
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('offline', onOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('offline', onOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ foyer, setFoyer, membreActif, setMembreActif, isOnline, queueCount, setQueueCount }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp doit être utilisé dans <AppProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// src/hooks/useOfflineSync.ts
|
||||
// Déclenche la synchronisation des saisies offline dès que la connexion revient
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getQueue, dequeue, countQueue } from '../offline/queue';
|
||||
import { saisiesApi } from '../api';
|
||||
import { useApp } from '../context/AppContext';
|
||||
|
||||
export function useOfflineSync() {
|
||||
const { isOnline, setQueueCount } = useApp();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/** Synchronise la queue et met à jour le compteur */
|
||||
const syncQueue = useCallback(async () => {
|
||||
const queue = await getQueue();
|
||||
setQueueCount(queue.length);
|
||||
|
||||
if (!isOnline || queue.length === 0) return;
|
||||
|
||||
console.log(`📡 Synchronisation de ${queue.length} saisie(s) offline...`);
|
||||
let synced = 0;
|
||||
|
||||
for (const item of queue) {
|
||||
try {
|
||||
const { offline_id, created_at, ...saisieData } = item;
|
||||
await saisiesApi.create(saisieData);
|
||||
await dequeue(offline_id);
|
||||
synced++;
|
||||
} catch (err) {
|
||||
console.warn('Échec sync saisie:', err);
|
||||
break; // Arrêter si erreur réseau
|
||||
}
|
||||
}
|
||||
|
||||
if (synced > 0) {
|
||||
console.log(`✅ ${synced} saisie(s) synchronisée(s)`);
|
||||
queryClient.invalidateQueries({ queryKey: ['saisies'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
const remaining = await countQueue();
|
||||
setQueueCount(remaining);
|
||||
}
|
||||
}, [isOnline, setQueueCount, queryClient]);
|
||||
|
||||
// Synchroniser quand la connexion revient
|
||||
useEffect(() => {
|
||||
if (isOnline) {
|
||||
syncQueue();
|
||||
}
|
||||
}, [isOnline, syncQueue]);
|
||||
|
||||
// Mettre à jour le compteur au démarrage
|
||||
useEffect(() => {
|
||||
countQueue().then(setQueueCount);
|
||||
}, [setQueueCount]);
|
||||
|
||||
return { syncQueue };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// src/hooks/useToast.ts
|
||||
// Hook pour afficher des notifications toast
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timerRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'success', score?: number) => {
|
||||
const id = Date.now().toString(36);
|
||||
const toast: Toast = { id, message, type, score };
|
||||
|
||||
setToasts(prev => [...prev, toast]);
|
||||
|
||||
// Suppression automatique après 3s
|
||||
const timer = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
timerRef.current.delete(id);
|
||||
}, 3000);
|
||||
timerRef.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
const timer = timerRef.current.get(id);
|
||||
if (timer) { clearTimeout(timer); timerRef.current.delete(id); }
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast, dismissToast };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Masquer la scrollbar mais garder le scroll */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Input color styling */
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Sélection datetime */
|
||||
::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// src/main.tsx
|
||||
// Point d'entrée de l'application React
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
// src/offline/queue.ts
|
||||
// Gestion de la queue offline via IndexedDB
|
||||
// Les saisies créées sans connexion sont stockées ici et synchronisées dès que possible
|
||||
|
||||
import { openDB, IDBPDatabase } from 'idb';
|
||||
import type { OfflineSaisie, SaisieFormData } from '../types';
|
||||
|
||||
const DB_NAME = 'equitask-offline';
|
||||
const STORE_NAME = 'saisies-queue';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbInstance: IDBPDatabase | null = null;
|
||||
|
||||
/** Ouvre la base IndexedDB */
|
||||
async function getDb(): Promise<IDBPDatabase> {
|
||||
if (dbInstance) return dbInstance;
|
||||
dbInstance = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'offline_id' });
|
||||
store.createIndex('created_at', 'created_at');
|
||||
}
|
||||
},
|
||||
});
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/** Génère un UUID simple */
|
||||
function uuid(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
/** Ajoute une saisie à la queue offline */
|
||||
export async function enqueue(data: SaisieFormData): Promise<string> {
|
||||
const db = await getDb();
|
||||
const offline_id = uuid();
|
||||
await db.add(STORE_NAME, { ...data, offline_id, created_at: Date.now() } as OfflineSaisie);
|
||||
return offline_id;
|
||||
}
|
||||
|
||||
/** Récupère toutes les saisies en attente */
|
||||
export async function getQueue(): Promise<OfflineSaisie[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll(STORE_NAME);
|
||||
}
|
||||
|
||||
/** Supprime une saisie de la queue (après sync réussie) */
|
||||
export async function dequeue(offline_id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete(STORE_NAME, offline_id);
|
||||
}
|
||||
|
||||
/** Vide entièrement la queue */
|
||||
export async function clearQueue(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear(STORE_NAME);
|
||||
}
|
||||
|
||||
/** Compte les saisies en attente */
|
||||
export async function countQueue(): Promise<number> {
|
||||
const db = await getDb();
|
||||
return db.count(STORE_NAME);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// src/pages/Dashboard.tsx
|
||||
// Dashboard de visualisation de la répartition des tâches
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, AreaChart, Area, Legend,
|
||||
} from 'recharts';
|
||||
import { format, subDays, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { dashboardApi, categoriesApi, membresApi, saisiesApi } from '../api';
|
||||
import type { PeriodSelection, PeriodKey, Saisie } from '../types';
|
||||
|
||||
// ─── Sélecteur de période ────────────────────────────────────────────────────
|
||||
function getPeriod(key: PeriodKey, custom?: { debut: string; fin: string }): PeriodSelection {
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => format(d, 'yyyy-MM-dd');
|
||||
switch (key) {
|
||||
case 'semaine': return { key, debut: fmt(startOfWeek(today, { weekStartsOn: 1 })), fin: fmt(endOfWeek(today, { weekStartsOn: 1 })) };
|
||||
case 'mois': return { key, debut: fmt(startOfMonth(today)), fin: fmt(endOfMonth(today)) };
|
||||
case '7jours': return { key, debut: fmt(subDays(today, 6)), fin: fmt(today) };
|
||||
case '30jours': return { key, debut: fmt(subDays(today, 29)), fin: fmt(today) };
|
||||
case 'personnalise': return { key, debut: custom?.debut || fmt(subDays(today, 29)), fin: custom?.fin || fmt(today) };
|
||||
default: return { key: '30jours', debut: fmt(subDays(today, 29)), fin: fmt(today) };
|
||||
}
|
||||
}
|
||||
|
||||
const PERIOD_LABELS: Record<PeriodKey, string> = {
|
||||
semaine: 'Cette semaine',
|
||||
mois: 'Ce mois',
|
||||
'7jours': '7 derniers jours',
|
||||
'30jours': '30 derniers jours',
|
||||
personnalise: 'Personnalisé',
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>('30jours');
|
||||
const [customDebut, setCustomDebut] = useState('');
|
||||
const [customFin, setCustomFin] = useState('');
|
||||
const [inclureEnfants, setInclureEnfants] = useState(false);
|
||||
const [categorieFiltre, setCategorieFiltre] = useState<number | undefined>();
|
||||
const [histoPage, setHistoPage] = useState(1);
|
||||
|
||||
const period = useMemo(() => getPeriod(periodKey, { debut: customDebut, fin: customFin }), [periodKey, customDebut, customFin]);
|
||||
|
||||
// Charger les stats
|
||||
const { data: stats, isLoading: loadingStats } = useQuery({
|
||||
queryKey: ['dashboard', period.debut, period.fin, inclureEnfants, categorieFiltre],
|
||||
queryFn: () => dashboardApi.stats({ debut: period.debut, fin: period.fin, inclure_enfants: inclureEnfants, categorie_id: categorieFiltre }),
|
||||
});
|
||||
|
||||
// Charger les catégories pour le filtre
|
||||
const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list });
|
||||
|
||||
// Charger l'historique
|
||||
const { data: historiqueData } = useQuery({
|
||||
queryKey: ['saisies-historique', period.debut, period.fin, histoPage, categorieFiltre],
|
||||
queryFn: () => saisiesApi.list({ debut: period.debut, fin: period.fin, categorie_id: categorieFiltre, page: histoPage, limit: 20 }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-6 sm:pt-16">
|
||||
|
||||
{/* ─── Filtres ─── */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
{/* Sélecteur période */}
|
||||
<div className="flex gap-1 bg-slate-800 rounded-xl p-1 flex-wrap">
|
||||
{(Object.keys(PERIOD_LABELS) as PeriodKey[]).map(k => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setPeriodKey(k)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
periodKey === k ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{PERIOD_LABELS[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dates perso */}
|
||||
{periodKey === 'personnalise' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={customDebut} onChange={e => setCustomDebut(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-white text-xs focus:outline-none" />
|
||||
<span className="text-slate-500 text-xs">→</span>
|
||||
<input type="date" value={customFin} onChange={e => setCustomFin(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-white text-xs focus:outline-none" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filtre catégorie */}
|
||||
<select
|
||||
value={categorieFiltre ?? ''}
|
||||
onChange={e => setCategorieFiltre(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-sm text-white focus:outline-none"
|
||||
>
|
||||
<option value="">Toutes catégories</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.icone} {c.nom}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Toggle enfants */}
|
||||
<label className="flex items-center gap-2 cursor-pointer bg-slate-800 rounded-xl px-3 py-2 text-sm text-slate-400">
|
||||
<input type="checkbox" checked={inclureEnfants} onChange={e => setInclureEnfants(e.target.checked)}
|
||||
className="accent-indigo-500" />
|
||||
Inclure enfants
|
||||
</label>
|
||||
|
||||
{/* Export */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button onClick={() => dashboardApi.export('csv')}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white text-xs px-3 py-2 rounded-xl transition-colors">
|
||||
↓ CSV
|
||||
</button>
|
||||
<button onClick={() => dashboardApi.export('json')}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white text-xs px-3 py-2 rounded-xl transition-colors">
|
||||
↓ JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingStats ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="h-48 bg-slate-800 rounded-2xl animate-pulse" />)}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ─── Ligne 1 : Score total + Indicateur équilibre ─── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
{/* Carte scores par membre */}
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||
<h2 className="font-semibold text-white mb-4">Score cumulé</h2>
|
||||
{stats.scores_par_membre.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm text-center py-4">Aucune donnée sur cette période</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.scores_par_membre.map(m => (
|
||||
<div key={m.membre_id}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: m.couleur }} />
|
||||
<span className="text-sm text-white font-medium">{m.nom}</span>
|
||||
<span className="text-xs text-slate-500">{m.role}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-white font-bold">{m.score_total.toLocaleString()} pts</span>
|
||||
<span className="text-xs text-slate-400 ml-2">{m.pourcentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-slate-700 rounded-full h-2.5">
|
||||
<div
|
||||
className="h-2.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${m.pourcentage}%`, backgroundColor: m.couleur }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indicateur d'équilibre */}
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||
<h2 className="font-semibold text-white mb-4">Équilibre du couple</h2>
|
||||
{!stats.indicateur_equilibre ? (
|
||||
<p className="text-slate-500 text-sm text-center py-4">Nécessite 2 adultes</p>
|
||||
) : (
|
||||
<IndicateurEquilibre indicateur={stats.indicateur_equilibre} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Répartition par catégorie ─── */}
|
||||
{stats.scores_par_categorie.length > 0 && (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||
<h2 className="font-semibold text-white mb-4">Répartition par catégorie</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={stats.scores_par_categorie.map(cat => ({
|
||||
name: `${cat.icone} ${cat.nom}`,
|
||||
...Object.fromEntries(cat.scores_membres.map(m => [m.nom, m.score])),
|
||||
}))}
|
||||
margin={{ top: 5, right: 10, left: 0, bottom: 60 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} angle={-35} textAnchor="end" />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Legend wrapperStyle={{ paddingTop: 8, color: '#94a3b8' }} />
|
||||
{stats.scores_par_membre.map(m => (
|
||||
<Bar key={m.membre_id} dataKey={m.nom} fill={m.couleur} radius={[4, 4, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Évolution temporelle ─── */}
|
||||
{stats.evolution_temporelle.length > 0 && (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||
<h2 className="font-semibold text-white mb-4">Évolution sur la période</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={stats.evolution_temporelle.map(pt => ({
|
||||
date: format(new Date(pt.date), 'd MMM', { locale: fr }),
|
||||
...Object.fromEntries(pt.scores.map(s => [s.nom, s.score])),
|
||||
}))}
|
||||
margin={{ top: 5, right: 10, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Legend wrapperStyle={{ color: '#94a3b8' }} />
|
||||
{stats.scores_par_membre.map((m, i) => (
|
||||
<Area
|
||||
key={m.membre_id}
|
||||
type="monotone"
|
||||
dataKey={m.nom}
|
||||
stroke={m.couleur}
|
||||
fill={m.couleur}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Tableau historique ─── */}
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||
<h2 className="font-semibold text-white mb-4">Historique des saisies</h2>
|
||||
<TableauHistorique
|
||||
saisies={historiqueData?.saisies || []}
|
||||
total={historiqueData?.total || 0}
|
||||
page={histoPage}
|
||||
limit={20}
|
||||
onPageChange={setHistoPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-slate-500">
|
||||
<p className="text-lg">Aucune donnée disponible</p>
|
||||
<p className="text-sm mt-1">Commencez par enregistrer des tâches dans l'onglet Saisie</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Indicateur d'équilibre ─────────────────────────────────────────────────
|
||||
function IndicateurEquilibre({ indicateur }: { indicateur: NonNullable<import('../types').DashboardStats['indicateur_equilibre']> }) {
|
||||
const couleurs = { vert: '#22c55e', orange: '#f97316', rouge: '#ef4444' };
|
||||
const labels = { vert: '🟢 Équilibré', orange: '🟡 Léger déséquilibre', rouge: '🔴 Déséquilibre important' };
|
||||
const color = couleurs[indicateur.statut];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Statut */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{labels[indicateur.statut]}</span>
|
||||
<span className="font-bold text-xl" style={{ color }}>{indicateur.ecart_pct}% d'écart</span>
|
||||
</div>
|
||||
|
||||
{/* Barre */}
|
||||
{indicateur.adulte1 && indicateur.adulte2 && (
|
||||
<>
|
||||
<div className="flex rounded-full overflow-hidden h-6">
|
||||
<div
|
||||
className="flex items-center justify-center text-xs font-bold text-white transition-all"
|
||||
style={{ width: `${indicateur.adulte1.pourcentage}%`, backgroundColor: indicateur.adulte1.couleur }}
|
||||
>
|
||||
{indicateur.adulte1.pourcentage > 15 && `${indicateur.adulte1.pourcentage}%`}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center text-xs font-bold text-white transition-all"
|
||||
style={{ width: `${indicateur.adulte2.pourcentage}%`, backgroundColor: indicateur.adulte2.couleur }}
|
||||
>
|
||||
{indicateur.adulte2.pourcentage > 15 && `${indicateur.adulte2.pourcentage}%`}
|
||||
</div>
|
||||
</div>
|
||||
{/* Légende */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: indicateur.adulte1.couleur }} />
|
||||
<span className="text-slate-300">{indicateur.adulte1.nom}</span>
|
||||
<span className="text-slate-500">{indicateur.adulte1.score.toLocaleString()} pts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500">{indicateur.adulte2.score.toLocaleString()} pts</span>
|
||||
<span className="text-slate-300">{indicateur.adulte2.nom}</span>
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: indicateur.adulte2.couleur }} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tableau historique ──────────────────────────────────────────────────────
|
||||
function TableauHistorique({
|
||||
saisies, total, page, limit, onPageChange,
|
||||
}: {
|
||||
saisies: Saisie[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
onPageChange: (p: number) => void;
|
||||
}) {
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
if (saisies.length === 0) {
|
||||
return <p className="text-slate-500 text-sm text-center py-6">Aucune saisie sur cette période</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Table scroll */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
{['Date', 'Membre', 'Tâche', 'Catégorie', 'Durée', 'Péni.', 'Score', 'Notes', ''].map(h => (
|
||||
<th key={h} className="text-left py-2 px-2 text-xs text-slate-400 font-medium whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{saisies.map(s => (
|
||||
<tr key={s.id} className="border-b border-slate-800 hover:bg-slate-800/50">
|
||||
<td className="py-2 px-2 text-slate-400 whitespace-nowrap text-xs">
|
||||
{s.date_heure ? format(new Date(s.date_heure), 'd MMM HH:mm', { locale: fr }) : '—'}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: s.membre_couleur || '#6b7280' }} />
|
||||
<span className="text-white font-medium whitespace-nowrap">{s.membre_nom}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-slate-300 max-w-32 truncate" title={s.tache_nom || s.nom_tache_oneshot || ''}>
|
||||
{s.tache_nom || s.nom_tache_oneshot}
|
||||
{s.nom_tache_oneshot && <span className="ml-1 text-xs text-amber-500">ponct.</span>}
|
||||
</td>
|
||||
<td className="py-2 px-2 whitespace-nowrap">
|
||||
<span className="text-xs">{s.categorie_icone} {s.categorie_nom}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-slate-400 whitespace-nowrap">
|
||||
{s.duree_reelle_min || '—'} min
|
||||
</td>
|
||||
<td className="py-2 px-2 text-center">
|
||||
<span className="w-6 h-6 rounded bg-slate-700 text-xs font-bold text-slate-300 inline-flex items-center justify-center">
|
||||
{s.coefficient_penibilite}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className="font-bold text-indigo-400">{s.score_final}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-slate-500 text-xs max-w-24 truncate" title={s.notes || ''}>
|
||||
{s.notes}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<SaisieActions saisieId={s.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-slate-700">
|
||||
<span className="text-xs text-slate-500">{total} saisies au total</span>
|
||||
<div className="flex gap-1">
|
||||
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)}
|
||||
className="px-3 py-1.5 text-xs bg-slate-800 hover:bg-slate-700 disabled:opacity-40 rounded-lg transition-colors">
|
||||
←
|
||||
</button>
|
||||
<span className="px-3 py-1.5 text-xs text-slate-400">{page} / {totalPages}</span>
|
||||
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}
|
||||
className="px-3 py-1.5 text-xs bg-slate-800 hover:bg-slate-700 disabled:opacity-40 rounded-lg transition-colors">
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaisieActions({ saisieId }: { saisieId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Supprimer cette saisie ?')) return;
|
||||
await saisiesApi.delete(saisieId);
|
||||
queryClient.invalidateQueries({ queryKey: ['saisies-historique'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
};
|
||||
return (
|
||||
<button onClick={handleDelete} className="text-slate-600 hover:text-red-400 transition-colors text-xs px-1">
|
||||
🗑
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// src/pages/Parametres.tsx
|
||||
// Page de configuration : membres, catégories, tâches récurrentes, export
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { membresApi, categoriesApi, tachesApi, dashboardApi } from '../api';
|
||||
import { PenibiliteSelector } from '../components/ui/PenibiliteSelector';
|
||||
import type { Membre, Categorie, TacheRecurrente } from '../types';
|
||||
|
||||
type Onglet = 'membres' | 'categories' | 'taches' | 'export';
|
||||
|
||||
export function Parametres() {
|
||||
const [onglet, setOnglet] = useState<Onglet>('membres');
|
||||
|
||||
const ONGLETS: { id: Onglet; label: string; icon: string }[] = [
|
||||
{ id: 'membres', label: 'Membres', icon: '👥' },
|
||||
{ id: 'categories', label: 'Catégories', icon: '📂' },
|
||||
{ id: 'taches', label: 'Tâches', icon: '📋' },
|
||||
{ id: 'export', label: 'Export', icon: '↓' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-6 sm:pt-20">
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Paramètres</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-slate-800 rounded-xl p-1 mb-6">
|
||||
{ONGLETS.map(o => (
|
||||
<button
|
||||
key={o.id}
|
||||
onClick={() => setOnglet(o.id)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
onglet === o.id ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="hidden sm:block">{o.icon}</span>
|
||||
<span>{o.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onglet === 'membres' && <GestionMembres />}
|
||||
{onglet === 'categories' && <GestionCategories />}
|
||||
{onglet === 'taches' && <GestionTaches />}
|
||||
{onglet === 'export' && <ExportSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Gestion des membres ──────────────────────────────────────────────────────
|
||||
function GestionMembres() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: membres = [] } = useQuery({ queryKey: ['membres'], queryFn: membresApi.list });
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ nom: '', role: 'adulte' as 'adulte' | 'enfant', couleur: '#3b82f6' });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { id?: number; nom: string; role: string; couleur: string }) =>
|
||||
data.id ? membresApi.update(data.id, data) : membresApi.create({ ...data, actif: 1, ordre: membres.length }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['membres'] }); setShowAdd(false); setEditId(null); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: membresApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['membres'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{membres.map(m => (
|
||||
<div key={m.id} className="bg-slate-900 border border-slate-700 rounded-xl p-4 flex items-center gap-3">
|
||||
{editId === m.id ? (
|
||||
<MemberForm
|
||||
initial={{ nom: m.nom, role: m.role, couleur: m.couleur }}
|
||||
onSave={(data) => mutation.mutate({ id: m.id, ...data })}
|
||||
onCancel={() => setEditId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-lg flex-shrink-0"
|
||||
style={{ backgroundColor: m.couleur }}>
|
||||
{m.nom[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white">{m.nom}</p>
|
||||
<p className="text-xs text-slate-400 capitalize">{m.role}</p>
|
||||
</div>
|
||||
<button onClick={() => setEditId(m.id)} className="text-slate-500 hover:text-white text-sm px-2 py-1 rounded-lg hover:bg-slate-700 transition-colors">✏️</button>
|
||||
<button onClick={() => { if (confirm(`Désactiver ${m.nom} ?`)) deleteMutation.mutate(m.id); }}
|
||||
className="text-slate-500 hover:text-red-400 text-sm px-2 py-1 rounded-lg hover:bg-slate-700 transition-colors">🗑</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showAdd ? (
|
||||
<div className="bg-slate-900 border border-indigo-700 rounded-xl p-4">
|
||||
<MemberForm
|
||||
initial={{ nom: '', role: 'adulte', couleur: '#3b82f6' }}
|
||||
onSave={(data) => mutation.mutate(data)}
|
||||
onCancel={() => setShowAdd(false)}
|
||||
/>
|
||||
</div>
|
||||
) : membres.length < 7 ? (
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="w-full py-3 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors">
|
||||
+ Ajouter un membre
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberForm({ initial, onSave, onCancel }: {
|
||||
initial: { nom: string; role: string; couleur: string };
|
||||
onSave: (data: { nom: string; role: 'adulte' | 'enfant'; couleur: string }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [nom, setNom] = useState(initial.nom);
|
||||
const [role, setRole] = useState<'adulte' | 'enfant'>(initial.role as 'adulte' | 'enfant');
|
||||
const [couleur, setCouleur] = useState(initial.couleur);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-center w-full">
|
||||
<input type="color" value={couleur} onChange={e => setCouleur(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg cursor-pointer" />
|
||||
<input value={nom} onChange={e => setNom(e.target.value)} placeholder="Prénom"
|
||||
className="flex-1 min-w-32 bg-slate-800 rounded-lg px-3 py-2 text-white text-sm border border-slate-600 focus:outline-none" />
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'adulte' | 'enfant')}
|
||||
className="bg-slate-800 text-slate-300 text-sm rounded-lg px-2 py-2 border border-slate-600 focus:outline-none">
|
||||
<option value="adulte">Adulte</option>
|
||||
<option value="enfant">Enfant</option>
|
||||
</select>
|
||||
<button onClick={() => nom.trim() && onSave({ nom: nom.trim(), role, couleur })}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors">✓</button>
|
||||
<button onClick={onCancel} className="bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm px-3 py-2 rounded-lg transition-colors">✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Gestion des catégories ───────────────────────────────────────────────────
|
||||
function GestionCategories() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list });
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { id?: number; nom: string; icone: string; couleur: string }) =>
|
||||
data.id ? categoriesApi.update(data.id, data) : categoriesApi.create({ ...data, ordre: categories.length, actif: 1 }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); setShowAdd(false); setEditId(null); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: categoriesApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="bg-slate-900 border border-slate-700 rounded-xl p-3 flex items-center gap-3">
|
||||
{editId === cat.id ? (
|
||||
<CatForm
|
||||
initial={{ nom: cat.nom, icone: cat.icone, couleur: cat.couleur }}
|
||||
onSave={(data) => mutation.mutate({ id: cat.id, ...data })}
|
||||
onCancel={() => setEditId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-2xl">{cat.icone}</span>
|
||||
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: cat.couleur }} />
|
||||
<span className="flex-1 text-white font-medium">{cat.nom}</span>
|
||||
<button onClick={() => setEditId(cat.id)} className="text-slate-500 hover:text-white text-sm px-2 py-1 rounded hover:bg-slate-700 transition-colors">✏️</button>
|
||||
<button onClick={() => { if (confirm(`Désactiver "${cat.nom}" ?`)) deleteMutation.mutate(cat.id); }}
|
||||
className="text-slate-500 hover:text-red-400 text-sm px-2 py-1 rounded hover:bg-slate-700 transition-colors">🗑</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showAdd ? (
|
||||
<div className="bg-slate-900 border border-indigo-700 rounded-xl p-4">
|
||||
<CatForm initial={{ nom: '', icone: '📦', couleur: '#6b7280' }}
|
||||
onSave={(data) => mutation.mutate(data)}
|
||||
onCancel={() => setShowAdd(false)} />
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="w-full py-3 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors">
|
||||
+ Ajouter une catégorie
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatForm({ initial, onSave, onCancel }: {
|
||||
initial: { nom: string; icone: string; couleur: string };
|
||||
onSave: (data: { nom: string; icone: string; couleur: string }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [nom, setNom] = useState(initial.nom);
|
||||
const [icone, setIcone] = useState(initial.icone);
|
||||
const [couleur, setCouleur] = useState(initial.couleur);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input value={icone} onChange={e => setIcone(e.target.value)} placeholder="📦"
|
||||
className="w-12 bg-slate-800 rounded-lg px-2 py-2 text-center text-xl border border-slate-600 focus:outline-none" />
|
||||
<input type="color" value={couleur} onChange={e => setCouleur(e.target.value)} className="w-10 h-10 rounded-lg cursor-pointer" />
|
||||
<input value={nom} onChange={e => setNom(e.target.value)} placeholder="Nom de la catégorie"
|
||||
className="flex-1 min-w-32 bg-slate-800 rounded-lg px-3 py-2 text-white text-sm border border-slate-600 focus:outline-none" />
|
||||
<button onClick={() => nom.trim() && onSave({ nom: nom.trim(), icone, couleur })}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors">✓</button>
|
||||
<button onClick={onCancel} className="bg-slate-700 text-slate-300 text-sm px-3 py-2 rounded-lg transition-colors">✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Gestion des tâches ───────────────────────────────────────────────────────
|
||||
function GestionTaches() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: taches = [] } = useQuery({ queryKey: ['taches'], queryFn: tachesApi.list });
|
||||
const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list });
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [filtreCategorie, setFiltreCategorie] = useState<number | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { id?: number; nom: string; categorie_id: number; duree_moyenne_min: number; coefficient_penibilite: number }) =>
|
||||
data.id
|
||||
? tachesApi.update(data.id, data)
|
||||
: tachesApi.create({ ...data, actif: 1 }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['taches'] }); setShowAdd(false); setEditId(null); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: tachesApi.delete,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['taches'] }),
|
||||
});
|
||||
|
||||
const tachesFiltrees = filtreCategorie ? taches.filter(t => t.categorie_id === filtreCategorie) : taches;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filtre catégorie */}
|
||||
<div className="flex gap-1 flex-wrap mb-4">
|
||||
<button onClick={() => setFiltreCategorie(null)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${!filtreCategorie ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-400'}`}>
|
||||
Toutes
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button key={cat.id} onClick={() => setFiltreCategorie(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${filtreCategorie === cat.id ? 'text-white' : 'bg-slate-800 text-slate-400'}`}
|
||||
style={filtreCategorie === cat.id ? { backgroundColor: cat.couleur } : {}}>
|
||||
{cat.icone} {cat.nom}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{tachesFiltrees.map(tache => (
|
||||
<div key={tache.id} className="bg-slate-900 border border-slate-700 rounded-xl p-3">
|
||||
{editId === tache.id ? (
|
||||
<TacheForm
|
||||
initial={{ nom: tache.nom, categorie_id: tache.categorie_id, duree: tache.duree_moyenne_min, coef: tache.coefficient_penibilite }}
|
||||
categories={categories}
|
||||
onSave={(data) => mutation.mutate({ id: tache.id, ...data })}
|
||||
onCancel={() => setEditId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="text-xs text-slate-500">
|
||||
{tache.categorie_icone} {tache.categorie_nom}
|
||||
</span>
|
||||
<p className="text-white text-sm font-medium">{tache.nom}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">{tache.duree_moyenne_min} min</span>
|
||||
<span className="text-xs text-slate-400">x{tache.coefficient_penibilite}</span>
|
||||
<span className="text-xs font-bold text-indigo-400 bg-indigo-900/40 px-2 py-0.5 rounded-full">
|
||||
{tache.score_calcule} pts
|
||||
</span>
|
||||
<button onClick={() => setEditId(tache.id)} className="text-slate-500 hover:text-white text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition-colors">✏️</button>
|
||||
<button onClick={() => { if (confirm(`Archiver "${tache.nom}" ?`)) deleteMutation.mutate(tache.id); }}
|
||||
className="text-slate-500 hover:text-red-400 text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition-colors">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showAdd ? (
|
||||
<div className="bg-slate-900 border border-indigo-700 rounded-xl p-4">
|
||||
<TacheForm
|
||||
initial={{ nom: '', categorie_id: categories[0]?.id || 0, duree: 30, coef: 2 }}
|
||||
categories={categories}
|
||||
onSave={(data) => mutation.mutate(data)}
|
||||
onCancel={() => setShowAdd(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="w-full py-3 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors">
|
||||
+ Ajouter une tâche
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TacheForm({ initial, categories, onSave, onCancel }: {
|
||||
initial: { nom: string; categorie_id: number; duree: number; coef: number };
|
||||
categories: Categorie[];
|
||||
onSave: (data: { nom: string; categorie_id: number; duree_moyenne_min: number; coefficient_penibilite: number }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [nom, setNom] = useState(initial.nom);
|
||||
const [categorieId, setCategorieId] = useState(initial.categorie_id);
|
||||
const [duree, setDuree] = useState(initial.duree);
|
||||
const [coef, setCoef] = useState(initial.coef);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input value={nom} onChange={e => setNom(e.target.value)} placeholder="Nom de la tâche *"
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2.5 text-white text-sm focus:outline-none focus:border-indigo-500" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Catégorie</label>
|
||||
<select value={categorieId} onChange={e => setCategorieId(parseInt(e.target.value))}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-white text-sm focus:outline-none">
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.icone} {c.nom}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Durée (min)</label>
|
||||
<input type="number" min={1} max={480} value={duree} onChange={e => setDuree(parseInt(e.target.value) || 1)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-white text-sm focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Pénibilité — Score : {duree * coef} pts</label>
|
||||
<PenibiliteSelector value={coef} onChange={setCoef} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => nom.trim() && onSave({ nom: nom.trim(), categorie_id: categorieId, duree_moyenne_min: duree, coefficient_penibilite: coef })}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm py-2.5 rounded-xl transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
<button onClick={onCancel} className="bg-slate-700 text-slate-300 text-sm px-4 py-2.5 rounded-xl transition-colors">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
function ExportSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6">
|
||||
<h2 className="font-semibold text-white mb-2">Exporter les données</h2>
|
||||
<p className="text-slate-400 text-sm mb-5">Téléchargez toutes les saisies de votre foyer.</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => dashboardApi.export('csv')}
|
||||
className="flex-1 bg-emerald-700 hover:bg-emerald-600 text-white font-medium py-3 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<span>↓</span> Export CSV
|
||||
</button>
|
||||
<button onClick={() => dashboardApi.export('json')}
|
||||
className="flex-1 bg-indigo-700 hover:bg-indigo-600 text-white font-medium py-3 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<span>↓</span> Export JSON
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-3 text-center">
|
||||
CSV recommandé pour Excel / Sheets · JSON pour usage programmatique
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// src/pages/Saisie.tsx
|
||||
// Page principale de saisie des tâches effectuées
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { tachesApi, saisiesApi } from '../api';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { enqueue, countQueue } from '../offline/queue';
|
||||
import { ToastContainer } from '../components/ui/Toast';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { PenibiliteSelector } from '../components/ui/PenibiliteSelector';
|
||||
import { ScoreBadge } from '../components/ui/ScoreBadge';
|
||||
import type { TacheRecurrente, SaisieFormData } from '../types';
|
||||
|
||||
export function Saisie() {
|
||||
const navigate = useNavigate();
|
||||
const { membreActif, isOnline, setQueueCount } = useApp();
|
||||
const { toasts, showToast, dismissToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Modal états
|
||||
const [modalTache, setModalTache] = useState<TacheRecurrente | null>(null);
|
||||
const [modalOneshot, setModalOneshot] = useState(false);
|
||||
const [categorieActive, setCategorieActive] = useState<number | null>(null);
|
||||
|
||||
// Redirection si pas de membre actif
|
||||
React.useEffect(() => {
|
||||
if (!membreActif) navigate('/');
|
||||
}, [membreActif, navigate]);
|
||||
|
||||
// Charger les tâches
|
||||
const { data: taches = [], isLoading } = useQuery({
|
||||
queryKey: ['taches'],
|
||||
queryFn: tachesApi.list,
|
||||
});
|
||||
|
||||
// Grouper par catégorie
|
||||
const tachesParCategorie = useMemo(() => {
|
||||
const map = new Map<number, { categorie_id: number; nom: string; icone: string; couleur: string; taches: TacheRecurrente[] }>();
|
||||
for (const t of taches) {
|
||||
if (!map.has(t.categorie_id)) {
|
||||
map.set(t.categorie_id, {
|
||||
categorie_id: t.categorie_id,
|
||||
nom: t.categorie_nom || '',
|
||||
icone: t.categorie_icone || '📦',
|
||||
couleur: t.categorie_couleur || '#6b7280',
|
||||
taches: [],
|
||||
});
|
||||
}
|
||||
map.get(t.categorie_id)!.taches.push(t);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [taches]);
|
||||
|
||||
// Catégorie active par défaut
|
||||
React.useEffect(() => {
|
||||
if (tachesParCategorie.length > 0 && categorieActive === null) {
|
||||
setCategorieActive(tachesParCategorie[0].categorie_id);
|
||||
}
|
||||
}, [tachesParCategorie, categorieActive]);
|
||||
|
||||
// Mutation création saisie
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: SaisieFormData) => {
|
||||
if (isOnline) return saisiesApi.create(data);
|
||||
return enqueue(data).then(id => {
|
||||
countQueue().then(n => setQueueCount(n));
|
||||
return { id: -1, ...data, cree_le: new Date().toISOString() } as any;
|
||||
});
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['saisies'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
showToast(
|
||||
isOnline ? 'Tâche enregistrée !' : 'Enregistré hors ligne',
|
||||
'success',
|
||||
variables.score_final
|
||||
);
|
||||
},
|
||||
onError: () => showToast('Erreur lors de l\'enregistrement', 'error'),
|
||||
});
|
||||
|
||||
const soumettreSaisie = (data: SaisieFormData) => {
|
||||
mutation.mutate(data);
|
||||
setModalTache(null);
|
||||
setModalOneshot(false);
|
||||
};
|
||||
|
||||
if (!membreActif) return null;
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
|
||||
{/* Header membre */}
|
||||
<div
|
||||
className="px-4 py-4 border-b border-slate-800"
|
||||
style={{ borderTopColor: membreActif.couleur, borderTopWidth: 3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-lg"
|
||||
style={{ backgroundColor: membreActif.couleur }}
|
||||
>
|
||||
{membreActif.nom[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">{membreActif.nom}</p>
|
||||
<p className="text-slate-500 text-xs">{format(new Date(), 'EEEE d MMMM', { locale: fr })}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bouton tâche ponctuelle */}
|
||||
<button
|
||||
onClick={() => setModalOneshot(true)}
|
||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium px-3 py-2 rounded-xl transition-colors"
|
||||
>
|
||||
<span>+</span>
|
||||
<span className="hidden sm:block">Tâche ponctuelle</span>
|
||||
<span className="sm:hidden">Ponctuelle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Tabs catégories */}
|
||||
<div className="flex gap-1 overflow-x-auto py-3 scrollbar-hide -mx-4 px-4">
|
||||
{tachesParCategorie.map(cat => (
|
||||
<button
|
||||
key={cat.categorie_id}
|
||||
onClick={() => setCategorieActive(cat.categorie_id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all flex-shrink-0 ${
|
||||
categorieActive === cat.categorie_id
|
||||
? 'text-white shadow-lg'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
}`}
|
||||
style={categorieActive === cat.categorie_id ? { backgroundColor: cat.couleur } : {}}
|
||||
>
|
||||
<span>{cat.icone}</span>
|
||||
<span>{cat.nom}</span>
|
||||
<span className="text-xs opacity-60">({cat.taches.length})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille de tâches */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-24 bg-slate-800 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
tachesParCategorie
|
||||
.filter(cat => cat.categorie_id === categorieActive)
|
||||
.map(cat => (
|
||||
<div key={cat.categorie_id} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-2">
|
||||
{cat.taches.map(tache => (
|
||||
<TacheCard
|
||||
key={tache.id}
|
||||
tache={tache}
|
||||
couleurCat={cat.couleur}
|
||||
onClick={() => setModalTache(tache)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal confirmation tâche récurrente */}
|
||||
<Modal isOpen={!!modalTache} onClose={() => setModalTache(null)} title="Confirmer la saisie">
|
||||
{modalTache && (
|
||||
<ModalConfirmTache
|
||||
tache={modalTache}
|
||||
membreId={membreActif.id}
|
||||
onConfirm={soumettreSaisie}
|
||||
onClose={() => setModalTache(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal tâche ponctuelle */}
|
||||
<Modal isOpen={modalOneshot} onClose={() => setModalOneshot(false)} title="Tâche ponctuelle">
|
||||
<ModalOneShot
|
||||
membreId={membreActif.id}
|
||||
tachesParCategorie={tachesParCategorie}
|
||||
onConfirm={soumettreSaisie}
|
||||
onClose={() => setModalOneshot(false)}
|
||||
onAddToCatalogue={async (nom, cat_id, duree, coef) => {
|
||||
await tachesApi.create({ nom, categorie_id: cat_id, duree_moyenne_min: duree, coefficient_penibilite: coef, actif: 1 });
|
||||
queryClient.invalidateQueries({ queryKey: ['taches'] });
|
||||
showToast('Ajouté au catalogue !', 'info');
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Carte tâche ─────────────────────────────────────────────────────────────
|
||||
function TacheCard({ tache, couleurCat, onClick }: { tache: TacheRecurrente; couleurCat: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="bg-slate-800 hover:bg-slate-700 active:scale-95 rounded-2xl p-4 text-left transition-all duration-150 flex flex-col gap-2 border border-slate-700 hover:border-slate-500"
|
||||
>
|
||||
<p className="font-medium text-white text-sm leading-tight line-clamp-2">{tache.nom}</p>
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<span className="text-slate-400 text-xs">{tache.duree_moyenne_min} min</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-xs font-bold px-1.5 py-0.5 rounded-md"
|
||||
style={{ backgroundColor: couleurCat + '30', color: couleurCat }}
|
||||
>
|
||||
×{tache.coefficient_penibilite}
|
||||
</span>
|
||||
<span className="text-xs text-indigo-400 font-bold">{tache.score_calcule}pt</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal confirmation tâche récurrente ─────────────────────────────────────
|
||||
function ModalConfirmTache({
|
||||
tache, membreId, onConfirm, onClose,
|
||||
}: {
|
||||
tache: TacheRecurrente;
|
||||
membreId: number;
|
||||
onConfirm: (data: SaisieFormData) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [override, setOverride] = useState(false);
|
||||
const [dureeOverride, setDureeOverride] = useState(tache.duree_moyenne_min);
|
||||
const [coefOverride, setCoefOverride] = useState(tache.coefficient_penibilite);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [dateHeure, setDateHeure] = useState(
|
||||
new Date().toISOString().slice(0, 16)
|
||||
);
|
||||
|
||||
const dureeFinale = override ? dureeOverride : tache.duree_moyenne_min;
|
||||
const coefFinal = override ? coefOverride : tache.coefficient_penibilite;
|
||||
const scoreCalcule = dureeFinale * coefFinal;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm({
|
||||
tache_recurrente_id: tache.id,
|
||||
categorie_id: tache.categorie_id,
|
||||
membre_id: membreId,
|
||||
date_heure: dateHeure.replace('T', ' ') + ':00',
|
||||
duree_reelle_min: override ? dureeOverride : undefined,
|
||||
coefficient_penibilite: coefFinal,
|
||||
score_final: scoreCalcule,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{tache.nom}</h3>
|
||||
|
||||
{/* Score preview */}
|
||||
<div className="flex items-center gap-3 bg-slate-800 rounded-xl p-4 mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-slate-400 text-xs">Durée</p>
|
||||
<p className="text-white font-bold">{dureeFinale} min</p>
|
||||
</div>
|
||||
<span className="text-slate-600">×</span>
|
||||
<div className="text-center">
|
||||
<p className="text-slate-400 text-xs">Pénibilité</p>
|
||||
<p className="text-white font-bold">{coefFinal}</p>
|
||||
</div>
|
||||
<span className="text-slate-600">=</span>
|
||||
<div className="text-center flex-1">
|
||||
<p className="text-slate-400 text-xs">Score</p>
|
||||
<p className="text-indigo-400 font-bold text-xl">{scoreCalcule} pts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date/heure */}
|
||||
<div className="mb-3">
|
||||
<label className="text-slate-400 text-xs mb-1 block">Date et heure</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dateHeure}
|
||||
onChange={e => setDateHeure(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Override optionnel */}
|
||||
<button
|
||||
onClick={() => setOverride(!override)}
|
||||
className="text-slate-400 hover:text-white text-sm underline mb-3 transition-colors"
|
||||
>
|
||||
{override ? '← Utiliser les valeurs par défaut' : '✏️ Modifier durée / pénibilité'}
|
||||
</button>
|
||||
|
||||
{override && (
|
||||
<div className="space-y-3 mb-3 p-3 bg-slate-800 rounded-xl border border-slate-700">
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Durée réelle (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1} max={480}
|
||||
value={dureeOverride}
|
||||
onChange={e => setDureeOverride(parseInt(e.target.value) || 1)}
|
||||
className="w-full bg-slate-700 rounded-lg px-3 py-2 text-white text-sm border border-slate-600 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Pénibilité</label>
|
||||
<PenibiliteSelector value={coefOverride} onChange={setCoefOverride} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Notes (optionnel)…"
|
||||
rows={2}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 py-3 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-xl transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Valider +{scoreCalcule}pts
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal tâche one-shot ─────────────────────────────────────────────────────
|
||||
function ModalOneShot({
|
||||
membreId, tachesParCategorie, onConfirm, onClose, onAddToCatalogue,
|
||||
}: {
|
||||
membreId: number;
|
||||
tachesParCategorie: { categorie_id: number; nom: string; icone: string; taches: TacheRecurrente[] }[];
|
||||
onConfirm: (data: SaisieFormData) => void;
|
||||
onClose: () => void;
|
||||
onAddToCatalogue: (nom: string, cat_id: number, duree: number, coef: number) => Promise<void>;
|
||||
}) {
|
||||
const [nom, setNom] = useState('');
|
||||
const [categorieId, setCategorieId] = useState(tachesParCategorie[0]?.categorie_id || 0);
|
||||
const [duree, setDuree] = useState(30);
|
||||
const [coef, setCoef] = useState(2);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [dateHeure, setDateHeure] = useState(new Date().toISOString().slice(0, 16));
|
||||
const [ajouterCatalogue, setAjouterCatalogue] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const score = duree * coef;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!nom.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (ajouterCatalogue) {
|
||||
await onAddToCatalogue(nom.trim(), categorieId, duree, coef);
|
||||
}
|
||||
onConfirm({
|
||||
nom_tache_oneshot: nom.trim(),
|
||||
categorie_id: categorieId,
|
||||
membre_id: membreId,
|
||||
date_heure: dateHeure.replace('T', ' ') + ':00',
|
||||
duree_reelle_min: duree,
|
||||
coefficient_penibilite: coef,
|
||||
score_final: score,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Nom */}
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Nom de la tâche *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nom}
|
||||
onChange={e => setNom(e.target.value)}
|
||||
placeholder="Ex: Nettoyage garage..."
|
||||
autoFocus
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Catégorie */}
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Catégorie</label>
|
||||
<select
|
||||
value={categorieId}
|
||||
onChange={e => setCategorieId(parseInt(e.target.value))}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2.5 text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{tachesParCategorie.map(cat => (
|
||||
<option key={cat.categorie_id} value={cat.categorie_id}>
|
||||
{cat.icone} {cat.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Durée */}
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Durée (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1} max={480}
|
||||
value={duree}
|
||||
onChange={e => setDuree(parseInt(e.target.value) || 1)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pénibilité */}
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Pénibilité</label>
|
||||
<PenibiliteSelector value={coef} onChange={setCoef} />
|
||||
</div>
|
||||
|
||||
{/* Score preview */}
|
||||
<div className="flex items-center justify-center bg-slate-800 rounded-xl py-3">
|
||||
<ScoreBadge score={score} size="lg" />
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs mb-1 block">Date et heure</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dateHeure}
|
||||
onChange={e => setDateHeure(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Notes (optionnel)…"
|
||||
rows={2}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none"
|
||||
/>
|
||||
|
||||
{/* Ajouter au catalogue */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ajouterCatalogue}
|
||||
onChange={e => setAjouterCatalogue(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-indigo-500"
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">Ajouter au catalogue des tâches récurrentes</span>
|
||||
</label>
|
||||
|
||||
{/* Boutons */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button onClick={onClose} className="flex-1 py-3 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-xl transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!nom.trim() || loading}
|
||||
className="flex-1 py-3 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{loading ? '…' : `Valider +${score}pts`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/pages/SelectionProfil.tsx
|
||||
// Écran de sélection du profil actif (style Netflix)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { membresApi } from '../api';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import type { Membre } from '../types';
|
||||
|
||||
export function SelectionProfil() {
|
||||
const navigate = useNavigate();
|
||||
const { setMembreActif } = useApp();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
const { data: membres = [], isLoading } = useQuery({
|
||||
queryKey: ['membres'],
|
||||
queryFn: membresApi.list,
|
||||
});
|
||||
|
||||
const choisirMembre = async (membre: Membre) => {
|
||||
setSelected(membre.id);
|
||||
// Courte animation avant navigation
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
setMembreActif(membre);
|
||||
navigate('/saisie');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
|
||||
<div className="text-slate-400 animate-pulse text-lg">Chargement…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adultes = membres.filter(m => m.role === 'adulte');
|
||||
const enfants = membres.filter(m => m.role === 'enfant');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 to-slate-900 flex flex-col items-center justify-center p-6">
|
||||
<div className="mb-10 text-center">
|
||||
<div className="text-5xl mb-3">⚖️</div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">EquiTask</h1>
|
||||
<p className="text-slate-400 mt-2">Qui êtes-vous ?</p>
|
||||
</div>
|
||||
|
||||
{/* Adultes */}
|
||||
{adultes.length > 0 && (
|
||||
<div className="w-full max-w-2xl">
|
||||
{enfants.length > 0 && (
|
||||
<p className="text-slate-500 text-xs uppercase tracking-widest mb-4 text-center">Adultes</p>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{adultes.map(membre => (
|
||||
<ProfileCard
|
||||
key={membre.id}
|
||||
membre={membre}
|
||||
isSelected={selected === membre.id}
|
||||
onClick={() => choisirMembre(membre)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enfants */}
|
||||
{enfants.length > 0 && (
|
||||
<div className="w-full max-w-2xl mt-8">
|
||||
<p className="text-slate-500 text-xs uppercase tracking-widest mb-4 text-center">Enfants</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{enfants.map(membre => (
|
||||
<ProfileCard
|
||||
key={membre.id}
|
||||
membre={membre}
|
||||
isSelected={selected === membre.id}
|
||||
onClick={() => choisirMembre(membre)}
|
||||
small
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liens rapides */}
|
||||
<div className="mt-12 flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="text-slate-500 hover:text-slate-300 text-sm transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
📊 Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/parametres')}
|
||||
className="text-slate-500 hover:text-slate-300 text-sm transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
⚙️ Réglages
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Carte profil style Netflix */
|
||||
function ProfileCard({
|
||||
membre, isSelected, onClick, small = false,
|
||||
}: {
|
||||
membre: Membre; isSelected: boolean; onClick: () => void; small?: boolean;
|
||||
}) {
|
||||
const size = small ? 'w-24' : 'w-32';
|
||||
const avatarSize = small ? 'w-24 h-24 text-4xl' : 'w-32 h-32 text-5xl';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex flex-col items-center gap-3 group transition-all duration-200 ${
|
||||
isSelected ? 'scale-110' : 'hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`${avatarSize} rounded-2xl flex items-center justify-center font-bold text-white shadow-xl transition-all duration-200 ${
|
||||
isSelected ? 'ring-4 ring-white scale-105' : 'group-hover:ring-2 group-hover:ring-white/40'
|
||||
}`}
|
||||
style={{ backgroundColor: membre.couleur }}
|
||||
>
|
||||
<span>{membre.nom[0].toUpperCase()}</span>
|
||||
</div>
|
||||
{/* Nom */}
|
||||
<span className={`font-semibold text-center transition-colors ${
|
||||
isSelected ? 'text-white' : 'text-slate-400 group-hover:text-white'
|
||||
} ${small ? 'text-sm' : 'text-base'}`}>
|
||||
{membre.nom}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// src/pages/Setup.tsx
|
||||
// Page de premier lancement : configuration du foyer et ajout des membres
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { foyerApi, membresApi } from '../api';
|
||||
|
||||
const COULEURS_PRESET = [
|
||||
'#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899',
|
||||
];
|
||||
|
||||
interface MembreTemp {
|
||||
nom: string;
|
||||
role: 'adulte' | 'enfant';
|
||||
couleur: string;
|
||||
}
|
||||
|
||||
export function Setup() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [etape, setEtape] = useState<'foyer' | 'membres'>('foyer');
|
||||
const [nomFoyer, setNomFoyer] = useState('');
|
||||
const [membres, setMembres] = useState<MembreTemp[]>([
|
||||
{ nom: '', role: 'adulte', couleur: '#3b82f6' },
|
||||
{ nom: '', role: 'adulte', couleur: '#ec4899' },
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [erreur, setErreur] = useState('');
|
||||
|
||||
const ajouterMembre = () => {
|
||||
if (membres.length >= 7) return;
|
||||
const couleur = COULEURS_PRESET[membres.length % COULEURS_PRESET.length];
|
||||
setMembres([...membres, { nom: '', role: 'enfant', couleur }]);
|
||||
};
|
||||
|
||||
const supprimerMembre = (i: number) => {
|
||||
if (membres.length <= 1) return;
|
||||
setMembres(membres.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
const validerFoyer = () => {
|
||||
if (!nomFoyer.trim()) { setErreur('Entrez un nom pour le foyer'); return; }
|
||||
setErreur('');
|
||||
setEtape('membres');
|
||||
};
|
||||
|
||||
const validerMembres = async () => {
|
||||
const membresFiltres = membres.filter(m => m.nom.trim());
|
||||
if (membresFiltres.length === 0) { setErreur('Ajoutez au moins un membre'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setErreur('');
|
||||
try {
|
||||
await foyerApi.create(nomFoyer.trim());
|
||||
for (let i = 0; i < membresFiltres.length; i++) {
|
||||
const m = membresFiltres[i];
|
||||
await membresApi.create({ nom: m.nom.trim(), role: m.role, couleur: m.couleur, actif: 1, ordre: i });
|
||||
}
|
||||
await queryClient.invalidateQueries();
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setErreur(err instanceof Error ? err.message : 'Erreur lors de la création');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-950 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-3">⚖️</div>
|
||||
<h1 className="text-3xl font-bold text-white">EquiTask</h1>
|
||||
<p className="text-slate-400 mt-2">Mesurez la répartition des tâches dans votre foyer</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 rounded-2xl p-6 border border-slate-700 shadow-2xl">
|
||||
{etape === 'foyer' ? (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-1">Créer votre foyer</h2>
|
||||
<p className="text-slate-400 text-sm mb-6">Comment s'appelle votre foyer ?</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={nomFoyer}
|
||||
onChange={e => setNomFoyer(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && validerFoyer()}
|
||||
placeholder="Ex: Famille Martin, Notre maison…"
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 text-lg"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{erreur && <p className="text-red-400 text-sm mt-2">{erreur}</p>}
|
||||
|
||||
<button
|
||||
onClick={validerFoyer}
|
||||
className="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-3 rounded-xl transition-colors"
|
||||
>
|
||||
Continuer →
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button onClick={() => setEtape('foyer')} className="text-slate-400 hover:text-white">←</button>
|
||||
<h2 className="text-xl font-semibold text-white">Ajouter les membres</h2>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm mb-5">Qui vit dans <strong className="text-white">{nomFoyer}</strong> ?</p>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
{membres.map((m, i) => (
|
||||
<div key={i} className="flex gap-2 items-center bg-slate-800 rounded-xl p-3">
|
||||
{/* Sélecteur couleur */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={m.couleur}
|
||||
onChange={e => setMembres(prev => prev.map((x, j) => j === i ? { ...x, couleur: e.target.value } : x))}
|
||||
className="w-10 h-10 rounded-lg cursor-pointer border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
{/* Nom */}
|
||||
<input
|
||||
type="text"
|
||||
value={m.nom}
|
||||
onChange={e => setMembres(prev => prev.map((x, j) => j === i ? { ...x, nom: e.target.value } : x))}
|
||||
placeholder={`Prénom ${i + 1}`}
|
||||
className="flex-1 bg-transparent text-white placeholder-slate-500 focus:outline-none"
|
||||
/>
|
||||
{/* Rôle */}
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={e => setMembres(prev => prev.map((x, j) => j === i ? { ...x, role: e.target.value as 'adulte' | 'enfant' } : x))}
|
||||
className="bg-slate-700 text-slate-300 text-xs rounded-lg px-2 py-1 border-0 focus:outline-none"
|
||||
>
|
||||
<option value="adulte">Adulte</option>
|
||||
<option value="enfant">Enfant</option>
|
||||
</select>
|
||||
{/* Supprimer */}
|
||||
{membres.length > 1 && (
|
||||
<button onClick={() => supprimerMembre(i)} className="text-slate-500 hover:text-red-400 text-lg">×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{membres.length < 7 && (
|
||||
<button
|
||||
onClick={ajouterMembre}
|
||||
className="w-full py-2 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors mb-4"
|
||||
>
|
||||
+ Ajouter un membre
|
||||
</button>
|
||||
)}
|
||||
|
||||
{erreur && <p className="text-red-400 text-sm mb-3">{erreur}</p>}
|
||||
|
||||
<button
|
||||
onClick={validerMembres}
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors"
|
||||
>
|
||||
{loading ? 'Création…' : 'Créer le foyer 🎉'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// src/types/index.ts
|
||||
// Types TypeScript partagés pour EquiTask
|
||||
|
||||
export interface Foyer {
|
||||
id: number;
|
||||
nom: string;
|
||||
cree_le: string;
|
||||
}
|
||||
|
||||
export interface Membre {
|
||||
id: number;
|
||||
nom: string;
|
||||
role: 'adulte' | 'enfant';
|
||||
couleur: string;
|
||||
actif: number;
|
||||
ordre: number;
|
||||
cree_le?: string;
|
||||
}
|
||||
|
||||
export interface Categorie {
|
||||
id: number;
|
||||
nom: string;
|
||||
icone: string;
|
||||
couleur: string;
|
||||
ordre: number;
|
||||
actif: number;
|
||||
}
|
||||
|
||||
export interface TacheRecurrente {
|
||||
id: number;
|
||||
nom: string;
|
||||
categorie_id: number;
|
||||
duree_moyenne_min: number;
|
||||
coefficient_penibilite: number;
|
||||
score_calcule: number;
|
||||
actif: number;
|
||||
// Jointures optionnelles
|
||||
categorie_nom?: string;
|
||||
categorie_icone?: string;
|
||||
categorie_couleur?: string;
|
||||
}
|
||||
|
||||
export interface Saisie {
|
||||
id: number;
|
||||
tache_recurrente_id: number | null;
|
||||
nom_tache_oneshot: string | null;
|
||||
categorie_id: number;
|
||||
membre_id: number;
|
||||
date_heure: string;
|
||||
duree_reelle_min: number | null;
|
||||
coefficient_penibilite: number;
|
||||
score_final: number;
|
||||
notes: string | null;
|
||||
synced: number;
|
||||
cree_le: string;
|
||||
// Jointures
|
||||
membre_nom?: string;
|
||||
membre_couleur?: string;
|
||||
membre_role?: string;
|
||||
categorie_nom?: string;
|
||||
categorie_icone?: string;
|
||||
categorie_couleur?: string;
|
||||
tache_nom?: string;
|
||||
}
|
||||
|
||||
export interface SaisieFormData {
|
||||
tache_recurrente_id?: number;
|
||||
nom_tache_oneshot?: string;
|
||||
categorie_id: number;
|
||||
membre_id: number;
|
||||
date_heure: string;
|
||||
duree_reelle_min?: number;
|
||||
coefficient_penibilite: number;
|
||||
score_final: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export interface ScoreMembre {
|
||||
membre_id: number;
|
||||
nom: string;
|
||||
couleur: string;
|
||||
role: string;
|
||||
score_total: number;
|
||||
pourcentage: number;
|
||||
nb_saisies: number;
|
||||
}
|
||||
|
||||
export interface ScoreCategorie {
|
||||
categorie_id: number;
|
||||
nom: string;
|
||||
icone: string;
|
||||
couleur: string;
|
||||
scores_membres: { membre_id: number; nom: string; couleur: string; score: number }[];
|
||||
}
|
||||
|
||||
export interface EvolutionPoint {
|
||||
date: string;
|
||||
scores: { membre_id: number; nom: string; couleur: string; score: number }[];
|
||||
}
|
||||
|
||||
export interface IndicateurEquilibre {
|
||||
adulte1: { membre_id: number; nom: string; couleur: string; score: number; pourcentage: number } | null;
|
||||
adulte2: { membre_id: number; nom: string; couleur: string; score: number; pourcentage: number } | null;
|
||||
ecart_pct: number;
|
||||
statut: 'vert' | 'orange' | 'rouge';
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
scores_par_membre: ScoreMembre[];
|
||||
scores_par_categorie: ScoreCategorie[];
|
||||
evolution_temporelle: EvolutionPoint[];
|
||||
indicateur_equilibre: IndicateurEquilibre | null;
|
||||
}
|
||||
|
||||
// Queue offline
|
||||
export interface OfflineSaisie extends SaisieFormData {
|
||||
offline_id: string; // UUID local
|
||||
created_at: number; // timestamp
|
||||
}
|
||||
|
||||
// Périodes du dashboard
|
||||
export type PeriodKey = 'semaine' | 'mois' | '7jours' | '30jours' | 'personnalise';
|
||||
|
||||
export interface PeriodSelection {
|
||||
key: PeriodKey;
|
||||
debut: string; // YYYY-MM-DD
|
||||
fin: string; // YYYY-MM-DD
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'bounce-in': 'bounceIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
|
||||
slideUp: { from: { transform: 'translateY(20px)', opacity: '0' }, to: { transform: 'translateY(0)', opacity: '1' } },
|
||||
bounceIn: { from: { transform: 'scale(0.8)', opacity: '0' }, to: { transform: 'scale(1)', opacity: '1' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'icons/*.png'],
|
||||
manifest: {
|
||||
name: 'EquiTask - Répartition tâches',
|
||||
short_name: 'EquiTask',
|
||||
description: 'Mesurez la répartition des tâches domestiques dans votre foyer',
|
||||
theme_color: '#1e1b4b',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user