feat: Phase 2G — pay-later tab system
Backend:
- New models: Tab, TabEntry, TabPayment (3 new tables)
- Tab: open|closed|forgiven, one open tab per customer enforced
- TabEntry: what went ON the tab (per-item snapshot with description)
- TabPayment: what came OFF the tab; balance = sum(entries) - sum(payments), never stored
- New router /api/tabs/:
- GET / — list open tabs (sorted by balance desc); ?tab_status= override
- GET /{id} — full tab detail with entries + payments
- POST / — open new tab for a customer (400 if one already exists)
- POST /{id}/entries — add entry directly (amount + description)
- POST /{id}/pay — record payment; validates amount <= balance
- POST /{id}/close — close tab (requires balance = 0)
- POST /{id}/forgive — write off remaining balance
- GET /customer/{customer_id} — all tabs for a customer (open first)
- POST /api/orders/{id}/items/{item_id}/tab:
- Requires order has a customer assigned
- Finds or auto-creates open tab for that customer
- Sets order_item.status = "tabbed" (new valid status value)
- Creates TabEntry with auto-generated description
- Broadcasts order_updated SSE event
- Migrations: CREATE TABLE IF NOT EXISTS for tabs, tab_entries, tab_payments
Frontend:
- TabsPage (/tabs): open tabs list sorted by balance; each card shows charges/payments
history, live balance, Πληρωμή modal (defaults to full balance), Κλείσιμο button
(only shown when balance=0), Χάρισμα Υπολοίπου with confirm modal + reason
- CustomersPage CustomerDetail: open tab banner showing balance + entry count
(appears between stats and contact info when customer has an open tab)
- OrderDetailPage: 📋 Καρτέλα button appears on active items when order has a customer;
tabbed items show a 📋 Καρτέλα badge; tabItem mutation calls /items/{id}/tab
- Sidebar: CreditCard icon for Καρτέλες (after Πελάτες)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import models.reservation # noqa: F401
|
|||||||
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
||||||
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
|
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
|
||||||
import models.customers # noqa: F401 — registers Customer
|
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 auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||||
from routers import business_day as business_day_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 import notes as notes_router
|
||||||
from routers.expenses import contacts_router, expenses_router
|
from routers.expenses import contacts_router, expenses_router
|
||||||
from routers import customers as customers_router
|
from routers import customers as customers_router
|
||||||
|
from routers import tabs as tabs_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -275,6 +277,35 @@ def _run_migrations():
|
|||||||
# Phase 2B — staff payroll
|
# Phase 2B — staff payroll
|
||||||
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
||||||
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot 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
|
# Phase 2F — customer CRM
|
||||||
"""CREATE TABLE IF NOT EXISTS customers (
|
"""CREATE TABLE IF NOT EXISTS customers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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(contacts_router, prefix="/api/contacts", tags=["contacts"])
|
||||||
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
|
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
|
||||||
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
||||||
|
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
|
||||||
|
|||||||
58
local_backend/models/tabs.py
Normal file
58
local_backend/models/tabs.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 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])
|
||||||
@@ -583,6 +583,10 @@ class AssignCustomerBody(BaseModel):
|
|||||||
customer_id: Optional[int] = None # null = unassign
|
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")
|
@router.put("/{order_id}/customer")
|
||||||
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
from models.customers import Customer
|
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)
|
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width)
|
||||||
return {"status": "printing"}
|
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}
|
||||||
|
|||||||
223
local_backend/routers/tabs.py
Normal file
223
local_backend/routers/tabs.py
Normal file
@@ -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]
|
||||||
63
local_backend/schemas/tabs.py
Normal file
63
local_backend/schemas/tabs.py
Normal file
@@ -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
|
||||||
@@ -15,6 +15,7 @@ import NotesPage from './pages/NotesPage'
|
|||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ExpensesPage from './pages/ExpensesPage'
|
import ExpensesPage from './pages/ExpensesPage'
|
||||||
import CustomersPage from './pages/CustomersPage'
|
import CustomersPage from './pages/CustomersPage'
|
||||||
|
import TabsPage from './pages/TabsPage'
|
||||||
import client from './api/client'
|
import client from './api/client'
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -89,6 +90,7 @@ export default function App() {
|
|||||||
<Route path="contacts" element={<ContactsPage />} />
|
<Route path="contacts" element={<ContactsPage />} />
|
||||||
<Route path="expenses" element={<ExpensesPage />} />
|
<Route path="expenses" element={<ExpensesPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
|
<Route path="tabs" element={<TabsPage />} />
|
||||||
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
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'
|
import { getIncomingOrders } from '../api/client'
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@@ -30,6 +30,7 @@ export default function Sidebar() {
|
|||||||
{ to: '/expenses', icon: Receipt, label: 'Έξοδα' },
|
{ to: '/expenses', icon: Receipt, label: 'Έξοδα' },
|
||||||
{ to: '/contacts', icon: BookUser, label: 'Επαφές' },
|
{ to: '/contacts', icon: BookUser, label: 'Επαφές' },
|
||||||
{ to: '/customers', icon: Users, label: 'Πελάτες' },
|
{ to: '/customers', icon: Users, label: 'Πελάτες' },
|
||||||
|
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ function CustomerDetail({ customer, onEdit, onClose }) {
|
|||||||
staleTime: 30_000,
|
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 || []
|
const orders = data?.orders || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,6 +135,21 @@ function CustomerDetail({ customer, onEdit, onClose }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Open tab banner */}
|
||||||
|
{openTab && (
|
||||||
|
<div style={{ padding: '10px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#dc2626' }}>Ανοιχτή Καρτέλα</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
{openTab.entries.length} χρεώσεις · πληρωμένο {fmt(openTab.total_paid)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: '#dc2626' }}>{fmt(openTab.balance)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact info */}
|
{/* Contact info */}
|
||||||
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
|
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
|
||||||
{(customer.phone || customer.email || customer.notes) ? (
|
{(customer.phone || customer.email || customer.notes) ? (
|
||||||
|
|||||||
@@ -241,6 +241,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
onError: () => toast.error('Σφάλμα πληρωμής'),
|
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) {
|
function requestPay(item_ids) {
|
||||||
setPayPending(item_ids)
|
setPayPending(item_ids)
|
||||||
}
|
}
|
||||||
@@ -448,6 +454,14 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
>
|
>
|
||||||
Πληρωμή
|
Πληρωμή
|
||||||
</button>
|
</button>
|
||||||
|
{order.customer_id && (
|
||||||
|
<button
|
||||||
|
onClick={() => tabItem.mutate(item.id)}
|
||||||
|
style={{ fontSize: 12, padding: '0 8px', height: 32, borderRadius: 6, border: '1px solid #bfdbfe', background: '#eff6ff', color: '#1d4ed8', cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
📋 Καρτέλα
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||||
@@ -456,6 +470,11 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{item.status === 'tabbed' && (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>
|
||||||
|
📋 Καρτέλα
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
342
manager_dashboard/src/pages/TabsPage.jsx
Normal file
342
manager_dashboard/src/pages/TabsPage.jsx
Normal file
@@ -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 (
|
||||||
|
<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' }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700 }}>Πληρωμή Καρτέλας</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name}</div>
|
||||||
|
</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(tab.balance)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής (€)</label>
|
||||||
|
<input type="number" step="0.01" min="0.01"
|
||||||
|
value={amount} onChange={e => 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' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τρόπος πληρωμής</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{[['cash', 'Μετρητά'], ['card', 'Κάρτα'], ['other', 'Άλλο']].map(([v, l]) => (
|
||||||
|
<button key={v} onClick={() => setMethod(v)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '7px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1.5px solid ${method === v ? '#111827' : '#e5e7eb'}`,
|
||||||
|
background: method === v ? '#111827' : 'white',
|
||||||
|
color: method === v ? 'white' : '#6b7280',
|
||||||
|
}}>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
|
||||||
|
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="προαιρετικό"
|
||||||
|
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button onClick={() => onPay(parsed, method, notes || null)} disabled={!canPay || isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forgive confirm ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ForgiveModal({ tab, onClose, onForgive, isPending }) {
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
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' }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#dc2626' }}>Χάρισμα Καρτέλας</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name} — υπόλοιπο {fmt(tab.balance)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: '12px 14px', background: '#fef2f2', borderRadius: 10, border: '1px solid #fecaca', fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
|
||||||
|
Το υπόλοιπο των {fmt(tab.balance)} θα διαγραφεί. Η ενέργεια δεν αναιρείται.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Αιτιολογία</label>
|
||||||
|
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="π.χ. Καλή θέληση"
|
||||||
|
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button onClick={() => onForgive(reason || null)} disabled={isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer', background: '#dc2626', color: 'white' }}>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Χάρισμα Υπολοίπου'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden', background: 'white' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '14px 18px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 22, fontWeight: 800, color: tab.balance > 0 ? '#dc2626' : '#16a34a' }}>
|
||||||
|
{fmt(tab.balance)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entries */}
|
||||||
|
{tab.entries.length > 0 && (
|
||||||
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{tab.entries.map(e => (
|
||||||
|
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payments */}
|
||||||
|
{tab.payments.length > 0 && (
|
||||||
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{tab.payments.map(p => (
|
||||||
|
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span style={{ color: '#374151' }}>
|
||||||
|
{p.received_by_name} · {fmtDateTime(p.created_at)}
|
||||||
|
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
|
||||||
|
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}>— {p.notes}</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{tab.status === 'open' && (
|
||||||
|
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{tab.balance > 0.005 && (
|
||||||
|
<button onClick={() => onPay(tab)}
|
||||||
|
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||||
|
Πληρωμή
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canClose && (
|
||||||
|
<button onClick={() => onClose(tab)}
|
||||||
|
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||||
|
Κλείσιμο Καρτέλας
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onForgive(tab)}
|
||||||
|
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||||
|
Χάρισμα Υπολοίπου
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<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', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Καρτέλες</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||||
|
{openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '}
|
||||||
|
<strong style={{ color: totalOutstanding > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(s => !s)}
|
||||||
|
style={{ padding: '7px 14px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#6b7280' }}
|
||||||
|
>
|
||||||
|
{showAll ? 'Μόνο ανοιχτές' : 'Εμφάνιση κλειστών'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && openTabs.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 14, padding: '48px 0' }}>
|
||||||
|
Δεν υπάρχουν ανοιχτές καρτέλες.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{openTabs.map(tab => (
|
||||||
|
<TabDetail
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
onPay={t => setModal({ type: 'pay', tab: t })}
|
||||||
|
onClose={t => closeTab.mutate(t.id)}
|
||||||
|
onForgive={t => setModal({ type: 'forgive', tab: t })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showAll && allTabs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 8 }}>Κλειστές / Χαρισμένες</div>
|
||||||
|
{allTabs.map(tab => (
|
||||||
|
<TabDetail key={tab.id} tab={tab} onPay={() => {}} onClose={() => {}} onForgive={() => {}} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal?.type === 'pay' && (
|
||||||
|
<PayModal
|
||||||
|
tab={modal.tab}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={payTab.isPending}
|
||||||
|
onPay={(amount, payment_method, notes) => payTab.mutate({ id: modal.tab.id, amount, payment_method, notes })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal?.type === 'forgive' && (
|
||||||
|
<ForgiveModal
|
||||||
|
tab={modal.tab}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={forgiveTab.isPending}
|
||||||
|
onForgive={(reason) => forgiveTab.mutate({ id: modal.tab.id, reason })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user