deploy: equitask — 2026-04-28 19:51:14

This commit is contained in:
deploy.py
2026-04-28 19:51:14 +02:00
commit 53532f7f59
47 changed files with 4093 additions and 0 deletions
+17
View File
@@ -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>
+33
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1
View File
@@ -0,0 +1 @@
1Y
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+78
View File
@@ -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>
);
}
+36
View File
@@ -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' }),
};
+83
View File
@@ -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');
},
};
+96
View File
@@ -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>
);
}
+39
View File
@@ -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">&times;</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>
);
}
+23
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+61
View File
@@ -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;
}
+58
View File
@@ -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 };
}
+38
View File
@@ -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 };
}
+36
View File
@@ -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);
}
+13
View File
@@ -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>
);
+63
View File
@@ -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);
}
+416
View File
@@ -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>
);
}
+383
View File
@@ -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>
);
}
+490
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+129
View File
@@ -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
}
+22
View File
@@ -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: [],
};
+25
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+51
View File
@@ -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 },
},
},
});