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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user