fix(ui): bouton + laptop, overflow mobile, clôture semaine, backup complet
- TodosPage/ShoppingPage : bouton « + » visible en laptop (hidden lg:flex) - ShoppingPage : renomme « Terminer » en « Clôturer la semaine », badge ⚠ si semaine ISO dépassée, confirmation modale avec décompte non-cochés - NotesPage : overflowWrap:anywhere sur titre/contenu/markdown (URLs longues qui débordaient hors de la tuile sur smartphone) - index.css : overflow-x:hidden + max-width:100vw sur html/body (garde-fou global) - admin.py : backup remplacé par archive .tar.gz (pg_dump + uploads/) streamée au navigateur ; restore via multipart upload avec extraction sécurisée - admin.ts : downloadBackup() (blob trigger) + uploadAndRestore() avec progression XHR - ConfigPage : refonte section backup avec boutons Télécharger/Restaurer et barre de progression upload v0.5.11 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+40
-20
@@ -18,31 +18,51 @@ export async function fetchStats(): Promise<AppStats> {
|
||||
return res.json() as Promise<AppStats>
|
||||
}
|
||||
|
||||
export interface BackupFile {
|
||||
filename: string
|
||||
size: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function fetchBackups(): Promise<BackupFile[]> {
|
||||
const res = await fetch('/api/admin/backups')
|
||||
if (!res.ok) throw new Error('Erreur chargement sauvegardes')
|
||||
return res.json() as Promise<BackupFile[]>
|
||||
}
|
||||
|
||||
export async function createBackup(): Promise<BackupFile> {
|
||||
export async function downloadBackup(): Promise<string> {
|
||||
const res = await fetch('/api/admin/backup', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { detail?: string }
|
||||
throw new Error(err.detail ?? 'Erreur lors du backup')
|
||||
}
|
||||
return res.json() as Promise<BackupFile>
|
||||
const blob = await res.blob()
|
||||
const disposition = res.headers.get('Content-Disposition') ?? ''
|
||||
const match = /filename="?([^";]+)"?/.exec(disposition)
|
||||
const filename = match?.[1] ?? `homehub_${new Date().toISOString().slice(0, 10)}.tar.gz`
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
return filename
|
||||
}
|
||||
|
||||
export async function restoreBackup(filename: string): Promise<void> {
|
||||
const res = await fetch(`/api/admin/restore/${encodeURIComponent(filename)}`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { detail?: string }
|
||||
throw new Error(err.detail ?? 'Erreur lors de la restauration')
|
||||
}
|
||||
export async function uploadAndRestore(file: File, onProgress?: (pct: number) => void): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/admin/restore')
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = ev => {
|
||||
if (ev.lengthComputable) onProgress(Math.round((ev.loaded / ev.total) * 100))
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||
else {
|
||||
try {
|
||||
const err = JSON.parse(xhr.responseText) as { detail?: string }
|
||||
reject(new Error(err.detail ?? `Erreur ${xhr.status}`))
|
||||
} catch {
|
||||
reject(new Error(`Erreur ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('Erreur réseau'))
|
||||
xhr.send(form)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,9 +15,14 @@ input, textarea, select {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
background-color: var(--bg-1);
|
||||
color: var(--ink-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
|
||||
import { fetchBackups, createBackup, restoreBackup, fetchStats, type BackupFile, type AppStats } from '../api/admin'
|
||||
import { downloadBackup, uploadAndRestore, fetchStats, type AppStats } from '../api/admin'
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
background: 'var(--bg-3)',
|
||||
@@ -39,30 +39,28 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme, fontScale, setFontScale } = useTheme()
|
||||
const [stats, setStats] = useState<AppStats | null>(null)
|
||||
const [backups, setBackups] = useState<BackupFile[]>([])
|
||||
const [backupLoading, setBackupLoading] = useState(false)
|
||||
const [restoring, setRestoring] = useState<string | null>(null)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [restoreProgress, setRestoreProgress] = useState(0)
|
||||
const [backupError, setBackupError] = useState<string | null>(null)
|
||||
const [backupInfo, setBackupInfo] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats().then(setStats).catch(() => null)
|
||||
fetchBackups().then(setBackups).catch(() => setBackups([]))
|
||||
}, [])
|
||||
|
||||
async function handleCreateBackup() {
|
||||
setBackupLoading(true)
|
||||
setBackupError(null)
|
||||
setBackupInfo(null)
|
||||
try {
|
||||
const b = await createBackup()
|
||||
setBackups(prev => [b, ...prev])
|
||||
const filename = await downloadBackup()
|
||||
setBackupInfo(`Téléchargé : ${filename}`)
|
||||
} catch (e) {
|
||||
setBackupError((e as Error).message)
|
||||
} finally {
|
||||
@@ -70,16 +68,22 @@ export default function ConfigPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore(filename: string) {
|
||||
if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return
|
||||
setRestoring(filename)
|
||||
async function handleRestoreFile(file: File) {
|
||||
if (!confirm(`Restaurer depuis "${file.name}" ? La base actuelle (BDD + médias) sera remplacée.`)) return
|
||||
setRestoring(true)
|
||||
setRestoreProgress(0)
|
||||
setBackupError(null)
|
||||
setBackupInfo(null)
|
||||
try {
|
||||
await restoreBackup(filename)
|
||||
await uploadAndRestore(file, pct => setRestoreProgress(pct))
|
||||
setBackupInfo('Restauration réussie')
|
||||
fetchStats().then(setStats).catch(() => null)
|
||||
} catch (e) {
|
||||
setBackupError((e as Error).message)
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
setRestoring(false)
|
||||
setRestoreProgress(0)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +193,11 @@ export default function ConfigPage() {
|
||||
|
||||
{/* Sauvegarde & Restauration */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={labelStyle}>Base de données</div>
|
||||
<div style={labelStyle}>Sauvegarde complète (BDD + médias)</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={backupLoading}
|
||||
disabled={backupLoading || restoring}
|
||||
style={{
|
||||
padding: '10px 16px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--ok)', color: '#1d2021',
|
||||
@@ -202,59 +207,63 @@ export default function ConfigPage() {
|
||||
opacity: backupLoading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-database" />
|
||||
{backupLoading ? 'Sauvegarde en cours…' : 'Créer une sauvegarde'}
|
||||
<i className="fa-solid fa-download" />
|
||||
{backupLoading ? 'Préparation…' : 'Télécharger une archive'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz,application/gzip"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) void handleRestoreFile(f)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={backupLoading || restoring}
|
||||
style={{
|
||||
padding: '10px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--warn)', background: 'transparent',
|
||||
color: 'var(--warn)',
|
||||
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
|
||||
cursor: restoring ? 'default' : 'pointer', minHeight: 44,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
opacity: restoring ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-upload" />
|
||||
{restoring
|
||||
? (restoreProgress < 100 ? `Upload ${restoreProgress}%…` : 'Restauration en cours…')
|
||||
: 'Restaurer depuis une archive'}
|
||||
</button>
|
||||
|
||||
{restoring && (
|
||||
<div style={{ height: 6, background: 'var(--bg-4)', borderRadius: 999, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${restoreProgress}%`, height: '100%',
|
||||
background: 'var(--warn)', transition: 'width 0.2s ease',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupError && (
|
||||
<div style={{ color: 'var(--err)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
|
||||
{backupError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backups.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
||||
<div style={{ ...labelStyle, marginBottom: 0 }}>Sauvegardes disponibles</div>
|
||||
{backups.map(b => (
|
||||
<div
|
||||
key={b.filename}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-file-zipper" style={{ color: 'var(--ink-3)', fontSize: 14, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.filename}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-4)' }}>
|
||||
{formatDate(b.created_at)} — {formatSize(b.size)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleRestore(b.filename)}
|
||||
disabled={restoring === b.filename}
|
||||
style={{
|
||||
padding: '5px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--warn)', background: 'transparent',
|
||||
color: 'var(--warn)', fontFamily: 'var(--font-ui)', fontSize: 11,
|
||||
cursor: restoring === b.filename ? 'default' : 'pointer',
|
||||
flexShrink: 0, opacity: restoring === b.filename ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{restoring === b.filename ? '…' : 'Restaurer'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{backupInfo && (
|
||||
<div style={{ color: 'var(--ok)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
|
||||
{backupInfo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backups.length === 0 && !backupLoading && (
|
||||
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>
|
||||
Aucune sauvegarde disponible
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 11, lineHeight: 1.4 }}>
|
||||
L'archive contient le dump PostgreSQL et tout le dossier <code style={{ fontFamily: 'var(--font-mono)' }}>uploads/</code> (photos, audio, vidéos). À restaurer sur une instance compatible.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taille du texte */}
|
||||
|
||||
@@ -326,10 +326,10 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
// ─── COLLAPSED ───────────────────────────────────────────────────────────────
|
||||
if (state === 'collapsed') {
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px' }}>
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400, overflowWrap: 'anywhere' }}>
|
||||
{note.title || note.content.slice(0, 60).replace(/\n/g, ' ')}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
|
||||
@@ -345,17 +345,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
// ─── SEMI (défaut) ───────────────────────────────────────────────────────────
|
||||
if (state === 'semi') {
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
|
||||
{note.title && (
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4, overflowWrap: 'anywhere' }}>
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5,
|
||||
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
overflowWrap: 'anywhere',
|
||||
} as React.CSSProperties}>
|
||||
{note.content}
|
||||
</div>
|
||||
@@ -370,18 +371,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
|
||||
|
||||
// ─── EXPANDED ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.title && (
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15 }}>
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15, overflowWrap: 'anywhere' }}>
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{toggleBtn}
|
||||
</div>
|
||||
<div>{renderMarkdown(note.content)}</div>
|
||||
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
|
||||
{mediaSection}
|
||||
{metaLine}
|
||||
{actionButtons}
|
||||
|
||||
@@ -30,6 +30,24 @@ const inputStyle: React.CSSProperties = {
|
||||
|
||||
const noSelect: React.CSSProperties = { userSelect: 'none' }
|
||||
|
||||
function isoWeek(d: Date): { week: number; year: number } {
|
||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
||||
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7))
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
|
||||
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
|
||||
return { week, year: date.getUTCFullYear() }
|
||||
}
|
||||
|
||||
function isListOutdated(name: string | null | undefined): boolean {
|
||||
if (!name) return false
|
||||
const m = /^S(\d{1,2})\s+(\d{4})$/.exec(name)
|
||||
if (!m) return false
|
||||
const listWeek = parseInt(m[1], 10)
|
||||
const listYear = parseInt(m[2], 10)
|
||||
const { week, year } = isoWeek(new Date())
|
||||
return year > listYear || (year === listYear && week > listWeek)
|
||||
}
|
||||
|
||||
function QtyControls({ qty, onDecrement, onIncrement }: { qty: number; onDecrement: () => void; onIncrement: () => void }) {
|
||||
const btnBase: React.CSSProperties = {
|
||||
width: 32, height: 32, borderRadius: 8, border: 'none',
|
||||
@@ -334,10 +352,13 @@ export default function ShoppingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
|
||||
|
||||
async function handleFinish() {
|
||||
if (!currentList) return
|
||||
try {
|
||||
await finishShopping(currentList.id)
|
||||
setShowFinishConfirm(false)
|
||||
void loadData()
|
||||
} catch {
|
||||
setError('Erreur lors de la finalisation')
|
||||
@@ -418,6 +439,21 @@ export default function ShoppingPage() {
|
||||
...noSelect,
|
||||
}}
|
||||
>Boutiques</button>
|
||||
{hasCurrentList && (
|
||||
<button
|
||||
className="hidden lg:flex"
|
||||
onClick={openAddSheet}
|
||||
style={{
|
||||
alignItems: 'center', gap: 8,
|
||||
background: 'var(--accent)', border: 'none',
|
||||
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
|
||||
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
|
||||
...noSelect,
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-cart-plus" /> Article
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Erreur ── */}
|
||||
@@ -496,10 +532,27 @@ export default function ShoppingPage() {
|
||||
padding: '8px 16px',
|
||||
background: 'var(--bg-3)',
|
||||
borderBottom: '1px solid var(--bg-4)',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ flex: 1, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
|
||||
{checkedItems.length}/{currentList.item_count} cochés
|
||||
</span>
|
||||
{isListOutdated(currentList.name) && (
|
||||
<span
|
||||
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
|
||||
style={{
|
||||
background: 'var(--warn)', color: '#1d2021',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
...noSelect,
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-triangle-exclamation" style={{ fontSize: 10 }} />
|
||||
semaine dépassée
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{pastLists.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowHistoryModal(true)}
|
||||
@@ -511,14 +564,17 @@ export default function ShoppingPage() {
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
|
||||
>Supprimer</button>
|
||||
<button
|
||||
onClick={() => void handleFinish()}
|
||||
onClick={() => setShowFinishConfirm(true)}
|
||||
style={{
|
||||
background: 'var(--ok)', color: '#1d2021', border: 'none',
|
||||
borderRadius: 8, padding: '6px 14px',
|
||||
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
|
||||
cursor: 'pointer', minHeight: 36, ...noSelect,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>Terminer ✓</button>
|
||||
>
|
||||
<i className="fa-solid fa-check" /> Clôturer la semaine
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Articles non cochés */}
|
||||
@@ -801,6 +857,35 @@ export default function ShoppingPage() {
|
||||
onStoresChanged={() => void loadData()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFinishConfirm && currentList && (
|
||||
<Modal title="Clôturer la semaine ?" onClose={() => setShowFinishConfirm(false)} width={420}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p style={{ margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 14, lineHeight: 1.5 }}>
|
||||
La liste <strong style={{ color: 'var(--ink-1)' }}>{currentList.name ?? 'en cours'}</strong> va être archivée.
|
||||
</p>
|
||||
{uncheckedItems.length > 0 ? (
|
||||
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<strong style={{ color: 'var(--warn)' }}>{uncheckedItems.length}</strong> article{uncheckedItems.length > 1 ? 's' : ''} non coché{uncheckedItems.length > 1 ? 's' : ''} {uncheckedItems.length > 1 ? 'seront reportés' : 'sera reporté'} dans la nouvelle liste de la semaine en cours.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
Tous les articles sont cochés. Une nouvelle liste vide sera créée pour la semaine en cours.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => setShowFinishConfirm(false)}
|
||||
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
|
||||
>Annuler</button>
|
||||
<button
|
||||
onClick={() => void handleFinish()}
|
||||
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--ok)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44 }}
|
||||
>Clôturer</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,13 @@ export default function TodosPage() {
|
||||
<option value="cancelled">Annulé</option>
|
||||
<option value="">Tous</option>
|
||||
</select>
|
||||
<button
|
||||
className="hidden lg:flex"
|
||||
onClick={() => setShowForm(true)}
|
||||
style={{ alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, cursor: 'pointer', ...noSelect }}
|
||||
>
|
||||
<i className="fa-solid fa-plus" /> Nouvelle tâche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
Reference in New Issue
Block a user