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.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||
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 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 connect_orders as connect_orders_router
|
||||
from routers import reservations as reservations_router
|
||||
from routers import notes as notes_router
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
@@ -269,6 +271,25 @@ def _run_migrations():
|
||||
# Phase 2B — staff payroll
|
||||
"ALTER TABLE users ADD COLUMN hourly_rate 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:
|
||||
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(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
|
||||
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}
|
||||
Reference in New Issue
Block a user