diff --git a/local_backend/main.py b/local_backend/main.py index 8beef6d..99c26ec 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -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"]) diff --git a/local_backend/models/notes.py b/local_backend/models/notes.py new file mode 100644 index 0000000..b3d1e45 --- /dev/null +++ b/local_backend/models/notes.py @@ -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]) diff --git a/local_backend/routers/notes.py b/local_backend/routers/notes.py new file mode 100644 index 0000000..c198160 --- /dev/null +++ b/local_backend/routers/notes.py @@ -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() diff --git a/local_backend/schemas/notes.py b/local_backend/schemas/notes.py new file mode 100644 index 0000000..b828c0c --- /dev/null +++ b/local_backend/schemas/notes.py @@ -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} diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx index 7b4379e..8c2e55d 100644 --- a/manager_dashboard/src/App.jsx +++ b/manager_dashboard/src/App.jsx @@ -11,6 +11,7 @@ import ManagementPage from './pages/ManagementPage' import ReportsPage from './pages/reports/ReportsPage' import SettingsPage from './pages/Settings/SettingsPage' import OnlineOrdersPage from './pages/OnlineOrdersPage' +import NotesPage from './pages/NotesPage' import client from './api/client' function Spinner() { @@ -81,6 +82,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index 3e98bdd..e71867a 100644 --- a/manager_dashboard/src/components/Sidebar.jsx +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -1,6 +1,6 @@ import { NavLink } from 'react-router-dom' 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' export default function Sidebar() { @@ -26,6 +26,7 @@ export default function Sidebar() { { to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount }, { to: '/reports', icon: ClipboardList, label: 'Αναφορές' }, { to: '/management', icon: Package, label: 'Διαχείριση' }, + { to: '/notes', icon: NotebookPen, label: 'Σημειώσεις' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, ] diff --git a/manager_dashboard/src/pages/NotesPage.jsx b/manager_dashboard/src/pages/NotesPage.jsx new file mode 100644 index 0000000..2c25132 --- /dev/null +++ b/manager_dashboard/src/pages/NotesPage.jsx @@ -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 ( +
+ {editing ? ( +