feat(todos): page TodosPage — vue mobile swipeable + vue laptop tableau filtrable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:13:56 +02:00
parent 580aab822a
commit 8aeb45387d
2 changed files with 368 additions and 0 deletions
+2
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/layout/Layout'
import HomePage from './pages/HomePage'
import TodosPage from './pages/TodosPage'
export default function App() {
return (
@@ -8,6 +9,7 @@ export default function App() {
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="todos" element={<TodosPage />} />
</Route>
</Routes>
</BrowserRouter>
+366
View File
@@ -0,0 +1,366 @@
// frontend/src/pages/TodosPage.tsx
import { useState, useEffect, useCallback } from 'react'
import type { Todo, TodoCreate, TodoFilters } from '../api/todos'
import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '../api/todos'
import SwipeableRow from '../components/todos/SwipeableRow'
import TodoForm from '../components/todos/TodoForm'
const DOMAINS = [
'informatique', 'diy', 'electronique', 'domotique',
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
]
const STATUS_LABELS: Record<string, string> = {
pending: 'En cours', done: 'Terminé', cancelled: 'Annulé',
}
const PRIORITY_COLORS: Record<string, string> = {
high: 'var(--err)', medium: 'var(--warn)', low: 'var(--ink-3)',
}
const selectStyle: React.CSSProperties = {
background: 'var(--bg-3)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
padding: '6px 10px',
color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)',
fontSize: 13,
}
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
const load = useCallback(async () => {
setLoading(true)
try {
setTodos(await fetchTodos(filters))
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { void load() }, [load])
async function handleCreate(data: TodoCreate) {
await createTodo(data)
setShowForm(false)
void load()
}
async function handleDone(id: string) {
await updateTodo(id, { status: 'done' })
void load()
}
async function handleDelete(id: string) {
await deleteTodo(id)
void load()
}
async function handlePostpone(id: string, days: 1 | 7) {
await postponeTodo(id, days)
void load()
}
// Grouper par domaine pour la vue mobile
const grouped = DOMAINS.reduce<Record<string, Todo[]>>((acc, d) => {
const items = todos.filter(t => t.domain === d)
if (items.length > 0) acc[d] = items
return acc
}, {})
const sansDomaine = todos.filter(t => !t.domain)
if (sansDomaine.length > 0) grouped['—'] = sansDomaine
return (
<div className="p-4">
{/* En-tête + filtres */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, minWidth: 100 }}>
Tâches
</h1>
<select
style={selectStyle}
value={filters.status ?? 'pending'}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
>
<option value="pending">En cours</option>
<option value="done">Terminé</option>
<option value="cancelled">Annulé</option>
<option value="">Tous</option>
</select>
<select
style={selectStyle}
value={filters.domain ?? ''}
onChange={e => setFilters(f => ({ ...f, domain: e.target.value || undefined }))}
>
<option value="">Tous domaines</option>
{DOMAINS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select
style={selectStyle}
value={filters.priority ?? ''}
onChange={e => setFilters(f => ({ ...f, priority: e.target.value || undefined }))}
>
<option value="">Toutes priorités</option>
<option value="high">Haute</option>
<option value="medium">Moyenne</option>
<option value="low">Basse</option>
</select>
</div>
{/* Formulaire de création */}
{showForm && (
<div className="glass" style={{ padding: 16, borderRadius: 10, marginBottom: 16 }}>
<div className="hidden lg:block">
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} extended />
</div>
<div className="block lg:hidden">
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
</div>
</div>
)}
{loading && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement</p>
)}
{/* Vue mobile — liste groupée par domaine */}
<div className="block lg:hidden">
{!loading && Object.entries(grouped).map(([domain, items]) => (
<div key={domain} style={{ marginBottom: 20 }}>
{/* Entête de groupe avec badge compteur */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{
color: 'var(--accent)', fontFamily: 'var(--font-mono)',
fontSize: 12, textTransform: 'uppercase', letterSpacing: 1,
}}>
{domain}
</span>
<span style={{
background: 'var(--bg-4)', color: 'var(--ink-3)',
fontSize: 11, borderRadius: 999, padding: '1px 7px',
}}>
{items.length}
</span>
</div>
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden' }}>
{items.map((todo, idx) => (
<SwipeableRow
key={todo.id}
onSwipeRight={() => void handleDone(todo.id)}
rightContent={
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
<button
onClick={() => void handlePostpone(todo.id, 1)}
style={{ background: 'var(--info)', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>+1j</button>
<button
onClick={() => void handlePostpone(todo.id, 7)}
style={{ background: 'var(--warn)', color: '#1d2021', border: 'none', borderRadius: 6, padding: '8px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>+1S</button>
<button
onClick={() => void handleDelete(todo.id)}
style={{ background: 'var(--err)', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
></button>
</div>
}
>
<div style={{
padding: '12px 16px',
minHeight: 48,
borderBottom: idx < items.length - 1 ? '1px solid var(--bg-4)' : 'none',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[todo.priority], flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontSize: 14, fontFamily: 'var(--font-ui)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{todo.title}
</div>
{todo.due_date && (
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2 }}>
{new Date(todo.due_date).toLocaleDateString('fr-FR')}
{todo.postponed_count > 0 && ` · reporté ${todo.postponed_count}×`}
</div>
)}
</div>
</div>
</SwipeableRow>
))}
</div>
</div>
))}
{!loading && todos.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>Aucune tâche</p>
)}
</div>
{/* Vue laptop — tableau filtrable */}
<div className="hidden lg:block">
{!loading && (
<>
{/* Filtre période (laptop uniquement) */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
<span style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>Période :</span>
<input
type="date"
style={selectStyle}
value={filters.due_after ?? ''}
onChange={e => setFilters(f => ({ ...f, due_after: e.target.value || undefined }))}
/>
<input
type="date"
style={selectStyle}
value={filters.due_before ?? ''}
onChange={e => setFilters(f => ({ ...f, due_before: e.target.value || undefined }))}
/>
</div>
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--bg-4)' }}>
{['Titre', 'Domaine', 'Priorité', 'Statut', 'Date objectif', 'Reports', 'Actions'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--ink-3)', fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{todos.map((todo, idx) => (
<tr
key={todo.id}
style={{
borderBottom: idx < todos.length - 1 ? '1px solid var(--bg-4)' : 'none',
background: idx % 2 === 0 ? 'transparent' : 'rgba(0,0,0,0.1)',
}}
>
<td style={{ padding: '10px 14px', color: 'var(--ink-1)', maxWidth: 280 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{todo.title}
</div>
{todo.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{todo.tags.map(tag => (
<span key={tag} style={{ background: 'var(--bg-4)', color: 'var(--ink-3)', fontSize: 10, borderRadius: 999, padding: '1px 6px' }}>
{tag}
</span>
))}
</div>
)}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)' }}>{todo.domain ?? '—'}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ color: PRIORITY_COLORS[todo.priority], fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{todo.priority}
</span>
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)' }}>
{STATUS_LABELS[todo.status] ?? todo.status}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{todo.due_date ? new Date(todo.due_date).toLocaleDateString('fr-FR') : '—'}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>
{todo.postponed_count > 0 ? `${todo.postponed_count}×` : '—'}
</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 6 }}>
{todo.status === 'pending' && (
<>
<button
onClick={() => void handleDone(todo.id)}
title="Marquer terminé"
style={{ background: 'var(--ok)', color: '#1d2021', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
></button>
<button
onClick={() => void handlePostpone(todo.id, 1)}
title="Reporter d'1 jour"
style={{ background: 'var(--info)', color: '#fff', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>+1j</button>
<button
onClick={() => void handlePostpone(todo.id, 7)}
title="Reporter d'1 semaine"
style={{ background: 'var(--warn)', color: '#1d2021', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>+1S</button>
</>
)}
<button
onClick={() => void handleDelete(todo.id)}
title="Supprimer"
style={{ background: 'var(--err)', color: '#fff', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{todos.length === 0 && (
<p style={{ padding: 24, color: 'var(--ink-3)', textAlign: 'center' }}>Aucune tâche</p>
)}
</div>
</>
)}
</div>
{/* FAB mobile (au-dessus de la barre de navigation) */}
{!showForm && (
<button
className="lg:hidden"
onClick={() => setShowForm(true)}
aria-label="Nouvelle tâche"
style={{
position: 'fixed',
bottom: 72,
right: 20,
width: 56,
height: 56,
borderRadius: '50%',
background: 'var(--accent)',
color: '#1d2021',
border: 'none',
fontSize: 28,
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
}}
>+</button>
)}
{/* Bouton création laptop */}
{!showForm && (
<button
className="hidden lg:flex"
onClick={() => setShowForm(true)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
padding: '10px 20px',
borderRadius: 8,
background: 'var(--accent)',
color: '#1d2021',
border: 'none',
cursor: 'pointer',
fontFamily: 'var(--font-ui)',
fontWeight: 600,
fontSize: 14,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
alignItems: 'center',
gap: 6,
}}
>
+ Nouvelle tâche
</button>
)}
</div>
)
}