diff --git a/local_backend/main.py b/local_backend/main.py index a7ca7fd..722f131 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -23,6 +23,7 @@ import models.reservation # noqa: F401 import models.notes # noqa: F401 — registers SiteNote, SiteTodo import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment import models.customers # noqa: F401 — registers Customer +import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router from routers import business_day as business_day_router @@ -37,6 +38,7 @@ from routers import reservations as reservations_router from routers import notes as notes_router from routers.expenses import contacts_router, expenses_router from routers import customers as customers_router +from routers import tabs as tabs_router def _run_migrations(): @@ -275,6 +277,35 @@ 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 2G — pay-later tab system + """CREATE TABLE IF NOT EXISTS tabs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), + status VARCHAR NOT NULL DEFAULT 'open', + opened_at DATETIME DEFAULT CURRENT_TIMESTAMP, + closed_at DATETIME, + closed_by_id INTEGER REFERENCES users(id), + notes TEXT + )""", + """CREATE TABLE IF NOT EXISTS tab_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tab_id INTEGER NOT NULL REFERENCES tabs(id), + order_id INTEGER REFERENCES orders(id), + order_item_id INTEGER REFERENCES order_items(id), + amount REAL NOT NULL, + description TEXT, + created_by_id INTEGER NOT NULL REFERENCES users(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + """CREATE TABLE IF NOT EXISTS tab_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tab_id INTEGER NOT NULL REFERENCES tabs(id), + amount REAL NOT NULL, + payment_method VARCHAR, + received_by_id INTEGER NOT NULL REFERENCES users(id), + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", # Phase 2F — customer CRM """CREATE TABLE IF NOT EXISTS customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -412,3 +443,4 @@ app.include_router(notes_router.router, prefix="/api/notes", tag app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"]) app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"]) app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"]) +app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"]) diff --git a/local_backend/models/tabs.py b/local_backend/models/tabs.py new file mode 100644 index 0000000..b4cdef0 --- /dev/null +++ b/local_backend/models/tabs.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 Tab(Base): + __tablename__ = "tabs" + + id = Column(Integer, primary_key=True, index=True) + customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False) + status = Column(String, default="open", nullable=False) # open | closed | forgiven + opened_at = Column(DateTime(timezone=True), default=_utcnow) + closed_at = Column(DateTime(timezone=True), nullable=True) + closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + notes = Column(Text, nullable=True) + + customer = relationship("Customer", foreign_keys=[customer_id]) + closed_by = relationship("User", foreign_keys=[closed_by_id]) + entries = relationship("TabEntry", back_populates="tab", cascade="all, delete-orphan") + payments = relationship("TabPayment", back_populates="tab", cascade="all, delete-orphan") + + +class TabEntry(Base): + """What went ON the tab — a deferred charge.""" + __tablename__ = "tab_entries" + + id = Column(Integer, primary_key=True, index=True) + tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=True) + order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) + amount = Column(Float, nullable=False) # snapshot at time of tab entry + description = Column(Text, nullable=True) # auto-generated summary + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), default=_utcnow) + + tab = relationship("Tab", back_populates="entries") + created_by = relationship("User", foreign_keys=[created_by_id]) + + +class TabPayment(Base): + """What came OFF the tab — a payment reducing the balance.""" + __tablename__ = "tab_payments" + + id = Column(Integer, primary_key=True, index=True) + tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False) + amount = Column(Float, nullable=False) + payment_method = Column(String, nullable=True) # cash | card | other + received_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + notes = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), default=_utcnow) + + tab = relationship("Tab", back_populates="payments") + received_by = relationship("User", foreign_keys=[received_by_id]) diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index b2f3376..322bffc 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -583,6 +583,10 @@ class AssignCustomerBody(BaseModel): customer_id: Optional[int] = None # null = unassign +class TabItemBody(BaseModel): + pass # no body needed — tab derived from order's customer + + @router.put("/{order_id}/customer") def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)): from models.customers import Customer @@ -958,3 +962,55 @@ def print_synopsis( background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width) return {"status": "printing"} + + +# ─── Phase 2G: Put item on customer tab ─────────────────────────────────────── + +@router.post("/{order_id}/items/{item_id}/tab") +def put_item_on_tab( + order_id: int, + item_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + from models.tabs import Tab, TabEntry + + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not order.customer_id: + raise HTTPException(status_code=400, detail="Order has no customer assigned — assign a customer first") + + item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if item.status != "active": + raise HTTPException(status_code=400, detail=f"Item is already {item.status} — cannot tab it") + + # Find or auto-create the customer's open tab + tab = db.query(Tab).filter(Tab.customer_id == order.customer_id, Tab.status == "open").first() + if not tab: + tab = Tab(customer_id=order.customer_id) + db.add(tab) + db.flush() + + # Build description + product_name = item.product.name if item.product else f"#{item.product_id}" + description = f"{product_name} ×{item.quantity} @ €{item.unit_price:.2f}" + + entry = TabEntry( + tab_id=tab.id, + order_id=order_id, + order_item_id=item_id, + amount=round(item.unit_price * item.quantity, 2), + description=description, + created_by_id=user.id, + ) + db.add(entry) + + # Mark item as tabbed — distinct from active/paid/cancelled + item.status = "tabbed" + + db.commit() + broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "item_tabbed"}) + return {"status": "tabbed", "tab_id": tab.id, "entry_amount": entry.amount} diff --git a/local_backend/routers/tabs.py b/local_backend/routers/tabs.py new file mode 100644 index 0000000..c4aeef1 --- /dev/null +++ b/local_backend/routers/tabs.py @@ -0,0 +1,223 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from database import get_db +from models.tabs import Tab, TabEntry, TabPayment +from models.customers import Customer +from models.order import Order, OrderItem +from models.user import User +from schemas.tabs import TabOut, TabEntryOut, TabPaymentOut, TabPayRequest, TabForgiveRequest, TabCloseRequest +from routers.deps import require_manager + +router = APIRouter() + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _tab_out(tab: Tab) -> dict: + c = tab.customer + customer_name = c.name if c else None + if c and c.nickname: + customer_name = f"{c.name} «{c.nickname}»" + customer_phone = c.phone if c else None + + total_charged = round(sum(e.amount for e in tab.entries), 2) + total_paid = round(sum(p.amount for p in tab.payments), 2) + balance = round(total_charged - total_paid, 2) + + closed_by_name = None + if tab.closed_by: + closed_by_name = tab.closed_by.full_name or tab.closed_by.username + + entries_out = [] + for e in sorted(tab.entries, key=lambda x: x.created_at): + creator = e.created_by.full_name or e.created_by.username if e.created_by else None + entries_out.append({ + "id": e.id, "tab_id": e.tab_id, "order_id": e.order_id, + "order_item_id": e.order_item_id, "amount": e.amount, + "description": e.description, "created_by_id": e.created_by_id, + "created_by_name": creator, "created_at": e.created_at, + }) + + payments_out = [] + for p in sorted(tab.payments, key=lambda x: x.created_at): + receiver = p.received_by.full_name or p.received_by.username if p.received_by else None + payments_out.append({ + "id": p.id, "tab_id": p.tab_id, "amount": p.amount, + "payment_method": p.payment_method, "received_by_id": p.received_by_id, + "received_by_name": receiver, "notes": p.notes, "created_at": p.created_at, + }) + + return { + "id": tab.id, + "customer_id": tab.customer_id, + "customer_name": customer_name, + "customer_phone": customer_phone, + "status": tab.status, + "opened_at": tab.opened_at, + "closed_at": tab.closed_at, + "closed_by_id": tab.closed_by_id, + "notes": tab.notes, + "balance": balance, + "total_charged": total_charged, + "total_paid": total_paid, + "entries": entries_out, + "payments": payments_out, + } + + +@router.get("/", response_model=List[TabOut]) +def list_tabs( + tab_status: Optional[str] = None, + customer_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(Tab) + if tab_status: + q = q.filter(Tab.status == tab_status) + else: + q = q.filter(Tab.status == "open") # default: only open tabs + if customer_id: + q = q.filter(Tab.customer_id == customer_id) + tabs = q.order_by(Tab.opened_at.desc()).all() + result = [_tab_out(t) for t in tabs] + # Sort open tabs by balance descending + result.sort(key=lambda x: x["balance"], reverse=True) + return result + + +@router.get("/{tab_id}", response_model=TabOut) +def get_tab(tab_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + tab = db.query(Tab).filter(Tab.id == tab_id).first() + if not tab: + raise HTTPException(status_code=404, detail="Tab not found") + return _tab_out(tab) + + +@router.post("/", response_model=TabOut, status_code=status.HTTP_201_CREATED) +def open_tab(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + customer = db.query(Customer).filter(Customer.id == customer_id, Customer.is_active == True).first() + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + existing = db.query(Tab).filter(Tab.customer_id == customer_id, Tab.status == "open").first() + if existing: + raise HTTPException(status_code=400, detail="Customer already has an open tab") + tab = Tab(customer_id=customer_id) + db.add(tab) + db.commit() + db.refresh(tab) + return _tab_out(tab) + + +@router.post("/{tab_id}/entries", response_model=TabOut) +def add_tab_entry( + tab_id: int, + order_id: Optional[int] = None, + order_item_id: Optional[int] = None, + amount: float = 0.0, + description: Optional[str] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first() + if not tab: + raise HTTPException(status_code=404, detail="Open tab not found") + if amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + entry = TabEntry( + tab_id=tab_id, order_id=order_id, order_item_id=order_item_id, + amount=amount, description=description, created_by_id=user.id, + ) + db.add(entry) + db.commit() + db.refresh(tab) + return _tab_out(tab) + + +@router.post("/{tab_id}/pay", response_model=TabOut) +def pay_tab( + tab_id: int, + body: TabPayRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first() + if not tab: + raise HTTPException(status_code=404, detail="Open tab not found") + if body.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + total_charged = sum(e.amount for e in tab.entries) + total_paid = sum(p.amount for p in tab.payments) + balance = total_charged - total_paid + if body.amount > balance + 0.005: + raise HTTPException(status_code=400, detail=f"Payment exceeds balance ({balance:.2f})") + payment = TabPayment( + tab_id=tab_id, amount=body.amount, + payment_method=body.payment_method, notes=body.notes, + received_by_id=user.id, + ) + db.add(payment) + db.commit() + db.refresh(tab) + return _tab_out(tab) + + +@router.post("/{tab_id}/close", response_model=TabOut) +def close_tab( + tab_id: int, + body: TabCloseRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first() + if not tab: + raise HTTPException(status_code=404, detail="Open tab not found") + total_charged = sum(e.amount for e in tab.entries) + total_paid = sum(p.amount for p in tab.payments) + balance = round(total_charged - total_paid, 2) + if balance > 0.005: + raise HTTPException(status_code=400, detail=f"Tab still has a balance of €{balance:.2f} — pay it off first or use /forgive") + tab.status = "closed" + tab.closed_at = _utcnow() + tab.closed_by_id = user.id + if body.notes: + tab.notes = body.notes + db.commit() + db.refresh(tab) + return _tab_out(tab) + + +@router.post("/{tab_id}/forgive", response_model=TabOut) +def forgive_tab( + tab_id: int, + body: TabForgiveRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first() + if not tab: + raise HTTPException(status_code=404, detail="Open tab not found") + tab.status = "forgiven" + tab.closed_at = _utcnow() + tab.closed_by_id = user.id + if body.reason: + tab.notes = body.reason + db.commit() + db.refresh(tab) + return _tab_out(tab) + + +# ── Customer tabs ────────────────────────────────────────────────────────────── + +@router.get("/customer/{customer_id}", response_model=List[TabOut]) +def customer_tabs(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + tabs = db.query(Tab).filter(Tab.customer_id == customer_id).order_by( + Tab.status.asc(), # open first + Tab.opened_at.desc(), + ).all() + return [_tab_out(t) for t in tabs] diff --git a/local_backend/schemas/tabs.py b/local_backend/schemas/tabs.py new file mode 100644 index 0000000..f0d4953 --- /dev/null +++ b/local_backend/schemas/tabs.py @@ -0,0 +1,63 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +class TabEntryOut(BaseModel): + id: int + tab_id: int + order_id: Optional[int] = None + order_item_id: Optional[int] = None + amount: float + description: Optional[str] = None + created_by_id: int + created_by_name: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class TabPaymentOut(BaseModel): + id: int + tab_id: int + amount: float + payment_method: Optional[str] = None + received_by_id: int + received_by_name: Optional[str] = None + notes: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class TabOut(BaseModel): + id: int + customer_id: int + customer_name: Optional[str] = None + customer_phone: Optional[str] = None + status: str + opened_at: datetime + closed_at: Optional[datetime] = None + closed_by_id: Optional[int] = None + notes: Optional[str] = None + balance: float = 0.0 # computed: sum(entries) - sum(payments) + total_charged: float = 0.0 + total_paid: float = 0.0 + entries: List[TabEntryOut] = [] + payments: List[TabPaymentOut] = [] + + model_config = {"from_attributes": True} + + +class TabPayRequest(BaseModel): + amount: float + payment_method: Optional[str] = None + notes: Optional[str] = None + + +class TabForgiveRequest(BaseModel): + reason: Optional[str] = None + + +class TabCloseRequest(BaseModel): + notes: Optional[str] = None diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx index bd4046b..2e8ea8a 100644 --- a/manager_dashboard/src/App.jsx +++ b/manager_dashboard/src/App.jsx @@ -15,6 +15,7 @@ import NotesPage from './pages/NotesPage' import ContactsPage from './pages/ContactsPage' import ExpensesPage from './pages/ExpensesPage' import CustomersPage from './pages/CustomersPage' +import TabsPage from './pages/TabsPage' import client from './api/client' function Spinner() { @@ -89,6 +90,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index adff9ba..80bc116 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, Receipt, BookUser, Users } from 'lucide-react' +import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard } from 'lucide-react' import { getIncomingOrders } from '../api/client' export default function Sidebar() { @@ -30,6 +30,7 @@ export default function Sidebar() { { to: '/expenses', icon: Receipt, label: 'Έξοδα' }, { to: '/contacts', icon: BookUser, label: 'Επαφές' }, { to: '/customers', icon: Users, label: 'Πελάτες' }, + { to: '/tabs', icon: CreditCard, label: 'Καρτέλες' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, ] diff --git a/manager_dashboard/src/pages/CustomersPage.jsx b/manager_dashboard/src/pages/CustomersPage.jsx index 9b56ef0..5e436be 100644 --- a/manager_dashboard/src/pages/CustomersPage.jsx +++ b/manager_dashboard/src/pages/CustomersPage.jsx @@ -98,6 +98,13 @@ function CustomerDetail({ customer, onEdit, onClose }) { staleTime: 30_000, }) + const { data: tabsData = [] } = useQuery({ + queryKey: ['customer-tabs', customer.id], + queryFn: () => client.get(`/api/tabs/customer/${customer.id}`).then(r => r.data), + staleTime: 30_000, + }) + + const openTab = tabsData.find(t => t.status === 'open') const orders = data?.orders || [] return ( @@ -128,6 +135,21 @@ function CustomerDetail({ customer, onEdit, onClose }) { ))} + {/* Open tab banner */} + {openTab && ( +
+
+
+
Ανοιχτή Καρτέλα
+
+ {openTab.entries.length} χρεώσεις · πληρωμένο {fmt(openTab.total_paid)} +
+
+
{fmt(openTab.balance)}
+
+
+ )} + {/* Contact info */}
{(customer.phone || customer.email || customer.notes) ? ( diff --git a/manager_dashboard/src/pages/OrderDetailPage.jsx b/manager_dashboard/src/pages/OrderDetailPage.jsx index 4c06edf..6063be2 100644 --- a/manager_dashboard/src/pages/OrderDetailPage.jsx +++ b/manager_dashboard/src/pages/OrderDetailPage.jsx @@ -241,6 +241,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false onError: () => toast.error('Σφάλμα πληρωμής'), }) + const tabItem = useMutation({ + mutationFn: (itemId) => client.post(`/api/orders/${orderId}/items/${itemId}/tab`), + onSuccess: () => { toast.success('Αντικείμενο μεταφέρθηκε στην καρτέλα'); invalidate() }, + onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'), + }) + function requestPay(item_ids) { setPayPending(item_ids) } @@ -448,6 +454,14 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false > Πληρωμή + {order.customer_id && ( + + )} )} + {item.status === 'tabbed' && ( + + 📋 Καρτέλα + + )}
) diff --git a/manager_dashboard/src/pages/TabsPage.jsx b/manager_dashboard/src/pages/TabsPage.jsx new file mode 100644 index 0000000..b7e7f5e --- /dev/null +++ b/manager_dashboard/src/pages/TabsPage.jsx @@ -0,0 +1,342 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../api/client' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fmt(n) { + if (n == null) return '—' + return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' +} + +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' }) +} + +function daysSince(iso) { + if (!iso) return null + const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000) + if (days === 0) return 'Σήμερα' + if (days === 1) return '1 μέρα' + return `${days} μέρες` +} + +const STATUS_CONFIG = { + open: { label: 'Ανοιχτή', bg: '#fee2e2', color: '#dc2626' }, + closed: { label: 'Κλειστή', bg: '#dcfce7', color: '#16a34a' }, + forgiven: { label: 'Χαρίστηκε', bg: '#f3f4f6', color: '#6b7280' }, +} + +// ── Pay modal ───────────────────────────────────────────────────────────────── + +function PayModal({ tab, onClose, onPay, isPending }) { + const [amount, setAmount] = useState(String(Math.round(tab.balance * 100) / 100)) + const [method, setMethod] = useState('cash') + const [notes, setNotes] = useState('') + const parsed = parseFloat(amount) + const canPay = parsed > 0 && parsed <= tab.balance + 0.005 + + return ( +
+
+
+
Πληρωμή Καρτέλας
+
{tab.customer_name}
+
+
+
+ Υπόλοιπο καρτέλας + {fmt(tab.balance)} +
+
+ + setAmount(e.target.value)} autoFocus + style={{ width: '100%', padding: '9px 12px', border: '1.5px solid #e5e7eb', borderRadius: 8, fontSize: 16, fontWeight: 600, outline: 'none', boxSizing: 'border-box' }} /> +
+
+ +
+ {[['cash', 'Μετρητά'], ['card', 'Κάρτα'], ['other', 'Άλλο']].map(([v, l]) => ( + + ))} +
+
+
+ + setNotes(e.target.value)} placeholder="προαιρετικό" + style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} /> +
+
+
+ + +
+
+
+ ) +} + +// ── Forgive confirm ─────────────────────────────────────────────────────────── + +function ForgiveModal({ tab, onClose, onForgive, isPending }) { + const [reason, setReason] = useState('') + return ( +
+
+
+
Χάρισμα Καρτέλας
+
{tab.customer_name} — υπόλοιπο {fmt(tab.balance)}
+
+
+
+ Το υπόλοιπο των {fmt(tab.balance)} θα διαγραφεί. Η ενέργεια δεν αναιρείται. +
+
+ + setReason(e.target.value)} placeholder="π.χ. Καλή θέληση" + style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} /> +
+
+
+ + +
+
+
+ ) +} + +// ── Tab detail card ─────────────────────────────────────────────────────────── + +function TabDetail({ tab, onPay, onClose, onForgive }) { + const sc = STATUS_CONFIG[tab.status] || STATUS_CONFIG.open + const canClose = tab.status === 'open' && tab.balance <= 0.005 && tab.entries.length > 0 + + return ( +
+ {/* Header */} +
+
+
{tab.customer_name}
+
+ Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή +
+
+
+ 0 ? '#dc2626' : '#16a34a' }}> + {fmt(tab.balance)} + + {sc.label} +
+
+ + {/* Stats */} +
+ {[ + { label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) }, + { label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' }, + { label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' }, + ].map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ + {/* Entries */} + {tab.entries.length > 0 && ( +
+
Χρεώσεις
+
+ {tab.entries.map(e => ( +
+ {e.description || `Χρέωση #${e.id}`} + {fmt(e.amount)} +
+ ))} +
+
+ )} + + {/* Payments */} + {tab.payments.length > 0 && ( +
+
Πληρωμές
+
+ {tab.payments.map(p => ( +
+ + {p.received_by_name} · {fmtDateTime(p.created_at)} + {p.payment_method && ({p.payment_method})} + {p.notes && — {p.notes}} + + {fmt(p.amount)} +
+ ))} +
+
+ )} + + {/* Actions */} + {tab.status === 'open' && ( +
+ {tab.balance > 0.005 && ( + + )} + {canClose && ( + + )} + +
+ )} +
+ ) +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function TabsPage() { + const qc = useQueryClient() + const [showAll, setShowAll] = useState(false) + const [modal, setModal] = useState(null) // { type: 'pay'|'forgive', tab } + + const { data: tabs = [], isLoading } = useQuery({ + queryKey: ['tabs', showAll], + queryFn: () => client.get('/api/tabs/', { params: showAll ? { tab_status: 'all' } : {} }).then(r => r.data), + staleTime: 15_000, + }) + + // Refetch tabs with all statuses when toggled + const { data: allTabs = [] } = useQuery({ + queryKey: ['tabs-all'], + queryFn: () => client.get('/api/tabs/', { params: { tab_status: 'closed' } }).then(r => r.data), + staleTime: 30_000, + enabled: showAll, + }) + + const payTab = useMutation({ + mutationFn: ({ id, amount, payment_method, notes }) => + client.post(`/api/tabs/${id}/pay`, { amount, payment_method, notes }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tabs'] }) + qc.invalidateQueries({ queryKey: ['tabs-all'] }) + setModal(null) + toast.success('Πληρωμή καταγράφηκε') + }, + onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'), + }) + + const closeTab = useMutation({ + mutationFn: (id) => client.post(`/api/tabs/${id}/close`, {}), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tabs'] }) + toast.success('Καρτέλα έκλεισε') + }, + onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'), + }) + + const forgiveTab = useMutation({ + mutationFn: ({ id, reason }) => client.post(`/api/tabs/${id}/forgive`, { reason }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tabs'] }) + setModal(null) + toast.success('Το υπόλοιπο χαρίστηκε') + }, + onError: () => toast.error('Σφάλμα'), + }) + + const openTabs = tabs.filter(t => t.status === 'open') + const totalOutstanding = openTabs.reduce((s, t) => s + t.balance, 0) + + return ( +
+ {/* Header */} +
+
+

Καρτέλες

+

+ {openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '} + 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)} +

+
+ +
+ + {/* Tab list */} +
+ {isLoading &&
Φόρτωση…
} + {!isLoading && openTabs.length === 0 && ( +
+ Δεν υπάρχουν ανοιχτές καρτέλες. +
+ )} + {openTabs.map(tab => ( + setModal({ type: 'pay', tab: t })} + onClose={t => closeTab.mutate(t.id)} + onForgive={t => setModal({ type: 'forgive', tab: t })} + /> + ))} + + {showAll && allTabs.length > 0 && ( + <> +
Κλειστές / Χαρισμένες
+ {allTabs.map(tab => ( + {}} onClose={() => {}} onForgive={() => {}} /> + ))} + + )} +
+ + {modal?.type === 'pay' && ( + setModal(null)} + isPending={payTab.isPending} + onPay={(amount, payment_method, notes) => payTab.mutate({ id: modal.tab.id, amount, payment_method, notes })} + /> + )} + + {modal?.type === 'forgive' && ( + setModal(null)} + isPending={forgiveTab.isPending} + onForgive={(reason) => forgiveTab.mutate({ id: modal.tab.id, reason })} + /> + )} +
+ ) +}