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:
2026-06-08 03:07:22 +03:00
parent e9bdf61a4d
commit 2ff48730f7
10 changed files with 819 additions and 1 deletions

View File

@@ -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"])

View 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])

View File

@@ -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}

View 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]

View 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

View File

@@ -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 />} />

View File

@@ -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: 'Ρυθμίσεις' },
] ]

View File

@@ -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) ? (

View File

@@ -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>
) )

View 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>
)
}