From a675c2e091bb589d7cd91c16dac2b3712f803f84 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 8 Jun 2026 02:33:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202D=20=E2=80=94=20expense=20trac?= =?UTF-8?q?king=20+=20supplier=20contact=20book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New models: Contact, Expense, ExpensePayment (3 new tables) - Expense status (paid/partial/due) is always computed from paid_amount vs total_amount — never stored - paid_amount recomputed from sum of all payments on each payment write (no client trust) - New router /api/contacts/: list, create, update, soft-delete (is_active=false) - New router /api/expenses/: list (filter by status/category/contact), create, update, delete, record payment, summary (total_due, total_paid, by_category) - Migrations: CREATE TABLE IF NOT EXISTS for contacts, expenses, expense_payments Frontend: - ContactsPage (/contacts): searchable table, type badge (Προμηθευτής/Προσωπικό/Κοινή Χρεία/Άλλο), create/edit modal, soft-delete - ExpensesPage (/expenses): filter bar (status + category), expandable rows with payment history, summary header showing total outstanding, inline Payment modal (defaults to full due amount), create/edit expense modal - Sidebar: Receipt icon for Έξοδα, BookUser icon for Επαφές Co-Authored-By: Claude Sonnet 4.6 --- local_backend/main.py | 37 ++ local_backend/models/expenses.py | 58 +++ local_backend/routers/expenses.py | 220 ++++++++++ local_backend/schemas/expenses.py | 97 ++++ manager_dashboard/src/App.jsx | 4 + manager_dashboard/src/components/Sidebar.jsx | 4 +- manager_dashboard/src/pages/ContactsPage.jsx | 218 +++++++++ manager_dashboard/src/pages/ExpensesPage.jsx | 437 +++++++++++++++++++ 8 files changed, 1074 insertions(+), 1 deletion(-) create mode 100644 local_backend/models/expenses.py create mode 100644 local_backend/routers/expenses.py create mode 100644 local_backend/schemas/expenses.py create mode 100644 manager_dashboard/src/pages/ContactsPage.jsx create mode 100644 manager_dashboard/src/pages/ExpensesPage.jsx 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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index e71867a..4f9e9d9 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, NotebookPen } from 'lucide-react' +import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser } from 'lucide-react' import { getIncomingOrders } from '../api/client' export default function Sidebar() { @@ -27,6 +27,8 @@ export default function Sidebar() { { to: '/reports', icon: ClipboardList, label: 'Αναφορές' }, { to: '/management', icon: Package, label: 'Διαχείριση' }, { to: '/notes', icon: NotebookPen, label: 'Σημειώσεις' }, + { to: '/expenses', icon: Receipt, label: 'Έξοδα' }, + { to: '/contacts', icon: BookUser, label: 'Επαφές' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, ] diff --git a/manager_dashboard/src/pages/ContactsPage.jsx b/manager_dashboard/src/pages/ContactsPage.jsx new file mode 100644 index 0000000..2c6c686 --- /dev/null +++ b/manager_dashboard/src/pages/ContactsPage.jsx @@ -0,0 +1,218 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../api/client' + +const TYPE_LABELS = { + supplier: 'Προμηθευτής', + staff: 'Προσωπικό', + utility: 'Κοινή Χρεία', + other: 'Άλλο', +} +const TYPE_COLORS = { + supplier: { bg: '#eff6ff', color: '#1d4ed8' }, + staff: { bg: '#f0fdf4', color: '#15803d' }, + utility: { bg: '#fefce8', color: '#a16207' }, + other: { bg: '#f3f4f6', color: '#374151' }, +} + +const EMPTY_FORM = { name: '', type: 'supplier', phone: '', email: '', address: '', notes: '' } + +function ContactModal({ initial, onClose, onSave, isPending }) { + const [form, setForm] = useState(initial ?? EMPTY_FORM) + const f = (k, v) => setForm(p => ({ ...p, [k]: v })) + const isNew = !initial?.id + const canSave = form.name.trim() + + const inputStyle = { + width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', + borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', + background: 'white', + } + + return ( +
+
+
+

{isNew ? 'Νέα Επαφή' : 'Επεξεργασία Επαφής'}

+ +
+ +
+
+ + f('name', e.target.value)} autoFocus placeholder="π.χ. Νίκος Γαλακτοπωλείο" /> +
+
+ + +
+
+
+ + f('phone', e.target.value)} placeholder="π.χ. 6912345678" /> +
+
+ + f('email', e.target.value)} placeholder="info@example.com" /> +
+
+
+ + f('address', e.target.value)} placeholder="Οδός, Πόλη" /> +
+
+ +