cbb2d81279
- 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>
420 lines
18 KiB
TypeScript
420 lines
18 KiB
TypeScript
// 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>
|
||
)
|
||
}
|