diff --git a/local_backend/main.py b/local_backend/main.py
index 99c26ec..b6b08ce 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -21,6 +21,7 @@ import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAs
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
import models.reservation # noqa: F401
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
+import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -33,6 +34,7 @@ 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
+from routers.expenses import contacts_router, expenses_router
def _run_migrations():
@@ -271,6 +273,39 @@ 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 2D — expense tracking + supplier contacts
+ """CREATE TABLE IF NOT EXISTS contacts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name VARCHAR NOT NULL,
+ type VARCHAR NOT NULL DEFAULT 'supplier',
+ phone VARCHAR,
+ email VARCHAR,
+ address TEXT,
+ notes TEXT,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )""",
+ """CREATE TABLE IF NOT EXISTS expenses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ description VARCHAR NOT NULL,
+ category VARCHAR NOT NULL,
+ contact_id INTEGER REFERENCES contacts(id),
+ total_amount REAL NOT NULL,
+ paid_amount REAL NOT NULL DEFAULT 0.0,
+ due_date DATETIME,
+ business_day_id INTEGER REFERENCES business_days(id),
+ created_by_id INTEGER NOT NULL REFERENCES users(id),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ notes TEXT
+ )""",
+ """CREATE TABLE IF NOT EXISTS expense_payments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ expense_id INTEGER NOT NULL REFERENCES expenses(id),
+ amount REAL NOT NULL,
+ paid_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ paid_by_id INTEGER NOT NULL REFERENCES users(id),
+ notes TEXT
+ )""",
# Phase 2C — notes & todos
"""CREATE TABLE IF NOT EXISTS site_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -353,3 +388,5 @@ app.include_router(data_transfer_router.router, prefix="/api/data-transfer", ta
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"])
+app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
+app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
diff --git a/local_backend/models/expenses.py b/local_backend/models/expenses.py
new file mode 100644
index 0000000..449b525
--- /dev/null
+++ b/local_backend/models/expenses.py
@@ -0,0 +1,58 @@
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
+from sqlalchemy.orm import relationship
+from datetime import datetime, timezone
+from database import Base
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+class Contact(Base):
+ __tablename__ = "contacts"
+
+ id = Column(Integer, primary_key=True, index=True)
+ name = Column(String, nullable=False)
+ type = Column(String, default="supplier", nullable=False) # supplier|staff|utility|other
+ phone = Column(String, nullable=True)
+ email = Column(String, nullable=True)
+ address = Column(Text, nullable=True)
+ notes = Column(Text, nullable=True)
+ is_active = Column(Boolean, default=True, nullable=False)
+ created_at = Column(DateTime(timezone=True), default=_utcnow)
+
+ expenses = relationship("Expense", back_populates="contact")
+
+
+class Expense(Base):
+ __tablename__ = "expenses"
+
+ id = Column(Integer, primary_key=True, index=True)
+ description = Column(String, nullable=False)
+ category = Column(String, nullable=False) # supplier|utilities|rent|maintenance|staff|equipment|other
+ contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True)
+ total_amount = Column(Float, nullable=False)
+ paid_amount = Column(Float, default=0.0, nullable=False)
+ due_date = Column(DateTime(timezone=True), nullable=True)
+ business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
+ created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ created_at = Column(DateTime(timezone=True), default=_utcnow)
+ notes = Column(Text, nullable=True)
+
+ contact = relationship("Contact", back_populates="expenses")
+ created_by = relationship("User", foreign_keys=[created_by_id])
+ payments = relationship("ExpensePayment", back_populates="expense", cascade="all, delete-orphan")
+
+
+class ExpensePayment(Base):
+ __tablename__ = "expense_payments"
+
+ id = Column(Integer, primary_key=True, index=True)
+ expense_id = Column(Integer, ForeignKey("expenses.id"), nullable=False)
+ amount = Column(Float, nullable=False)
+ paid_at = Column(DateTime(timezone=True), default=_utcnow)
+ paid_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ notes = Column(Text, nullable=True)
+
+ expense = relationship("Expense", back_populates="payments")
+ paid_by = relationship("User", foreign_keys=[paid_by_id])
diff --git a/local_backend/routers/expenses.py b/local_backend/routers/expenses.py
new file mode 100644
index 0000000..e5d34cc
--- /dev/null
+++ b/local_backend/routers/expenses.py
@@ -0,0 +1,220 @@
+from datetime import datetime, timezone
+from fastapi import APIRouter, Depends, HTTPException, status, Query
+from sqlalchemy.orm import Session
+from typing import List, Optional
+
+from database import get_db
+from models.expenses import Contact, Expense, ExpensePayment
+from models.user import User
+from schemas.expenses import (
+ ContactCreate, ContactUpdate, ContactOut,
+ ExpenseCreate, ExpenseUpdate, ExpenseOut,
+ ExpensePaymentCreate, ExpensePaymentOut,
+)
+from routers.deps import require_manager
+
+contacts_router = APIRouter()
+expenses_router = APIRouter()
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+def _expense_status(total: float, paid: float) -> str:
+ if paid >= total:
+ return "paid"
+ if paid > 0:
+ return "partial"
+ return "due"
+
+
+def _expense_out(e: Expense) -> dict:
+ contact_name = None
+ if e.contact:
+ contact_name = e.contact.name
+ creator_name = None
+ if e.created_by:
+ creator_name = e.created_by.full_name or e.created_by.username
+ due_amount = round(max(0.0, e.total_amount - e.paid_amount), 2)
+ payments_out = []
+ for p in e.payments:
+ payer_name = None
+ if p.paid_by:
+ payer_name = p.paid_by.full_name or p.paid_by.username
+ payments_out.append({
+ "id": p.id,
+ "expense_id": p.expense_id,
+ "amount": p.amount,
+ "paid_at": p.paid_at,
+ "paid_by_id": p.paid_by_id,
+ "paid_by_name": payer_name,
+ "notes": p.notes,
+ })
+ return {
+ "id": e.id,
+ "description": e.description,
+ "category": e.category,
+ "contact_id": e.contact_id,
+ "contact_name": contact_name,
+ "total_amount": e.total_amount,
+ "paid_amount": e.paid_amount,
+ "due_amount": due_amount,
+ "status": _expense_status(e.total_amount, e.paid_amount),
+ "due_date": e.due_date,
+ "business_day_id": e.business_day_id,
+ "created_by_id": e.created_by_id,
+ "created_by_name": creator_name,
+ "created_at": e.created_at,
+ "notes": e.notes,
+ "payments": payments_out,
+ }
+
+
+# ── Contacts ──────────────────────────────────────────────────────────────────
+
+@contacts_router.get("/", response_model=List[ContactOut])
+def list_contacts(
+ include_inactive: bool = False,
+ db: Session = Depends(get_db),
+ user: User = Depends(require_manager),
+):
+ q = db.query(Contact)
+ if not include_inactive:
+ q = q.filter(Contact.is_active == True)
+ return q.order_by(Contact.name).all()
+
+
+@contacts_router.post("/", response_model=ContactOut, status_code=status.HTTP_201_CREATED)
+def create_contact(body: ContactCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ contact = Contact(**body.model_dump())
+ db.add(contact)
+ db.commit()
+ db.refresh(contact)
+ return contact
+
+
+@contacts_router.put("/{contact_id}", response_model=ContactOut)
+def update_contact(contact_id: int, body: ContactUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ contact = db.query(Contact).filter(Contact.id == contact_id).first()
+ if not contact:
+ raise HTTPException(status_code=404, detail="Contact not found")
+ for field, value in body.model_dump(exclude_none=True).items():
+ setattr(contact, field, value)
+ db.commit()
+ db.refresh(contact)
+ return contact
+
+
+@contacts_router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_contact(contact_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ contact = db.query(Contact).filter(Contact.id == contact_id).first()
+ if not contact:
+ raise HTTPException(status_code=404, detail="Contact not found")
+ contact.is_active = False
+ db.commit()
+
+
+# ── Expenses ──────────────────────────────────────────────────────────────────
+
+@expenses_router.get("/summary")
+def expense_summary(
+ db: Session = Depends(get_db),
+ user: User = Depends(require_manager),
+):
+ expenses = db.query(Expense).all()
+ total_due = sum(max(0.0, e.total_amount - e.paid_amount) for e in expenses if e.paid_amount < e.total_amount)
+ total_paid = sum(e.paid_amount for e in expenses)
+ by_category: dict = {}
+ for e in expenses:
+ c = e.category
+ if c not in by_category:
+ by_category[c] = {"category": c, "total": 0.0, "paid": 0.0, "due": 0.0}
+ by_category[c]["total"] += e.total_amount
+ by_category[c]["paid"] += e.paid_amount
+ by_category[c]["due"] += max(0.0, e.total_amount - e.paid_amount)
+ return {
+ "total_due": round(total_due, 2),
+ "total_paid": round(total_paid, 2),
+ "by_category": list(by_category.values()),
+ }
+
+
+@expenses_router.get("/", response_model=List[ExpenseOut])
+def list_expenses(
+ expense_status: Optional[str] = Query(default=None, alias="status"),
+ category: Optional[str] = None,
+ contact_id: Optional[int] = None,
+ business_day_id: Optional[int] = None,
+ db: Session = Depends(get_db),
+ user: User = Depends(require_manager),
+):
+ expenses = db.query(Expense).order_by(Expense.created_at.desc()).all()
+ result = [_expense_out(e) for e in expenses]
+ if expense_status:
+ result = [e for e in result if e["status"] == expense_status]
+ if category:
+ result = [e for e in result if e["category"] == category]
+ if contact_id:
+ result = [e for e in result if e["contact_id"] == contact_id]
+ if business_day_id:
+ result = [e for e in result if e["business_day_id"] == business_day_id]
+ return result
+
+
+@expenses_router.post("/", response_model=ExpenseOut, status_code=status.HTTP_201_CREATED)
+def create_expense(body: ExpenseCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ expense = Expense(**body.model_dump(), created_by_id=user.id)
+ db.add(expense)
+ db.commit()
+ db.refresh(expense)
+ return _expense_out(expense)
+
+
+@expenses_router.put("/{expense_id}", response_model=ExpenseOut)
+def update_expense(expense_id: int, body: ExpenseUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ expense = db.query(Expense).filter(Expense.id == expense_id).first()
+ if not expense:
+ raise HTTPException(status_code=404, detail="Expense not found")
+ for field, value in body.model_dump(exclude_none=True).items():
+ setattr(expense, field, value)
+ db.commit()
+ db.refresh(expense)
+ return _expense_out(expense)
+
+
+@expenses_router.delete("/{expense_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_expense(expense_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ expense = db.query(Expense).filter(Expense.id == expense_id).first()
+ if not expense:
+ raise HTTPException(status_code=404, detail="Expense not found")
+ db.delete(expense)
+ db.commit()
+
+
+@expenses_router.post("/{expense_id}/pay", response_model=ExpenseOut)
+def record_payment(
+ expense_id: int,
+ body: ExpensePaymentCreate,
+ db: Session = Depends(get_db),
+ user: User = Depends(require_manager),
+):
+ expense = db.query(Expense).filter(Expense.id == expense_id).first()
+ if not expense:
+ raise HTTPException(status_code=404, detail="Expense not found")
+ if body.amount <= 0:
+ raise HTTPException(status_code=400, detail="Payment amount must be positive")
+ payment = ExpensePayment(
+ expense_id=expense_id,
+ amount=body.amount,
+ paid_by_id=user.id,
+ notes=body.notes,
+ )
+ db.add(payment)
+ db.flush()
+ # recompute paid_amount from all payments (never trust client totals)
+ all_payments = db.query(ExpensePayment).filter(ExpensePayment.expense_id == expense_id).all()
+ expense.paid_amount = round(sum(p.amount for p in all_payments), 2)
+ db.commit()
+ db.refresh(expense)
+ return _expense_out(expense)
diff --git a/local_backend/schemas/expenses.py b/local_backend/schemas/expenses.py
new file mode 100644
index 0000000..ae6406f
--- /dev/null
+++ b/local_backend/schemas/expenses.py
@@ -0,0 +1,97 @@
+from pydantic import BaseModel
+from typing import Optional, List
+from datetime import datetime
+
+
+# ── Contacts ──────────────────────────────────────────────────────────────────
+
+class ContactCreate(BaseModel):
+ name: str
+ type: str = "supplier"
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ address: Optional[str] = None
+ notes: Optional[str] = None
+
+
+class ContactUpdate(BaseModel):
+ name: Optional[str] = None
+ type: Optional[str] = None
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ address: Optional[str] = None
+ notes: Optional[str] = None
+ is_active: Optional[bool] = None
+
+
+class ContactOut(BaseModel):
+ id: int
+ name: str
+ type: str
+ phone: Optional[str] = None
+ email: Optional[str] = None
+ address: Optional[str] = None
+ notes: Optional[str] = None
+ is_active: bool
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+# ── Expenses ──────────────────────────────────────────────────────────────────
+
+class ExpensePaymentCreate(BaseModel):
+ amount: float
+ notes: Optional[str] = None
+
+
+class ExpensePaymentOut(BaseModel):
+ id: int
+ expense_id: int
+ amount: float
+ paid_at: datetime
+ paid_by_id: int
+ paid_by_name: Optional[str] = None
+ notes: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+
+class ExpenseCreate(BaseModel):
+ description: str
+ category: str
+ contact_id: Optional[int] = None
+ total_amount: float
+ due_date: Optional[datetime] = None
+ business_day_id: Optional[int] = None
+ notes: Optional[str] = None
+
+
+class ExpenseUpdate(BaseModel):
+ description: Optional[str] = None
+ category: Optional[str] = None
+ contact_id: Optional[int] = None
+ total_amount: Optional[float] = None
+ due_date: Optional[datetime] = None
+ notes: Optional[str] = None
+
+
+class ExpenseOut(BaseModel):
+ id: int
+ description: str
+ category: str
+ contact_id: Optional[int] = None
+ contact_name: Optional[str] = None
+ total_amount: float
+ paid_amount: float
+ due_amount: float # computed: total - paid
+ status: str # "paid" | "partial" | "due"
+ due_date: Optional[datetime] = None
+ business_day_id: Optional[int] = None
+ created_by_id: int
+ created_by_name: Optional[str] = None
+ created_at: datetime
+ notes: Optional[str] = None
+ payments: List[ExpensePaymentOut] = []
+
+ model_config = {"from_attributes": True}
diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx
index 8c2e55d..e599ce5 100644
--- a/manager_dashboard/src/App.jsx
+++ b/manager_dashboard/src/App.jsx
@@ -12,6 +12,8 @@ import ReportsPage from './pages/reports/ReportsPage'
import SettingsPage from './pages/Settings/SettingsPage'
import OnlineOrdersPage from './pages/OnlineOrdersPage'
import NotesPage from './pages/NotesPage'
+import ContactsPage from './pages/ContactsPage'
+import ExpensesPage from './pages/ExpensesPage'
import client from './api/client'
function Spinner() {
@@ -83,6 +85,8 @@ export default function App() {
{contacts.length} ενεργές επαφές
+| {h} | + ))} +||||
|---|---|---|---|---|
|
+ {c.name}
+ {c.notes && {c.notes.slice(0, 60)}{c.notes.length > 60 ? '…' : ''} }
+ |
+ + + {TYPE_LABELS[c.type] || c.type} + + | +{c.phone || '—'} | +{c.email || '—'} | +
+
+
+
+
+ |
+
{expense.description}
+📝 {expense.notes}
+ )} ++ {pendingCount} εκκρεμή · Σύνολο οφειλών: 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)} +
+| {h} | + ))} +
|---|