feat: Phase 2D — expense tracking + supplier contact book
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"])
|
||||
|
||||
58
local_backend/models/expenses.py
Normal file
58
local_backend/models/expenses.py
Normal file
@@ -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])
|
||||
220
local_backend/routers/expenses.py
Normal file
220
local_backend/routers/expenses.py
Normal file
@@ -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)
|
||||
97
local_backend/schemas/expenses.py
Normal file
97
local_backend/schemas/expenses.py
Normal file
@@ -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}
|
||||
@@ -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() {
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="management" element={<ManagementPage />} />
|
||||
<Route path="notes" element={<NotesPage />} />
|
||||
<Route path="contacts" element={<ContactsPage />} />
|
||||
<Route path="expenses" element={<ExpensesPage />} />
|
||||
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -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: 'Ρυθμίσεις' },
|
||||
]
|
||||
|
||||
|
||||
218
manager_dashboard/src/pages/ContactsPage.jsx
Normal file
218
manager_dashboard/src/pages/ContactsPage.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 50, padding: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέα Επαφή' : 'Επεξεργασία Επαφής'}</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
|
||||
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Νίκος Γαλακτοπωλείο" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τύπος</label>
|
||||
<select style={inputStyle} value={form.type} onChange={e => f('type', e.target.value)}>
|
||||
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
|
||||
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
|
||||
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="info@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Διεύθυνση</label>
|
||||
<input style={inputStyle} value={form.address} onChange={e => f('address', e.target.value)} placeholder="Οδός, Πόλη" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
|
||||
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Εσωτερικές σημειώσεις…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onSave(form)}
|
||||
disabled={!canSave || isPending}
|
||||
style={{
|
||||
padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
cursor: canSave && !isPending ? 'pointer' : 'default',
|
||||
background: canSave ? '#111827' : '#e5e7eb',
|
||||
color: canSave ? 'white' : '#9ca3af',
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContactsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [modal, setModal] = useState(null) // null | { contact } | { contact: null } for new
|
||||
|
||||
const { data: contacts = [], isLoading } = useQuery({
|
||||
queryKey: ['contacts'],
|
||||
queryFn: () => client.get('/api/contacts/').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: ({ form, id }) => id
|
||||
? client.put(`/api/contacts/${id}`, form)
|
||||
: client.post('/api/contacts/', form),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['contacts'] })
|
||||
setModal(null)
|
||||
toast.success('Αποθηκεύτηκε')
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
|
||||
const deactivate = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/contacts/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['contacts'] })
|
||||
toast.success('Η επαφή αποενεργοποιήθηκε')
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'white' }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Επαφές / Προμηθευτές</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ contact: null })}
|
||||
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||
>
|
||||
+ Νέα Επαφή
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px' }}>
|
||||
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||
{!isLoading && contacts.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
|
||||
Δεν υπάρχουν επαφές ακόμα. Προσθέστε τον πρώτο προμηθευτή.
|
||||
</div>
|
||||
)}
|
||||
{contacts.length > 0 && (
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||||
{['Όνομα', 'Τύπος', 'Τηλέφωνο', 'Email', ''].map(h => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contacts.map((c, i) => {
|
||||
const tc = TYPE_COLORS[c.type] || TYPE_COLORS.other
|
||||
return (
|
||||
<tr key={c.id} style={{ borderBottom: i < contacts.length - 1 ? '1px solid #f3f4f6' : 'none', background: 'white' }}>
|
||||
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#111315' }}>
|
||||
{c.name}
|
||||
{c.notes && <div style={{ fontSize: 11.5, color: '#9ca3af', fontWeight: 400, marginTop: 1 }}>{c.notes.slice(0, 60)}{c.notes.length > 60 ? '…' : ''}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: tc.bg, color: tc.color }}>
|
||||
{TYPE_LABELS[c.type] || c.type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', color: c.phone ? '#374151' : '#d1d5db' }}>{c.phone || '—'}</td>
|
||||
<td style={{ padding: '12px 16px', color: c.email ? '#374151' : '#d1d5db' }}>{c.email || '—'}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setModal({ contact: c })}
|
||||
style={{ padding: '4px 12px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, fontWeight: 500, cursor: 'pointer', color: '#374151' }}
|
||||
>
|
||||
Επεξεργασία
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`Αποενεργοποίηση "${c.name}";`)) deactivate.mutate(c.id) }}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
|
||||
>
|
||||
Απενεργ.
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<ContactModal
|
||||
initial={modal.contact ? { ...modal.contact, phone: modal.contact.phone || '', email: modal.contact.email || '', address: modal.contact.address || '', notes: modal.contact.notes || '' } : null}
|
||||
onClose={() => setModal(null)}
|
||||
isPending={save.isPending}
|
||||
onSave={(form) => save.mutate({
|
||||
form: { name: form.name, type: form.type, phone: form.phone || null, email: form.email || null, address: form.address || null, notes: form.notes || null },
|
||||
id: modal.contact?.id,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
437
manager_dashboard/src/pages/ExpensesPage.jsx
Normal file
437
manager_dashboard/src/pages/ExpensesPage.jsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'supplier', label: 'Προμηθευτής' },
|
||||
{ id: 'utilities', label: 'Κοινή Χρεία' },
|
||||
{ id: 'rent', label: 'Ενοίκιο' },
|
||||
{ id: 'maintenance',label: 'Συντήρηση' },
|
||||
{ id: 'staff', label: 'Προσωπικό' },
|
||||
{ id: 'equipment', label: 'Εξοπλισμός' },
|
||||
{ id: 'other', label: 'Άλλο' },
|
||||
]
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
due: { label: 'Οφείλεται', bg: '#fee2e2', color: '#dc2626' },
|
||||
partial: { label: 'Μερική Πληρ.', bg: '#fef9c3', color: '#a16207' },
|
||||
paid: { label: 'Πληρώθηκε', bg: '#dcfce7', color: '#16a34a' },
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n) {
|
||||
if (n == null) return '—'
|
||||
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtDateTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
|
||||
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
|
||||
}
|
||||
|
||||
// ── Expense form modal ────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_FORM = { description: '', category: 'supplier', contact_id: '', total_amount: '', due_date: '', notes: '' }
|
||||
|
||||
function ExpenseModal({ initial, contacts, 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.description.trim() && parseFloat(form.total_amount) > 0
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
|
||||
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 540, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέο Έξοδο' : 'Επεξεργασία Εξόδου'}</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Περιγραφή *</label>
|
||||
<input style={inputStyle} value={form.description} onChange={e => f('description', e.target.value)} autoFocus placeholder="π.χ. Τιμολόγιο Νοεμβρίου — Γαλακτοκομικά" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Κατηγορία</label>
|
||||
<select style={inputStyle} value={form.category} onChange={e => f('category', e.target.value)}>
|
||||
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό (€) *</label>
|
||||
<input style={inputStyle} type="number" step="0.01" min="0" value={form.total_amount} onChange={e => f('total_amount', e.target.value)} placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Επαφή / Προμηθευτής</label>
|
||||
<select style={inputStyle} value={form.contact_id} onChange={e => f('contact_id', e.target.value)}>
|
||||
<option value="">— Χωρίς επαφή —</option>
|
||||
{contacts.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ημ/νία πληρωμής</label>
|
||||
<input style={inputStyle} type="date" value={form.due_date ? form.due_date.slice(0, 10) : ''} onChange={e => f('due_date', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
|
||||
<textarea style={{ ...inputStyle, resize: 'none' }} rows={2} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Προαιρετικές σημειώσεις…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onSave(form)}
|
||||
disabled={!canSave || isPending}
|
||||
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canSave && !isPending ? 'pointer' : 'default', background: canSave ? '#111827' : '#e5e7eb', color: canSave ? 'white' : '#9ca3af' }}
|
||||
>
|
||||
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pay modal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PayModal({ expense, onClose, onPay, isPending }) {
|
||||
const [amount, setAmount] = useState(String(Math.round((expense.due_amount) * 100) / 100))
|
||||
const [notes, setNotes] = useState('')
|
||||
const parsed = parseFloat(amount)
|
||||
const canPay = parsed > 0 && parsed <= expense.due_amount + 0.001
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
|
||||
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
|
||||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>Καταγραφή Πληρωμής</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#6b7280' }}>{expense.description}</p>
|
||||
</div>
|
||||
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ background: '#f9fafb', borderRadius: 10, padding: '12px 14px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>Εκκρεμεί</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: '#dc2626' }}>{fmt(expense.due_amount)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής (€)</label>
|
||||
<input style={inputStyle} type="number" step="0.01" min="0.01" max={expense.due_amount} value={amount} onChange={e => setAmount(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
|
||||
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)} placeholder="π.χ. Μετρητά στο χέρι" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onPay(parsed, notes || null)}
|
||||
disabled={!canPay || isPending}
|
||||
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay && !isPending ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}
|
||||
>
|
||||
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Expense row (expandable) ──────────────────────────────────────────────────
|
||||
|
||||
function ExpenseRow({ expense, isExpanded, onToggle, onPay, onEdit, onDelete }) {
|
||||
const sc = STATUS_CONFIG[expense.status] || STATUS_CONFIG.due
|
||||
const catLabel = CATEGORIES.find(c => c.id === expense.category)?.label || expense.category
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer', background: isExpanded ? '#f9fafb' : 'white' }}
|
||||
>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#111315', fontSize: 13.5 }}>{expense.description}</div>
|
||||
{expense.contact_name && <div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>{expense.contact_name}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 12, color: '#6b7280' }}>{catLabel}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 600, color: '#111315', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{fmt(expense.total_amount)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: '#16a34a', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{expense.paid_amount > 0 ? fmt(expense.paid_amount) : '—'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 700, color: '#dc2626', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{expense.due_amount > 0 ? fmt(expense.due_amount) : '—'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 12, color: '#9ca3af' }}>{expense.due_date ? fmtDate(expense.due_date) : '—'}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 5, justifyContent: 'flex-end' }}>
|
||||
{expense.status !== 'paid' && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onPay() }}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #bbf7d0', background: '#f0fdf4', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#16a34a' }}
|
||||
>
|
||||
Πληρωμή
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onEdit() }}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}
|
||||
>
|
||||
Επεξ.
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{isExpanded && (
|
||||
<tr style={{ background: '#f9fafb' }}>
|
||||
<td colSpan={8} style={{ padding: '12px 16px 16px', borderBottom: '1px solid #f0f0ef' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{expense.notes && (
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: '#6b7280', fontStyle: 'italic' }}>📝 {expense.notes}</p>
|
||||
)}
|
||||
<div style={{ fontSize: 11.5, color: '#9ca3af' }}>
|
||||
Καταχωρήθηκε από {expense.created_by_name} · {fmtDate(expense.created_at)}
|
||||
</div>
|
||||
{expense.payments.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Ιστορικό Πληρωμών</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{expense.payments.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', background: 'white', borderRadius: 8, border: '1px solid #e5e7eb' }}>
|
||||
<span style={{ fontSize: 12.5, color: '#374151' }}>
|
||||
{p.paid_by_name} · {fmtDateTime(p.paid_at)}
|
||||
{p.notes && <span style={{ color: '#9ca3af' }}> — {p.notes}</span>}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#16a34a' }}>{fmt(p.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expense.payments.length === 0 && expense.status === 'due' && (
|
||||
<div style={{ fontSize: 12.5, color: '#d1d5db' }}>Δεν έχουν καταγραφεί πληρωμές.</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const qc = useQueryClient()
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const [modal, setModal] = useState(null) // null | { type: 'expense', expense? } | { type: 'pay', expense }
|
||||
|
||||
const params = {}
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
if (categoryFilter !== 'all') params.category = categoryFilter
|
||||
|
||||
const { data: expenses = [], isLoading } = useQuery({
|
||||
queryKey: ['expenses', statusFilter, categoryFilter],
|
||||
queryFn: () => client.get('/api/expenses/', { params }).then(r => r.data),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const { data: contacts = [] } = useQuery({
|
||||
queryKey: ['contacts'],
|
||||
queryFn: () => client.get('/api/contacts/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['expenses-summary'],
|
||||
queryFn: () => client.get('/api/expenses/summary').then(r => r.data),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const saveExpense = useMutation({
|
||||
mutationFn: ({ form, id }) => id
|
||||
? client.put(`/api/expenses/${id}`, form)
|
||||
: client.post('/api/expenses/', form),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
|
||||
setModal(null)
|
||||
toast.success('Αποθηκεύτηκε')
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
|
||||
const payExpense = useMutation({
|
||||
mutationFn: ({ id, amount, notes }) => client.post(`/api/expenses/${id}/pay`, { amount, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
|
||||
setModal(null)
|
||||
toast.success('Πληρωμή καταγράφηκε')
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα καταγραφής πληρωμής'),
|
||||
})
|
||||
|
||||
const deleteExpense = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/expenses/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
|
||||
toast.success('Διαγράφηκε')
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||
})
|
||||
|
||||
const totalDue = summary?.total_due ?? 0
|
||||
const pendingCount = expenses.filter(e => e.status !== 'paid').length
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Έξοδα</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{pendingCount} εκκρεμή · Σύνολο οφειλών: <strong style={{ color: totalDue > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ type: 'expense', expense: null })}
|
||||
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||
>
|
||||
+ Νέο Έξοδο
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{/* Status filter */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[['all', 'Όλα'], ['due', 'Οφείλεται'], ['partial', 'Μερική'], ['paid', 'Πληρώθηκε']].map(([v, l]) => (
|
||||
<button key={v} onClick={() => setStatusFilter(v)}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', border: '1.5px solid',
|
||||
borderColor: statusFilter === v ? '#111827' : '#e5e7eb',
|
||||
background: statusFilter === v ? '#111827' : 'white',
|
||||
color: statusFilter === v ? 'white' : '#6b7280',
|
||||
}}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Category filter */}
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
style={{ padding: '5px 10px', borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12, outline: 'none', background: 'white', color: '#374151' }}
|
||||
>
|
||||
<option value="all">Όλες οι κατηγορίες</option>
|
||||
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px' }}>
|
||||
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||
{!isLoading && expenses.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
|
||||
Δεν βρέθηκαν έξοδα για τα επιλεγμένα φίλτρα.
|
||||
</div>
|
||||
)}
|
||||
{expenses.length > 0 && (
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||||
{['Περιγραφή', 'Κατηγορία', 'Σύνολο', 'Πληρωμένο', 'Εκκρεμεί', 'Κατάσταση', 'Ημ/νία', ''].map(h => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: h === 'Σύνολο' || h === 'Πληρωμένο' || h === 'Εκκρεμεί' ? 'right' : 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenses.map(e => (
|
||||
<ExpenseRow
|
||||
key={e.id}
|
||||
expense={e}
|
||||
isExpanded={expanded === e.id}
|
||||
onToggle={() => setExpanded(expanded === e.id ? null : e.id)}
|
||||
onPay={() => setModal({ type: 'pay', expense: e })}
|
||||
onEdit={() => setModal({ type: 'expense', expense: e })}
|
||||
onDelete={() => { if (window.confirm('Διαγραφή εξόδου;')) deleteExpense.mutate(e.id) }}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expense create/edit modal */}
|
||||
{modal?.type === 'expense' && (
|
||||
<ExpenseModal
|
||||
initial={modal.expense ? {
|
||||
...modal.expense,
|
||||
contact_id: modal.expense.contact_id ? String(modal.expense.contact_id) : '',
|
||||
due_date: modal.expense.due_date || '',
|
||||
notes: modal.expense.notes || '',
|
||||
} : null}
|
||||
contacts={contacts}
|
||||
onClose={() => setModal(null)}
|
||||
isPending={saveExpense.isPending}
|
||||
onSave={(form) => saveExpense.mutate({
|
||||
form: {
|
||||
description: form.description,
|
||||
category: form.category,
|
||||
contact_id: form.contact_id ? Number(form.contact_id) : null,
|
||||
total_amount: parseFloat(form.total_amount),
|
||||
due_date: form.due_date || null,
|
||||
notes: form.notes || null,
|
||||
},
|
||||
id: modal.expense?.id,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pay modal */}
|
||||
{modal?.type === 'pay' && (
|
||||
<PayModal
|
||||
expense={modal.expense}
|
||||
onClose={() => setModal(null)}
|
||||
isPending={payExpense.isPending}
|
||||
onPay={(amount, notes) => payExpense.mutate({ id: modal.expense.id, amount, notes })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user