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 { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import Layout from './components/layout/Layout'
|
import Layout from './components/layout/Layout'
|
||||||
import HomePage from './pages/HomePage'
|
import HomePage from './pages/HomePage'
|
||||||
|
import TodosPage from './pages/TodosPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -8,6 +9,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="todos" element={<TodosPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</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