Files
home_hub/frontend/src/pages/TodosPage.tsx
T
gilles cbb2d81279 feat(nav): bouton action intégré dans la navbar v0.4.8
- ActionButtonContext : contexte React permettant aux pages d'injecter
  leur bouton action dans la navbar (Shopping=fa-cart-plus, Todos/Notes=+)
- BottomNav : 5e slot dédié avec dock circulaire visuel permanent ;
  bouton rendu 10px au-dessus du centre du slot (effet soulevé)
- Layout : ActionButtonProvider + overflow visible sur le conteneur nav
- Pages : useEffect enregistre/vide le bouton action — plus de FAB flottant
  sur le contenu, liste entièrement visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:02:03 +02:00

420 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, { DOMAIN_COLORS } from '../components/todos/TodoForm'
import Modal from '../components/Modal'
import { useActionButton } from '../contexts/ActionButtonContext'
type EditingTodo = Todo | null
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,
}
const noSelect: React.CSSProperties = { userSelect: 'none' }
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingTodo, setEditingTodo] = useState<EditingTodo>(null)
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
const { setActionButton } = useActionButton()
useEffect(() => {
setActionButton(
<button
onClick={() => setShowForm(true)}
aria-label="Nouvelle tâche"
style={{
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 24, cursor: 'pointer',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
)
return () => setActionButton(null)
}, [setActionButton])
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
setTodos(await fetchTodos(filters))
} catch {
setError('Erreur lors du chargement des tâches')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { void load() }, [load])
async function handleCreate(data: TodoCreate) {
try {
await createTodo(data)
setShowForm(false)
void load()
} catch {
setError('Erreur lors de la création')
}
}
async function handleUpdate(id: string, data: TodoCreate) {
try {
await updateTodo(id, data)
setEditingTodo(null)
void load()
} catch {
setError('Erreur lors de la mise à jour')
}
}
async function handleDone(id: string) {
try {
await updateTodo(id, { status: 'done' })
void load()
} catch {
setError('Erreur lors de la mise à jour')
}
}
async function handleDelete(id: string) {
try {
await deleteTodo(id)
void load()
} catch {
setError('Erreur lors de la suppression')
}
}
async function handlePostpone(id: string, days: 1 | 7) {
try {
await postponeTodo(id, days)
void load()
} catch {
setError('Erreur lors du report')
}
}
// Grouper par domaine (un todo peut apparaître dans plusieurs groupes)
const grouped = DOMAINS.reduce<Record<string, Todo[]>>((acc, d) => {
const items = todos.filter(t => t.domains.includes(d))
if (items.length > 0) acc[d] = items
return acc
}, {})
const sansDomaine = todos.filter(t => t.domains.length === 0)
if (sansDomaine.length > 0) grouped['—'] = sansDomaine
return (
<div className="p-4">
{/* En-tête + filtre statut uniquement */}
<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, ...noSelect }}>
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>
</div>
{error && (
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
{error}
</p>
)}
{/* Modal création */}
{showForm && (
<Modal title="Nouvelle tâche" onClose={() => setShowForm(false)}>
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
</Modal>
)}
{/* Modal édition */}
{editingTodo && (
<Modal title="Modifier la tâche" onClose={() => setEditingTodo(null)}>
<TodoForm
onSubmit={data => handleUpdate(editingTodo.id, data)}
onCancel={() => setEditingTodo(null)}
submitLabel="Enregistrer"
initialValues={{
title: editingTodo.title,
domains: editingTodo.domains,
priority: editingTodo.priority,
due_date: editingTodo.due_date ?? undefined,
body: editingTodo.body ?? undefined,
url: editingTodo.url ?? undefined,
tags: editingTodo.tags,
photo_path: editingTodo.photo_path ?? undefined,
gps_lat: editingTodo.gps_lat ?? undefined,
gps_lng: editingTodo.gps_lng ?? undefined,
}}
/>
</Modal>
)}
{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 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, ...noSelect }}>
<span style={{
color: DOMAIN_COLORS[domain] ?? '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)}
onSwipeLeft={() => setEditingTodo(todo)}
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,
...noSelect,
}}>
<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>
<div style={{ display: 'flex', gap: 4, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{todo.domains.map(d => (
<span
key={d}
style={{
background: DOMAIN_COLORS[d] ?? 'var(--bg-5)',
color: '#1d2021',
fontSize: 10,
fontFamily: 'var(--font-ui)',
fontWeight: 600,
borderRadius: 999,
padding: '1px 7px',
}}
>{d}</span>
))}
{todo.due_date && (
<span style={{ color: 'var(--ink-3)', fontSize: 11 }}>
{new Date(todo.due_date).toLocaleDateString('fr-FR')}
{todo.postponed_count > 0 && ` · reporté ${todo.postponed_count}×`}
</span>
)}
</div>
</div>
</div>
</SwipeableRow>
))}
</div>
</div>
))}
{!loading && todos.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune tâche</p>
)}
</div>
{/* Vue laptop — tableau filtrable */}
<div className="hidden lg:block">
{!loading && (
<>
{/* Filtre période */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
<span style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)', ...noSelect }}>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', 'Domaines', 'Priorité', 'Statut', 'Date objectif', 'Reports', 'Actions'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--ink-3)', fontWeight: 500, ...noSelect }}>{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, cursor: 'pointer', ...noSelect }}
onDoubleClick={() => setEditingTodo(todo)}
title="Double-clic pour modifier"
>
<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', ...noSelect }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{todo.domains.length === 0 ? (
<span style={{ color: 'var(--ink-4)' }}></span>
) : todo.domains.map(d => (
<span
key={d}
style={{
background: DOMAIN_COLORS[d] ?? 'var(--bg-5)',
color: '#1d2021',
fontSize: 11,
fontFamily: 'var(--font-ui)',
fontWeight: 600,
borderRadius: 999,
padding: '2px 8px',
}}
>{d}</span>
))}
</div>
</td>
<td style={{ padding: '10px 14px', ...noSelect }}>
<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)', ...noSelect }}>
{STATUS_LABELS[todo.status] ?? todo.status}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
{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)', ...noSelect }}>
{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', ...noSelect }}>Aucune tâche</p>
)}
</div>
</>
)}
</div>
</div>
)
}