feat: Phase 2C — notes & todos

- New models: SiteNote, SiteTodo (site_notes, site_todos tables)
- New schemas: NoteOut, TodoOut with creator/done-by name enrichment
- New router: /api/notes/ — full CRUD for notes and todos
  - Notes: create, list (pinned first), update (body + pin), delete
  - Todos: create, list (undone high-priority first), toggle done, edit, delete
  - Marking done records done_at + done_by_id; unchecking clears both
- Migrations: CREATE TABLE IF NOT EXISTS for both tables (additive, safe)
- NotesPage.jsx: two-column layout — notes left, todos right
  - Notes: inline click-to-edit, pin toggle, Ctrl+Enter to save, pinned section on top
  - Todos: inline edit, high-priority flag, completed collapse toggle
- /notes route added to App.jsx
- NotebookPen sidebar entry between Διαχείριση and Ρυθμίσεις

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:34:57 +03:00
parent b15fb27a37
commit cc356077ba
7 changed files with 766 additions and 1 deletions

View File

@@ -0,0 +1,501 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso) {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffMs = now - d
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'μόλις τώρα'
if (diffMins < 60) return `${diffMins}λ πριν`
const diffH = Math.floor(diffMins / 60)
if (diffH < 24) return `${diffH}ω πριν`
return d.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' })
}
// ── Notes column ──────────────────────────────────────────────────────────────
function NoteCard({ note, onSave, onDelete, onTogglePin }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(note.body)
const taRef = useRef(null)
useEffect(() => {
if (editing && taRef.current) {
taRef.current.focus()
taRef.current.selectionStart = taRef.current.value.length
}
}, [editing])
function handleBlur() {
if (draft.trim() === note.body) { setEditing(false); return }
if (!draft.trim()) { setEditing(false); setDraft(note.body); return }
onSave(note.id, draft.trim())
setEditing(false)
}
function handleKeyDown(e) {
if (e.key === 'Escape') { setEditing(false); setDraft(note.body) }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleBlur()
}
return (
<div style={{
border: note.is_pinned ? '1.5px solid #fbbf24' : '1px solid #e5e7eb',
borderRadius: 12,
background: note.is_pinned ? '#fffbeb' : 'white',
padding: '14px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}>
{editing ? (
<textarea
ref={taRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
style={{
width: '100%', minHeight: 80, border: '1.5px solid #3b82f6',
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, lineHeight: 1.55,
resize: 'vertical', outline: 'none', fontFamily: 'inherit',
background: 'white',
}}
/>
) : (
<p
onClick={() => { setEditing(true); setDraft(note.body) }}
style={{
fontSize: 13.5, color: '#111827', lineHeight: 1.6, cursor: 'text',
whiteSpace: 'pre-wrap', margin: 0, wordBreak: 'break-word',
}}
>
{note.body}
</p>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 11, color: '#9ca3af' }}>
{note.created_by_name} · {fmtDate(note.updated_at)}
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => onTogglePin(note.id, !note.is_pinned)}
title={note.is_pinned ? 'Αποσύνδεση' : 'Καρφίτσωμα'}
style={{
fontSize: 13, padding: '2px 7px', borderRadius: 6, border: '1px solid #e5e7eb',
background: note.is_pinned ? '#fef3c7' : 'white', cursor: 'pointer',
color: note.is_pinned ? '#d97706' : '#6b7280',
}}
>
{note.is_pinned ? '📌' : '📍'}
</button>
<button
onClick={() => { if (window.confirm('Διαγραφή σημείωσης;')) onDelete(note.id) }}
style={{
fontSize: 12, padding: '2px 7px', borderRadius: 6,
border: '1px solid #fecaca', background: 'white',
color: '#ef4444', cursor: 'pointer',
}}
></button>
</div>
</div>
</div>
)
}
function NotesColumn({ notes, onAdd, onSave, onDelete, onTogglePin }) {
const [newBody, setNewBody] = useState('')
const [newPinned, setNewPinned] = useState(false)
function submit() {
if (!newBody.trim()) return
onAdd(newBody.trim(), newPinned)
setNewBody('')
setNewPinned(false)
}
function onKeyDown(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit()
}
const pinned = notes.filter(n => n.is_pinned)
const rest = notes.filter(n => !n.is_pinned)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
{/* New note form */}
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 12, padding: '12px 14px', background: '#fafafa' }}>
<textarea
value={newBody}
onChange={e => setNewBody(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Νέα σημείωση… (Ctrl+Enter για αποθήκευση)"
style={{
width: '100%', minHeight: 72, border: 'none', outline: 'none',
background: 'transparent', fontSize: 13.5, lineHeight: 1.55,
resize: 'none', fontFamily: 'inherit', color: '#374151',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#6b7280', cursor: 'pointer' }}>
<input type="checkbox" checked={newPinned} onChange={e => setNewPinned(e.target.checked)} />
Καρφίτσωμα
</label>
<button
onClick={submit}
disabled={!newBody.trim()}
style={{
padding: '5px 14px', borderRadius: 7, border: 'none',
background: newBody.trim() ? '#111827' : '#e5e7eb',
color: newBody.trim() ? 'white' : '#9ca3af',
fontSize: 12.5, fontWeight: 600, cursor: newBody.trim() ? 'pointer' : 'default',
}}
>
Αποθήκευση
</button>
</div>
</div>
{/* Notes list */}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{pinned.length > 0 && (
<>
<div style={{ fontSize: 10, fontWeight: 700, color: '#d97706', textTransform: 'uppercase', letterSpacing: '0.06em' }}>📌 Καρφιτσωμένες</div>
{pinned.map(n => (
<NoteCard key={n.id} note={n} onSave={onSave} onDelete={onDelete} onTogglePin={onTogglePin} />
))}
</>
)}
{rest.length > 0 && (
<>
{pinned.length > 0 && <div style={{ fontSize: 10, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginTop: 4 }}>Πρόσφατες</div>}
{rest.map(n => (
<NoteCard key={n.id} note={n} onSave={onSave} onDelete={onDelete} onTogglePin={onTogglePin} />
))}
</>
)}
{notes.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>
Δεν υπάρχουν σημειώσεις ακόμα.
</div>
)}
</div>
</div>
)
}
// ── Todos column ──────────────────────────────────────────────────────────────
function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(todo.body)
const inputRef = useRef(null)
useEffect(() => {
if (editing && inputRef.current) inputRef.current.focus()
}, [editing])
function saveEdit() {
if (!draft.trim() || draft.trim() === todo.body) { setEditing(false); setDraft(todo.body); return }
onEdit(todo.id, draft.trim())
setEditing(false)
}
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '10px 12px', borderRadius: 10,
border: `1px solid ${todo.is_done ? '#e5e7eb' : todo.priority === 'high' ? '#fca5a5' : '#e5e7eb'}`,
background: todo.is_done ? '#fafafa' : todo.priority === 'high' ? '#fff5f5' : 'white',
opacity: todo.is_done ? 0.65 : 1,
}}>
{/* Checkbox */}
<button
onClick={() => onToggle(todo.id, !todo.is_done)}
style={{
width: 20, height: 20, borderRadius: 5, flexShrink: 0, marginTop: 1,
border: `2px solid ${todo.is_done ? '#22c55e' : todo.priority === 'high' ? '#ef4444' : '#d1d5db'}`,
background: todo.is_done ? '#22c55e' : 'white',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{todo.is_done && <span style={{ color: 'white', fontSize: 11, lineHeight: 1 }}></span>}
</button>
{/* Body */}
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<input
ref={inputRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={saveEdit}
onKeyDown={e => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') { setEditing(false); setDraft(todo.body) } }}
style={{
width: '100%', border: '1.5px solid #3b82f6', borderRadius: 6,
padding: '3px 7px', fontSize: 13.5, outline: 'none', fontFamily: 'inherit',
}}
/>
) : (
<p
onClick={() => { if (!todo.is_done) { setEditing(true); setDraft(todo.body) } }}
style={{
margin: 0, fontSize: 13.5, lineHeight: 1.5, wordBreak: 'break-word',
color: todo.is_done ? '#9ca3af' : '#111827',
textDecoration: todo.is_done ? 'line-through' : 'none',
cursor: todo.is_done ? 'default' : 'text',
}}
>
{todo.priority === 'high' && !todo.is_done && (
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', marginRight: 5 }}> ΕΠΕΙΓΟΝ</span>
)}
{todo.body}
</p>
)}
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 3 }}>
{todo.created_by_name} · {fmtDate(todo.created_at)}
{todo.is_done && todo.done_by_name && (
<span style={{ color: '#22c55e' }}> · {todo.done_by_name}</span>
)}
</div>
</div>
{/* Delete */}
<button
onClick={() => { if (window.confirm('Διαγραφή εργασίας;')) onDelete(todo.id) }}
style={{
fontSize: 11, padding: '2px 6px', borderRadius: 5, flexShrink: 0,
border: '1px solid #fecaca', background: 'white', color: '#ef4444', cursor: 'pointer',
}}
></button>
</div>
)
}
function TodosColumn({ todos, onAdd, onToggle, onDelete, onEdit }) {
const [newBody, setNewBody] = useState('')
const [newPriority, setNewPriority] = useState('normal')
const [showDone, setShowDone] = useState(false)
function submit() {
if (!newBody.trim()) return
onAdd(newBody.trim(), newPriority)
setNewBody('')
setNewPriority('normal')
}
const undone = todos.filter(t => !t.is_done)
const done = todos.filter(t => t.is_done)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
{/* New todo form */}
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 12, padding: '12px 14px', background: '#fafafa' }}>
<input
value={newBody}
onChange={e => setNewBody(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit() }}
placeholder="Νέα εργασία… (Enter)"
style={{
width: '100%', border: 'none', outline: 'none', background: 'transparent',
fontSize: 13.5, fontFamily: 'inherit', color: '#374151',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', gap: 6 }}>
{['normal', 'high'].map(p => (
<button
key={p}
onClick={() => setNewPriority(p)}
style={{
padding: '3px 10px', borderRadius: 6, fontSize: 11.5, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${newPriority === p ? (p === 'high' ? '#ef4444' : '#6b7280') : '#e5e7eb'}`,
background: newPriority === p ? (p === 'high' ? '#fee2e2' : '#f3f4f6') : 'white',
color: newPriority === p ? (p === 'high' ? '#ef4444' : '#374151') : '#9ca3af',
}}
>
{p === 'high' ? '● Επείγον' : 'Κανονικό'}
</button>
))}
</div>
<button
onClick={submit}
disabled={!newBody.trim()}
style={{
padding: '5px 14px', borderRadius: 7, border: 'none',
background: newBody.trim() ? '#111827' : '#e5e7eb',
color: newBody.trim() ? 'white' : '#9ca3af',
fontSize: 12.5, fontWeight: 600, cursor: newBody.trim() ? 'pointer' : 'default',
}}
>
Προσθήκη
</button>
</div>
</div>
{/* Todo list */}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{undone.length === 0 && done.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>
Δεν υπάρχουν εργασίες ακόμα.
</div>
)}
{undone.map(t => (
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} onEdit={onEdit} />
))}
{done.length > 0 && (
<button
onClick={() => setShowDone(s => !s)}
style={{
marginTop: 4, fontSize: 12, color: '#9ca3af', fontWeight: 600,
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: '4px 0',
}}
>
{showDone ? '▾' : '▸'} Ολοκληρωμένες ({done.length})
</button>
)}
{showDone && done.map(t => (
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} onEdit={onEdit} />
))}
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function NotesPage() {
const qc = useQueryClient()
const { data: notes = [] } = useQuery({
queryKey: ['site-notes'],
queryFn: () => client.get('/api/notes/').then(r => r.data),
staleTime: 30_000,
})
const { data: todos = [] } = useQuery({
queryKey: ['site-todos'],
queryFn: () => client.get('/api/notes/todos').then(r => r.data),
staleTime: 30_000,
})
// ── Note mutations ──────────────────────────────────────────────────────────
const addNote = useMutation({
mutationFn: ({ body, is_pinned }) => client.post('/api/notes/', { body, is_pinned }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const saveNote = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/notes/${id}`, { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const pinNote = useMutation({
mutationFn: ({ id, is_pinned }) => client.put(`/api/notes/${id}`, { is_pinned }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
})
const deleteNote = useMutation({
mutationFn: (id) => client.delete(`/api/notes/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
onError: () => toast.error('Σφάλμα διαγραφής'),
})
// ── Todo mutations ──────────────────────────────────────────────────────────
const addTodo = useMutation({
mutationFn: ({ body, priority }) => client.post('/api/notes/todos', { body, priority }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const toggleTodo = useMutation({
mutationFn: ({ id, is_done }) => client.put(`/api/notes/todos/${id}`, { is_done }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
})
const editTodo = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/notes/todos/${id}`, { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteTodo = useMutation({
mutationFn: (id) => client.delete(`/api/notes/todos/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
onError: () => toast.error('Σφάλμα διαγραφής'),
})
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Page header */}
<div style={{
padding: '18px 28px 14px',
borderBottom: '1px solid #f0f0ef',
display: 'flex', alignItems: 'baseline', gap: 16, flexShrink: 0,
background: 'white',
}}>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Σημειώσεις & Εργασίες</h1>
<span style={{ fontSize: 13, color: '#9ca3af' }}>
{notes.length} σημειώσεις · {todos.filter(t => !t.is_done).length} εκκρεμείς εργασίες
</span>
</div>
{/* Two-column body */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: 0, flex: 1, overflow: 'hidden',
}}>
{/* Left: Notes */}
<div style={{
borderRight: '1px solid #f0f0ef',
padding: '20px 24px',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
Σημειώσεις
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<NotesColumn
notes={notes}
onAdd={(body, is_pinned) => addNote.mutate({ body, is_pinned })}
onSave={(id, body) => saveNote.mutate({ id, body })}
onDelete={(id) => deleteNote.mutate(id)}
onTogglePin={(id, is_pinned) => pinNote.mutate({ id, is_pinned })}
/>
</div>
</div>
{/* Right: Todos */}
<div style={{
padding: '20px 24px',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
Εργασίες
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TodosColumn
todos={todos}
onAdd={(body, priority) => addTodo.mutate({ body, priority })}
onToggle={(id, is_done) => toggleTodo.mutate({ id, is_done })}
onEdit={(id, body) => editTodo.mutate({ id, body })}
onDelete={(id) => deleteTodo.mutate(id)}
/>
</div>
</div>
</div>
</div>
)
}