feat(todos): refonte formulaire + chips domaines + non-zoomable
- TodoForm : domaines en chips multi-select colorés, priorité en 3 boutons colorés (haute/moyenne/basse), date initialisée à aujourd'hui, description et URL toujours visibles, boutons photo et GPS - TodosPage : suppression filtres domaine/priorité, tags colorés par domaine dans les lignes, userSelect:none, groupage multi-domaines - todos.ts : ajout domains[], photo_path, gps_lat/lng dans les interfaces TS - index.html : viewport maximum-scale=1.0, user-scalable=no Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="fr" data-theme="dark">
|
<html lang="fr" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#fe8019" />
|
<meta name="theme-color" content="#fe8019" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ export interface Todo {
|
|||||||
title: string
|
title: string
|
||||||
body: string | null
|
body: string | null
|
||||||
url: string | null
|
url: string | null
|
||||||
domain: string | null
|
domains: string[]
|
||||||
category: string | null
|
category: string | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
status: 'pending' | 'done' | 'cancelled'
|
status: 'pending' | 'done' | 'cancelled'
|
||||||
priority: 'low' | 'medium' | 'high'
|
priority: 'low' | 'medium' | 'high'
|
||||||
due_date: string | null
|
due_date: string | null
|
||||||
postponed_count: number
|
postponed_count: number
|
||||||
|
photo_path: string | null
|
||||||
|
gps_lat: number | null
|
||||||
|
gps_lng: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
owner_id: string | null
|
owner_id: string | null
|
||||||
@@ -21,11 +24,15 @@ export interface TodoCreate {
|
|||||||
body?: string
|
body?: string
|
||||||
url?: string
|
url?: string
|
||||||
domain?: string
|
domain?: string
|
||||||
|
domains?: string[]
|
||||||
category?: string
|
category?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
status?: 'pending' | 'done' | 'cancelled'
|
status?: 'pending' | 'done' | 'cancelled'
|
||||||
priority?: 'low' | 'medium' | 'high'
|
priority?: 'low' | 'medium' | 'high'
|
||||||
due_date?: string
|
due_date?: string
|
||||||
|
photo_path?: string
|
||||||
|
gps_lat?: number
|
||||||
|
gps_lng?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoUpdate {
|
export interface TodoUpdate {
|
||||||
@@ -33,11 +40,15 @@ export interface TodoUpdate {
|
|||||||
body?: string
|
body?: string
|
||||||
url?: string
|
url?: string
|
||||||
domain?: string
|
domain?: string
|
||||||
|
domains?: string[]
|
||||||
category?: string
|
category?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
status?: 'pending' | 'done' | 'cancelled'
|
status?: 'pending' | 'done' | 'cancelled'
|
||||||
priority?: 'low' | 'medium' | 'high'
|
priority?: 'low' | 'medium' | 'high'
|
||||||
due_date?: string
|
due_date?: string
|
||||||
|
photo_path?: string
|
||||||
|
gps_lat?: number
|
||||||
|
gps_lng?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoFilters {
|
export interface TodoFilters {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import type { TodoCreate } from '../../api/todos'
|
import type { TodoCreate } from '../../api/todos'
|
||||||
|
|
||||||
const DOMAINS = [
|
const DOMAINS = [
|
||||||
@@ -6,10 +6,27 @@ const DOMAINS = [
|
|||||||
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
|
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const DOMAIN_COLORS: Record<string, string> = {
|
||||||
|
informatique: 'var(--info)',
|
||||||
|
diy: 'var(--warn)',
|
||||||
|
electronique: 'var(--accent)',
|
||||||
|
domotique: 'var(--ok)',
|
||||||
|
bricolage: 'var(--warn)',
|
||||||
|
jardin: 'var(--ok)',
|
||||||
|
cuisine: 'var(--err)',
|
||||||
|
voyage: 'var(--info)',
|
||||||
|
animaux: 'var(--ok)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG = [
|
||||||
|
{ value: 'high' as const, label: 'Haute', color: 'var(--err)', textColor: '#fff' },
|
||||||
|
{ value: 'medium' as const, label: 'Moyenne', color: 'var(--warn)', textColor: '#1d2021' },
|
||||||
|
{ value: 'low' as const, label: 'Basse', color: 'var(--bg-5)', textColor: 'var(--ink-2)' },
|
||||||
|
]
|
||||||
|
|
||||||
interface TodoFormProps {
|
interface TodoFormProps {
|
||||||
onSubmit: (data: TodoCreate) => Promise<void>
|
onSubmit: (data: TodoCreate) => Promise<void>
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
extended?: boolean
|
|
||||||
initialValues?: Partial<TodoCreate>
|
initialValues?: Partial<TodoCreate>
|
||||||
submitLabel?: string
|
submitLabel?: string
|
||||||
}
|
}
|
||||||
@@ -26,17 +43,67 @@ const inputStyle: React.CSSProperties = {
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TodoForm({ onSubmit, onCancel, extended = false, initialValues, submitLabel = 'Créer' }: TodoFormProps) {
|
const labelStyle: React.CSSProperties = {
|
||||||
|
color: 'var(--ink-3)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
marginBottom: 6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TodoForm({ onSubmit, onCancel, initialValues, submitLabel = 'Créer' }: TodoFormProps) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const [title, setTitle] = useState(initialValues?.title ?? '')
|
const [title, setTitle] = useState(initialValues?.title ?? '')
|
||||||
const [domain, setDomain] = useState(initialValues?.domain ?? '')
|
const [selectedDomains, setSelectedDomains] = useState<string[]>(initialValues?.domains ?? [])
|
||||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(initialValues?.priority ?? 'medium')
|
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(initialValues?.priority ?? 'medium')
|
||||||
const [dueDate, setDueDate] = useState(
|
const [dueDate, setDueDate] = useState(
|
||||||
initialValues?.due_date ? initialValues.due_date.slice(0, 10) : ''
|
initialValues?.due_date ? initialValues.due_date.slice(0, 10) : today
|
||||||
)
|
)
|
||||||
const [body, setBody] = useState(initialValues?.body ?? '')
|
const [body, setBody] = useState(initialValues?.body ?? '')
|
||||||
const [url, setUrl] = useState(initialValues?.url ?? '')
|
const [url, setUrl] = useState(initialValues?.url ?? '')
|
||||||
const [tags, setTags] = useState((initialValues?.tags ?? []).join(', '))
|
const [tags, setTags] = useState((initialValues?.tags ?? []).join(', '))
|
||||||
|
const [gpsLat, setGpsLat] = useState<number | undefined>(initialValues?.gps_lat ?? undefined)
|
||||||
|
const [gpsLng, setGpsLng] = useState<number | undefined>(initialValues?.gps_lng ?? undefined)
|
||||||
|
const [photoPath, setPhotoPath] = useState<string | undefined>(initialValues?.photo_path ?? undefined)
|
||||||
|
const [photoLoading, setPhotoLoading] = useState(false)
|
||||||
|
const [gpsLoading, setGpsLoading] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function toggleDomain(d: string) {
|
||||||
|
setSelectedDomains(prev => prev.includes(d) ? prev.filter(x => x !== d) : [...prev, d])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoCapture(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setPhotoLoading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await fetch('/api/media/upload', { method: 'POST', body: formData })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { file_path: string }
|
||||||
|
setPhotoPath(data.file_path)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPhotoLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGps() {
|
||||||
|
if (!navigator.geolocation) return
|
||||||
|
setGpsLoading(true)
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
pos => {
|
||||||
|
setGpsLat(pos.coords.latitude)
|
||||||
|
setGpsLng(pos.coords.longitude)
|
||||||
|
setGpsLoading(false)
|
||||||
|
},
|
||||||
|
() => setGpsLoading(false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -45,12 +112,15 @@ export default function TodoForm({ onSubmit, onCancel, extended = false, initial
|
|||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
domain: domain || undefined,
|
domains: selectedDomains,
|
||||||
priority,
|
priority,
|
||||||
due_date: dueDate ? new Date(dueDate).toISOString() : undefined,
|
due_date: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||||
body: body.trim() || undefined,
|
body: body.trim() || undefined,
|
||||||
url: url.trim() || undefined,
|
url: url.trim() || undefined,
|
||||||
tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
||||||
|
photo_path: photoPath,
|
||||||
|
gps_lat: gpsLat,
|
||||||
|
gps_lng: gpsLng,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -58,7 +128,7 @@ export default function TodoForm({ onSubmit, onCancel, extended = false, initial
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
placeholder="Titre de la tâche *"
|
placeholder="Titre de la tâche *"
|
||||||
@@ -68,49 +138,154 @@ export default function TodoForm({ onSubmit, onCancel, extended = false, initial
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
{/* Domaines — chips multi-select */}
|
||||||
<select style={inputStyle} value={domain} onChange={e => setDomain(e.target.value)}>
|
<div>
|
||||||
<option value="">Domaine</option>
|
<div style={labelStyle}>Domaines</div>
|
||||||
{DOMAINS.map(d => <option key={d} value={d}>{d}</option>)}
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
</select>
|
{DOMAINS.map(d => {
|
||||||
<select style={inputStyle} value={priority} onChange={e => setPriority(e.target.value as 'low' | 'medium' | 'high')}>
|
const selected = selectedDomains.includes(d)
|
||||||
<option value="low">Priorité basse</option>
|
const color = DOMAIN_COLORS[d]
|
||||||
<option value="medium">Priorité moyenne</option>
|
return (
|
||||||
<option value="high">Priorité haute</option>
|
<button
|
||||||
</select>
|
key={d}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDomain(d)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: selected ? 'none' : '1px solid var(--bg-5)',
|
||||||
|
background: selected ? color : 'var(--bg-4)',
|
||||||
|
color: selected ? '#1d2021' : 'var(--ink-3)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: selected ? 600 : 400,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>{d}</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
{/* Priorité — boutons radio colorés */}
|
||||||
style={inputStyle}
|
<div>
|
||||||
type="date"
|
<div style={labelStyle}>Priorité</div>
|
||||||
value={dueDate}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
onChange={e => setDueDate(e.target.value)}
|
{PRIORITY_CONFIG.map(p => {
|
||||||
|
const selected = priority === p.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriority(p.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 0',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: selected ? 'none' : '1px solid var(--bg-5)',
|
||||||
|
background: selected ? p.color : 'var(--bg-4)',
|
||||||
|
color: selected ? p.textColor : 'var(--ink-3)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selected ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: 40,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>{p.label}</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date objectif */}
|
||||||
|
<div>
|
||||||
|
<div style={labelStyle}>Date objectif</div>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={e => setDueDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }}
|
||||||
|
placeholder="Description (optionnel)"
|
||||||
|
value={body}
|
||||||
|
onChange={e => setBody(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{extended && (
|
{/* URL */}
|
||||||
<>
|
<input
|
||||||
<textarea
|
style={inputStyle}
|
||||||
style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }}
|
placeholder="URL (lien externe)"
|
||||||
placeholder="Description"
|
value={url}
|
||||||
value={body}
|
onChange={e => setUrl(e.target.value)}
|
||||||
onChange={e => setBody(e.target.value)}
|
type="url"
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="URL (lien externe optionnel)"
|
|
||||||
value={url}
|
|
||||||
onChange={e => setUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="Tags (séparés par virgule)"
|
|
||||||
value={tags}
|
|
||||||
onChange={e => setTags(e.target.value)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
{/* Tags */}
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Tags (séparés par virgule)"
|
||||||
|
value={tags}
|
||||||
|
onChange={e => setTags(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Photo + GPS */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handlePhotoCapture}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--bg-5)',
|
||||||
|
background: photoPath ? 'var(--ok)' : 'var(--bg-4)',
|
||||||
|
color: photoPath ? '#1d2021' : 'var(--ink-3)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photoLoading ? '…' : photoPath ? 'Photo ajoutée' : 'Ajouter une photo'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGps}
|
||||||
|
disabled={!navigator.geolocation}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--bg-5)',
|
||||||
|
background: gpsLat != null ? 'var(--info)' : 'var(--bg-4)',
|
||||||
|
color: gpsLat != null ? '#fff' : 'var(--ink-3)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: navigator.geolocation ? 'pointer' : 'default',
|
||||||
|
minHeight: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gpsLoading ? '…' : gpsLat != null ? `GPS : ${gpsLat.toFixed(4)}` : 'Position GPS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Boutons */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -124,9 +299,7 @@ export default function TodoForm({ onSubmit, onCancel, extended = false, initial
|
|||||||
fontFamily: 'var(--font-ui)',
|
fontFamily: 'var(--font-ui)',
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
}}
|
}}
|
||||||
>
|
>Annuler</button>
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -141,9 +314,7 @@ export default function TodoForm({ onSubmit, onCancel, extended = false, initial
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
}}
|
}}
|
||||||
>
|
>{loading ? '…' : submitLabel}</button>
|
||||||
{loading ? '…' : submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import type { Todo, TodoCreate, TodoFilters } from '../api/todos'
|
import type { Todo, TodoCreate, TodoFilters } from '../api/todos'
|
||||||
import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '../api/todos'
|
import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '../api/todos'
|
||||||
import SwipeableRow from '../components/todos/SwipeableRow'
|
import SwipeableRow from '../components/todos/SwipeableRow'
|
||||||
import TodoForm from '../components/todos/TodoForm'
|
import TodoForm, { DOMAIN_COLORS } from '../components/todos/TodoForm'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
type EditingTodo = Todo | null
|
type EditingTodo = Todo | null
|
||||||
@@ -12,9 +12,11 @@ const DOMAINS = [
|
|||||||
'informatique', 'diy', 'electronique', 'domotique',
|
'informatique', 'diy', 'electronique', 'domotique',
|
||||||
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
|
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
|
||||||
]
|
]
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
pending: 'En cours', done: 'Terminé', cancelled: 'Annulé',
|
pending: 'En cours', done: 'Terminé', cancelled: 'Annulé',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
high: 'var(--err)', medium: 'var(--warn)', low: 'var(--ink-3)',
|
high: 'var(--err)', medium: 'var(--warn)', low: 'var(--ink-3)',
|
||||||
}
|
}
|
||||||
@@ -29,6 +31,8 @@ const selectStyle: React.CSSProperties = {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noSelect: React.CSSProperties = { userSelect: 'none' }
|
||||||
|
|
||||||
export default function TodosPage() {
|
export default function TodosPage() {
|
||||||
const [todos, setTodos] = useState<Todo[]>([])
|
const [todos, setTodos] = useState<Todo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -98,20 +102,20 @@ export default function TodosPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grouper par domaine pour la vue mobile
|
// Grouper par domaine (un todo peut apparaître dans plusieurs groupes)
|
||||||
const grouped = DOMAINS.reduce<Record<string, Todo[]>>((acc, d) => {
|
const grouped = DOMAINS.reduce<Record<string, Todo[]>>((acc, d) => {
|
||||||
const items = todos.filter(t => t.domain === d)
|
const items = todos.filter(t => t.domains.includes(d))
|
||||||
if (items.length > 0) acc[d] = items
|
if (items.length > 0) acc[d] = items
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
const sansDomaine = todos.filter(t => !t.domain)
|
const sansDomaine = todos.filter(t => t.domains.length === 0)
|
||||||
if (sansDomaine.length > 0) grouped['—'] = sansDomaine
|
if (sansDomaine.length > 0) grouped['—'] = sansDomaine
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* En-tête + filtres */}
|
{/* En-tête + filtre statut uniquement */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
<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 }}>
|
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, minWidth: 100, ...noSelect }}>
|
||||||
Tâches
|
Tâches
|
||||||
</h1>
|
</h1>
|
||||||
<select
|
<select
|
||||||
@@ -124,24 +128,6 @@ export default function TodosPage() {
|
|||||||
<option value="cancelled">Annulé</option>
|
<option value="cancelled">Annulé</option>
|
||||||
<option value="">Tous</option>
|
<option value="">Tous</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -150,29 +136,31 @@ export default function TodosPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Formulaire de création */}
|
{/* Modal création */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Modal title="Nouvelle tâche" onClose={() => setShowForm(false)}>
|
<Modal title="Nouvelle tâche" onClose={() => setShowForm(false)}>
|
||||||
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} extended />
|
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Formulaire d'édition */}
|
{/* Modal édition */}
|
||||||
{editingTodo && (
|
{editingTodo && (
|
||||||
<Modal title="Modifier la tâche" onClose={() => setEditingTodo(null)}>
|
<Modal title="Modifier la tâche" onClose={() => setEditingTodo(null)}>
|
||||||
<TodoForm
|
<TodoForm
|
||||||
onSubmit={data => handleUpdate(editingTodo.id, data)}
|
onSubmit={data => handleUpdate(editingTodo.id, data)}
|
||||||
onCancel={() => setEditingTodo(null)}
|
onCancel={() => setEditingTodo(null)}
|
||||||
extended
|
|
||||||
submitLabel="Enregistrer"
|
submitLabel="Enregistrer"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
title: editingTodo.title,
|
title: editingTodo.title,
|
||||||
domain: editingTodo.domain ?? undefined,
|
domains: editingTodo.domains,
|
||||||
priority: editingTodo.priority,
|
priority: editingTodo.priority,
|
||||||
due_date: editingTodo.due_date ?? undefined,
|
due_date: editingTodo.due_date ?? undefined,
|
||||||
body: editingTodo.body ?? undefined,
|
body: editingTodo.body ?? undefined,
|
||||||
url: editingTodo.url ?? undefined,
|
url: editingTodo.url ?? undefined,
|
||||||
tags: editingTodo.tags,
|
tags: editingTodo.tags,
|
||||||
|
photo_path: editingTodo.photo_path ?? undefined,
|
||||||
|
gps_lat: editingTodo.gps_lat ?? undefined,
|
||||||
|
gps_lng: editingTodo.gps_lng ?? undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -186,11 +174,13 @@ export default function TodosPage() {
|
|||||||
<div className="block lg:hidden">
|
<div className="block lg:hidden">
|
||||||
{!loading && Object.entries(grouped).map(([domain, items]) => (
|
{!loading && Object.entries(grouped).map(([domain, items]) => (
|
||||||
<div key={domain} style={{ marginBottom: 20 }}>
|
<div key={domain} style={{ marginBottom: 20 }}>
|
||||||
{/* Entête de groupe avec badge compteur */}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, ...noSelect }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
||||||
<span style={{
|
<span style={{
|
||||||
color: 'var(--accent)', fontFamily: 'var(--font-mono)',
|
color: DOMAIN_COLORS[domain] ?? 'var(--accent)',
|
||||||
fontSize: 12, textTransform: 'uppercase', letterSpacing: 1,
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
}}>
|
}}>
|
||||||
{domain}
|
{domain}
|
||||||
</span>
|
</span>
|
||||||
@@ -232,18 +222,35 @@ export default function TodosPage() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
|
...noSelect,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[todo.priority], flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[todo.priority], flexShrink: 0 }} />
|
||||||
<div style={{ flex: 1, minWidth: 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' }}>
|
<div style={{ color: 'var(--ink-1)', fontSize: 14, fontFamily: 'var(--font-ui)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{todo.title}
|
{todo.title}
|
||||||
</div>
|
</div>
|
||||||
{todo.due_date && (
|
<div style={{ display: 'flex', gap: 4, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2 }}>
|
{todo.domains.map(d => (
|
||||||
{new Date(todo.due_date).toLocaleDateString('fr-FR')}
|
<span
|
||||||
{todo.postponed_count > 0 && ` · reporté ${todo.postponed_count}×`}
|
key={d}
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</SwipeableRow>
|
</SwipeableRow>
|
||||||
@@ -253,7 +260,7 @@ export default function TodosPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!loading && todos.length === 0 && (
|
{!loading && todos.length === 0 && (
|
||||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>Aucune tâche</p>
|
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune tâche</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -261,9 +268,9 @@ export default function TodosPage() {
|
|||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
{/* Filtre période (laptop uniquement) */}
|
{/* Filtre période */}
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
|
<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>
|
<span style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)', ...noSelect }}>Période :</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
style={selectStyle}
|
style={selectStyle}
|
||||||
@@ -282,8 +289,8 @@ export default function TodosPage() {
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--bg-4)' }}>
|
<tr style={{ borderBottom: '1px solid var(--bg-4)' }}>
|
||||||
{['Titre', 'Domaine', 'Priorité', 'Statut', 'Date objectif', 'Reports', 'Actions'].map(h => (
|
{['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 }}>{h}</th>
|
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--ink-3)', fontWeight: 500, ...noSelect }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -297,7 +304,7 @@ export default function TodosPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
style={{ padding: '10px 14px', color: 'var(--ink-1)', maxWidth: 280, cursor: 'pointer' }}
|
style={{ padding: '10px 14px', color: 'var(--ink-1)', maxWidth: 280, cursor: 'pointer', ...noSelect }}
|
||||||
onDoubleClick={() => setEditingTodo(todo)}
|
onDoubleClick={() => setEditingTodo(todo)}
|
||||||
title="Double-clic pour modifier"
|
title="Double-clic pour modifier"
|
||||||
>
|
>
|
||||||
@@ -314,19 +321,38 @@ export default function TodosPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px', color: 'var(--ink-2)' }}>{todo.domain ?? '—'}</td>
|
<td style={{ padding: '10px 14px', ...noSelect }}>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<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 }}>
|
<span style={{ color: PRIORITY_COLORS[todo.priority], fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
{todo.priority}
|
{todo.priority}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px', color: 'var(--ink-2)' }}>
|
<td style={{ padding: '10px 14px', color: 'var(--ink-2)', ...noSelect }}>
|
||||||
{STATUS_LABELS[todo.status] ?? todo.status}
|
{STATUS_LABELS[todo.status] ?? todo.status}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px', color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
<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') : '—'}
|
{todo.due_date ? new Date(todo.due_date).toLocaleDateString('fr-FR') : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px', color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>
|
<td style={{ padding: '10px 14px', color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', ...noSelect }}>
|
||||||
{todo.postponed_count > 0 ? `${todo.postponed_count}×` : '—'}
|
{todo.postponed_count > 0 ? `${todo.postponed_count}×` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 14px' }}>
|
<td style={{ padding: '10px 14px' }}>
|
||||||
@@ -362,14 +388,14 @@ export default function TodosPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{todos.length === 0 && (
|
{todos.length === 0 && (
|
||||||
<p style={{ padding: 24, color: 'var(--ink-3)', textAlign: 'center' }}>Aucune tâche</p>
|
<p style={{ padding: 24, color: 'var(--ink-3)', textAlign: 'center', ...noSelect }}>Aucune tâche</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB mobile (au-dessus de la barre de navigation) */}
|
{/* FAB mobile */}
|
||||||
<button
|
<button
|
||||||
className="lg:hidden"
|
className="lg:hidden"
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
|
|||||||
Reference in New Issue
Block a user