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:
@@ -20,6 +20,7 @@ import models.settings # noqa: F401
|
|||||||
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
||||||
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||||
import models.reservation # noqa: F401
|
import models.reservation # noqa: F401
|
||||||
|
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
||||||
|
|
||||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||||
from routers import business_day as business_day_router
|
from routers import business_day as business_day_router
|
||||||
@@ -31,6 +32,7 @@ from routers import sse as sse_router
|
|||||||
from routers import data_transfer as data_transfer_router
|
from routers import data_transfer as data_transfer_router
|
||||||
from routers import connect_orders as connect_orders_router
|
from routers import connect_orders as connect_orders_router
|
||||||
from routers import reservations as reservations_router
|
from routers import reservations as reservations_router
|
||||||
|
from routers import notes as notes_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -269,6 +271,25 @@ def _run_migrations():
|
|||||||
# Phase 2B — staff payroll
|
# Phase 2B — staff payroll
|
||||||
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
||||||
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
|
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
|
||||||
|
# Phase 2C — notes & todos
|
||||||
|
"""CREATE TABLE IF NOT EXISTS site_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_pinned INTEGER NOT NULL DEFAULT 0
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS site_todos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
done_at DATETIME,
|
||||||
|
done_by_id INTEGER REFERENCES users(id),
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
priority VARCHAR NOT NULL DEFAULT 'normal'
|
||||||
|
)""",
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -331,3 +352,4 @@ app.include_router(sse_router.router, prefix="/api/sse", tag
|
|||||||
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
|
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
|
||||||
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
|
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
|
||||||
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])
|
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])
|
||||||
|
app.include_router(notes_router.router, prefix="/api/notes", tags=["notes"])
|
||||||
|
|||||||
37
local_backend/models/notes.py
Normal file
37
local_backend/models/notes.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteNote(Base):
|
||||||
|
__tablename__ = "site_notes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
is_pinned = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
|
||||||
|
class SiteTodo(Base):
|
||||||
|
__tablename__ = "site_todos"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
is_done = Column(Boolean, default=False, nullable=False)
|
||||||
|
done_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
done_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
priority = Column(String, default="normal", nullable=False) # "normal" | "high"
|
||||||
|
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
done_by = relationship("User", foreign_keys=[done_by_id])
|
||||||
151
local_backend/routers/notes.py
Normal file
151
local_backend/routers/notes.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.notes import SiteNote, SiteTodo
|
||||||
|
from models.user import User
|
||||||
|
from schemas.notes import NoteCreate, NoteUpdate, NoteOut, TodoCreate, TodoUpdate, TodoOut
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _note_out(n: SiteNote) -> dict:
|
||||||
|
name = None
|
||||||
|
if n.created_by:
|
||||||
|
name = n.created_by.full_name or n.created_by.username
|
||||||
|
return {
|
||||||
|
"id": n.id,
|
||||||
|
"body": n.body,
|
||||||
|
"is_pinned": n.is_pinned,
|
||||||
|
"created_by_id": n.created_by_id,
|
||||||
|
"created_by_name": name,
|
||||||
|
"created_at": n.created_at,
|
||||||
|
"updated_at": n.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _todo_out(t: SiteTodo) -> dict:
|
||||||
|
creator_name = None
|
||||||
|
if t.created_by:
|
||||||
|
creator_name = t.created_by.full_name or t.created_by.username
|
||||||
|
done_name = None
|
||||||
|
if t.done_by:
|
||||||
|
done_name = t.done_by.full_name or t.done_by.username
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"body": t.body,
|
||||||
|
"is_done": t.is_done,
|
||||||
|
"done_at": t.done_at,
|
||||||
|
"done_by_id": t.done_by_id,
|
||||||
|
"done_by_name": done_name,
|
||||||
|
"created_by_id": t.created_by_id,
|
||||||
|
"created_by_name": creator_name,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"priority": t.priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[NoteOut])
|
||||||
|
def list_notes(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
notes = db.query(SiteNote).order_by(
|
||||||
|
SiteNote.is_pinned.desc(), SiteNote.updated_at.desc()
|
||||||
|
).all()
|
||||||
|
return [_note_out(n) for n in notes]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=NoteOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_note(body: NoteCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
note = SiteNote(body=body.body, is_pinned=body.is_pinned, created_by_id=user.id)
|
||||||
|
db.add(note)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(note)
|
||||||
|
return _note_out(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{note_id}", response_model=NoteOut)
|
||||||
|
def update_note(note_id: int, body: NoteUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
note = db.query(SiteNote).filter(SiteNote.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
if body.body is not None:
|
||||||
|
note.body = body.body
|
||||||
|
if body.is_pinned is not None:
|
||||||
|
note.is_pinned = body.is_pinned
|
||||||
|
note.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(note)
|
||||||
|
return _note_out(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_note(note_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
note = db.query(SiteNote).filter(SiteNote.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
db.delete(note)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Todos ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/todos", response_model=List[TodoOut])
|
||||||
|
def list_todos(done: bool = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
q = db.query(SiteTodo)
|
||||||
|
if done is not None:
|
||||||
|
q = q.filter(SiteTodo.is_done == done)
|
||||||
|
# undone high-priority first, then undone normal, then done (by created_at desc)
|
||||||
|
todos = q.order_by(
|
||||||
|
SiteTodo.is_done.asc(),
|
||||||
|
SiteTodo.priority.desc(),
|
||||||
|
SiteTodo.created_at.desc(),
|
||||||
|
).all()
|
||||||
|
return [_todo_out(t) for t in todos]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/todos", response_model=TodoOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_todo(body: TodoCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
todo = SiteTodo(body=body.body, priority=body.priority, created_by_id=user.id)
|
||||||
|
db.add(todo)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
return _todo_out(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/todos/{todo_id}", response_model=TodoOut)
|
||||||
|
def update_todo(todo_id: int, body: TodoUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
todo = db.query(SiteTodo).filter(SiteTodo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
if body.body is not None:
|
||||||
|
todo.body = body.body
|
||||||
|
if body.priority is not None:
|
||||||
|
todo.priority = body.priority
|
||||||
|
if body.is_done is not None:
|
||||||
|
todo.is_done = body.is_done
|
||||||
|
if body.is_done and not todo.done_at:
|
||||||
|
todo.done_at = _utcnow()
|
||||||
|
todo.done_by_id = user.id
|
||||||
|
elif not body.is_done:
|
||||||
|
todo.done_at = None
|
||||||
|
todo.done_by_id = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
return _todo_out(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_todo(todo_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
todo = db.query(SiteTodo).filter(SiteTodo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
db.delete(todo)
|
||||||
|
db.commit()
|
||||||
51
local_backend/schemas/notes.py
Normal file
51
local_backend/schemas/notes.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NoteCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
is_pinned: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class NoteUpdate(BaseModel):
|
||||||
|
body: Optional[str] = None
|
||||||
|
is_pinned: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
body: str
|
||||||
|
is_pinned: bool
|
||||||
|
created_by_id: int
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TodoCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
priority: str = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class TodoUpdate(BaseModel):
|
||||||
|
body: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
is_done: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TodoOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
body: str
|
||||||
|
is_done: bool
|
||||||
|
done_at: Optional[datetime] = None
|
||||||
|
done_by_id: Optional[int] = None
|
||||||
|
done_by_name: Optional[str] = None
|
||||||
|
created_by_id: int
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
priority: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -11,6 +11,7 @@ import ManagementPage from './pages/ManagementPage'
|
|||||||
import ReportsPage from './pages/reports/ReportsPage'
|
import ReportsPage from './pages/reports/ReportsPage'
|
||||||
import SettingsPage from './pages/Settings/SettingsPage'
|
import SettingsPage from './pages/Settings/SettingsPage'
|
||||||
import OnlineOrdersPage from './pages/OnlineOrdersPage'
|
import OnlineOrdersPage from './pages/OnlineOrdersPage'
|
||||||
|
import NotesPage from './pages/NotesPage'
|
||||||
import client from './api/client'
|
import client from './api/client'
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -81,6 +82,7 @@ export default function App() {
|
|||||||
<Route path="tables" element={<TablesPage />} />
|
<Route path="tables" element={<TablesPage />} />
|
||||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||||
<Route path="management" element={<ManagementPage />} />
|
<Route path="management" element={<ManagementPage />} />
|
||||||
|
<Route path="notes" element={<NotesPage />} />
|
||||||
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag } from 'lucide-react'
|
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen } from 'lucide-react'
|
||||||
import { getIncomingOrders } from '../api/client'
|
import { getIncomingOrders } from '../api/client'
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@@ -26,6 +26,7 @@ export default function Sidebar() {
|
|||||||
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount },
|
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount },
|
||||||
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
||||||
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
||||||
|
{ to: '/notes', icon: NotebookPen, label: 'Σημειώσεις' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
501
manager_dashboard/src/pages/NotesPage.jsx
Normal file
501
manager_dashboard/src/pages/NotesPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user