feat(pos): rebuild Sales — 3 tabs, smooth checkout + tender/change, print+email receipts

7 tabs → Register / History / Settings. Register: search/scan + cart + checkout flow
(cash tender→change, split tenders, layby deposit, notes) → completes → receipt auto-pops.
Receipts now server-rendered (app/receipts.py, one template) with per-item discounts, GST
breakdown, split lines, tendered/change, store header/footer; Print opens a clean window
(no visibility hack); Email via app/mailer.py (stdlib SMTP, creds in vault, admin-only) with
a Settings 'send test' button. History merges past sales + laybys (reprint/collect). Settings
holds currency/GST/discount + receipt header/footer + promotions. sales gains amount_tendered
+ notes; SaleIn carries tendered/notes; /sales/{id}/receipt + /{id}/email + /email-test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 16:29:42 +10:00
parent b46930d468
commit 5351cfb9c2
6 changed files with 542 additions and 260 deletions

60
app/mailer.py Normal file
View File

@ -0,0 +1,60 @@
import asyncio
import smtplib
import ssl
from email.message import EmailMessage
from . import vault
# Receipt email. SMTP creds live in the vault (admin-only). stdlib smtplib in a thread so the
# event loop isn't blocked — no new dependency. ponytail: one sender, swap to API mail if needed.
class MailUnconfigured(Exception):
pass
async def _creds(db):
host = await vault.get_secret(db, "smtp_host")
if not host:
raise MailUnconfigured("SMTP not configured — set smtp_* in admin → Connections")
user = await vault.get_secret(db, "smtp_user") or ""
return {
"host": host.strip(),
"port": int((await vault.get_secret(db, "smtp_port") or "587").strip() or 587),
"user": user,
"pass": await vault.get_secret(db, "smtp_pass") or "",
"from": (await vault.get_secret(db, "smtp_from") or user).strip(),
"from_name": await vault.get_secret(db, "smtp_from_name") or "RecordGod",
}
def _send_sync(c, to, subject, html):
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = f'{c["from_name"]} <{c["from"]}>' if c["from_name"] else c["from"]
msg["To"] = to
msg.set_content("Your receipt is below — view this email in an HTML-capable client.")
msg.add_alternative(html, subtype="html")
ctx = ssl.create_default_context()
if c["port"] == 465:
with smtplib.SMTP_SSL(c["host"], c["port"], context=ctx, timeout=20) as s:
if c["user"]:
s.login(c["user"], c["pass"])
s.send_message(msg)
else:
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
s.ehlo()
try:
s.starttls(context=ctx)
s.ehlo()
except smtplib.SMTPNotSupportedError:
pass
if c["user"]:
s.login(c["user"], c["pass"])
s.send_message(msg)
async def send_mail(db, to, subject, html):
c = await _creds(db)
await asyncio.to_thread(_send_sync, c, to, subject, html)
return c["from"]

View File

@ -74,6 +74,8 @@ _STARTUP_DDL = [
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS hold_expires_at timestamptz",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS split_payments jsonb",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_tendered numeric(10,2)",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS notes text",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)",
# auto-id for new POS rows (migrated tables came without sequences)

84
app/receipts.py Normal file
View File

@ -0,0 +1,84 @@
import html as _html
import json
def _e(s):
return _html.escape(str(s if s is not None else ""))
def render_receipt_html(sale, items, customer, st):
"""One self-contained receipt (inline CSS) used by both the print window and the email body."""
cur = st.get("currency_symbol", "$")
def m(v):
try:
return f"{cur}{float(v or 0):.2f}"
except (TypeError, ValueError):
return f"{cur}0.00"
store = st.get("store_name") or "RecordGod"
addr = st.get("store_address") or ""
footer = st.get("receipt_footer") or "thanks for digging"
date = str(sale.get("sale_date") or "")[:16].replace("T", " ")
who = (customer or {}).get("name") or "Guest"
rows = ""
for i in items:
disc = float(i.get("discount") or 0)
dtag = f' <span style="color:#c2185b">{m(disc)}</span>' if disc > 0 else ""
rows += (f'<tr><td>{i.get("qty",1)}× {_e(i.get("item_name") or i.get("sku"))}{dtag}</td>'
f'<td style="text-align:right">{m(i.get("line_total"))}</td></tr>')
sub = sale.get("subtotal")
disc_total = float(sale.get("discount_amount") or 0)
tax = float(sale.get("tax_amount") or 0)
incl = (st.get("tax_type") or "exclusive") == "inclusive"
trade = float(sale.get("trade_in_credit") or 0)
tot = float(sale.get("total") or 0)
summ = f'<tr><td>Subtotal</td><td style="text-align:right">{m(sub)}</td></tr>'
if disc_total > 0:
summ += f'<tr><td>Discount</td><td style="text-align:right">{m(disc_total)}</td></tr>'
if tax > 0:
summ += f'<tr><td>GST{" incl" if incl else ""} ({st.get("tax_rate","0")}%)</td><td style="text-align:right">{m(tax)}</td></tr>'
if trade > 0:
summ += f'<tr><td>Trade-in credit</td><td style="text-align:right">{m(trade)}</td></tr>'
# split-payment lines, else the single method + (cash) tender/change
pay = ""
method = sale.get("payment_method") or ""
splits = sale.get("split_payments")
if isinstance(splits, str):
try:
splits = json.loads(splits)
except ValueError:
splits = None
if splits and isinstance(splits, list):
for p in splits:
if p.get("method") and p.get("amount"):
pay += f'<tr><td>{_e(p["method"].title())}</td><td style="text-align:right">{m(p["amount"])}</td></tr>'
else:
pay += f'<tr><td>{_e(method.title() or "Paid")}</td><td style="text-align:right">{m(sale.get("amount_paid", tot))}</td></tr>'
tendered = sale.get("amount_tendered")
if method == "cash" and tendered:
pay += f'<tr><td>Tendered</td><td style="text-align:right">{m(tendered)}</td></tr>'
pay += f'<tr><td>Change</td><td style="text-align:right">{m(float(tendered) - tot)}</td></tr>'
bal = round(tot - float(sale.get("amount_paid") or 0), 2)
bal_line = (f'<tr><td style="color:#c2185b">Balance due</td>'
f'<td style="text-align:right;color:#c2185b">{m(bal)}</td></tr>') if bal > 0.005 else ""
notes = f'<div class="ct" style="margin-top:6px">{_e(sale.get("notes"))}</div>' if sale.get("notes") else ""
return f"""<div class="receipt-container" style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.6;color:#111;max-width:300px;margin:0 auto;padding:6px">
<style>.receipt-container td{{padding:1px 0}} .receipt-container table{{width:100%;border-collapse:collapse}} .receipt-container .hr{{border-top:1px dashed #999;margin:7px 0}} .receipt-container .ct{{text-align:center}} @media print{{.receipt-actions{{display:none}}}}</style>
<div class="ct" style="font-weight:700;font-size:15px">{_e(store)}</div>
{f'<div class="ct" style="color:#555">{_e(addr)}</div>' if addr else ''}
<div class="ct" style="color:#555">{_e(sale.get('sale_number',''))} · {_e(date)} · {_e(who)}</div>
<div class="hr"></div>
<table>{rows}</table>
<div class="hr"></div>
<table>{summ}<tr style="font-weight:700"><td>Total</td><td style="text-align:right">{m(tot)}</td></tr>{pay}{bal_line}</table>
{notes}
<div class="hr"></div>
<div class="ct" style="color:#555">{_e(footer)}</div>
</div>"""

View File

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from . import mailer, receipts
from .auth import require_token
from .db import get_db
@ -30,6 +31,8 @@ class SaleIn(BaseModel):
split: list[dict] | None = None
hold: dict | None = None
trade_in: float = 0
tendered: float | None = None # cash given (for the change line on the receipt)
notes: str | None = None
customer_id: int | None = None # 0 = guest sale; None when unset
@ -85,15 +88,15 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0)
sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total,
status, payment_method, payment_status, amount_paid, trade_in_credit,
hold_expires_at, split_payments, sale_date, created_at)
VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade,
:due, CAST(:split AS jsonb), now(), now())
status, payment_method, payment_status, amount_paid, amount_tendered, trade_in_credit,
notes, hold_expires_at, split_payments, sale_date, created_at)
VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :tend, :trade,
:notes, :due, CAST(:split AS jsonb), now(), now())
RETURNING id
"""), {"sn": sale_number, "cust": cust, "sub": gross - line_disc, "disc": line_disc + body.cart_discount,
"tax": tax, "total": total, "st": status, "pm": body.payment_method, "ps": pay_status,
"paid": amount_paid, "trade": body.trade_in, "due": hold_due,
"split": json.dumps(body.split) if body.split else None})).first()[0]
"paid": amount_paid, "tend": body.tendered, "trade": body.trade_in, "notes": body.notes,
"due": hold_due, "split": json.dumps(body.split) if body.split else None})).first()[0]
for li in body.items:
await db.execute(text("""
@ -131,15 +134,77 @@ async def list_sales(status: str = Query(""), ident=Depends(require_token), db=D
return {"sales": [dict(r) for r in rows.mappings()]}
@router.get("/{sale_id}")
async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
async def _load_sale(db, sale_id):
s = (await db.execute(text("SELECT * FROM sales WHERE id=:i"), {"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found")
s = dict(s)
items = [dict(r) for r in (await db.execute(text(
"SELECT sku, item_name, qty, unit_price::float AS unit_price, line_total::float AS line_total, "
"discount_amount::float AS discount FROM sale_items WHERE sale_id=:i"), {"i": sale_id})).mappings()]
return {"sale": dict(s), "items": items}
cust = None
if s.get("customer_id"):
c = (await db.execute(text(
"SELECT trim(first_name||' '||coalesce(last_name,'')) AS name, email FROM customer WHERE id=:i"),
{"i": s["customer_id"]})).mappings().first()
cust = dict(c) if c else None
return s, items, cust
@router.get("/{sale_id}")
async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
s, items, cust = await _load_sale(db, sale_id)
return {"sale": s, "items": items, "customer": cust}
@router.get("/{sale_id}/receipt")
async def sale_receipt(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
"""Rendered receipt HTML (for the print window) + the customer's email if on file."""
s, items, cust = await _load_sale(db, sale_id)
html = receipts.render_receipt_html(s, items, cust, await _settings(db))
return {"html": html, "sale_number": s.get("sale_number"),
"customer_email": (cust or {}).get("email")}
class EmailIn(BaseModel):
email: str
@router.post("/{sale_id}/email")
async def email_receipt(sale_id: int, body: EmailIn, ident=Depends(require_token), db=Depends(get_db)):
if "@" not in body.email:
raise HTTPException(422, "invalid email")
s, items, cust = await _load_sale(db, sale_id)
st = await _settings(db)
html = receipts.render_receipt_html(s, items, cust, st)
subject = f"{st['store_name']} receipt — {s.get('sale_number', '')}"
try:
sender = await mailer.send_mail(db, body.email, subject, html)
except mailer.MailUnconfigured as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(502, f"send failed: {e}")
return {"ok": True, "sent_to": body.email, "from": sender}
@router.post("/email-test")
async def email_test(body: EmailIn, ident=Depends(require_token), db=Depends(get_db)):
"""Send a sample receipt to verify the SMTP setup from the Settings tab."""
if "@" not in body.email:
raise HTTPException(422, "invalid email")
st = await _settings(db)
sample = {"sale_number": "TEST-0001", "sale_date": datetime.now().isoformat(),
"subtotal": 28.0, "discount_amount": 3.0, "tax_amount": 0, "total": 25.0,
"payment_method": "cash", "amount_paid": 25.0, "amount_tendered": 30.0}
items = [{"qty": 1, "item_name": "Selected Ambient Works (test)", "line_total": 28.0, "discount": 3.0}]
html = receipts.render_receipt_html(sample, items, {"name": "Test"}, st)
try:
sender = await mailer.send_mail(db, body.email, f"{st['store_name']} — test receipt", html)
except mailer.MailUnconfigured as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(502, f"send failed: {e}")
return {"ok": True, "sent_to": body.email, "from": sender}
@router.post("/{sale_id}/pay")
@ -218,20 +283,29 @@ async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Dep
# ── Settings (currency / tax / default discount) ─────────────────────────────────────────────
@router.get("/settings")
async def get_settings(ident=Depends(require_token), db=Depends(get_db)):
async def _settings(db):
rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active"))
s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()}
return {"currency_symbol": s.get("currency_symbol", "$"),
"tax_rate": s.get("default_tax_rate", s.get("tax_rate", "0")),
"discount_rate": s.get("default_discount_rate", "0"),
"tax_type": s.get("tax_type", "exclusive")}
"tax_type": s.get("tax_type", "exclusive"),
"store_name": s.get("store_name", "RecordGod"),
"store_address": s.get("store_address", ""),
"receipt_footer": s.get("receipt_footer", "thanks for digging")}
@router.get("/settings")
async def get_settings(ident=Depends(require_token), db=Depends(get_db)):
return await _settings(db)
@router.post("/settings")
async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)):
m = {"currency_symbol": body.get("currency_symbol"), "default_tax_rate": body.get("tax_rate"),
"default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type")}
"default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type"),
"store_name": body.get("store_name"), "store_address": body.get("store_address"),
"receipt_footer": body.get("receipt_footer")}
for k, v in m.items():
if v is None:
continue

View File

@ -22,6 +22,12 @@ KNOWN = {
"discogs_token": "Discogs personal access token",
"cloudflare_api_token": "Cloudflare API token",
"dealgod_api_key": "DealGod API key (X-Api-Key)",
"smtp_host": "Receipt email — SMTP host (e.g. mail.monsterrobot.party)",
"smtp_port": "Receipt email — SMTP port (587 STARTTLS / 465 SSL)",
"smtp_user": "Receipt email — SMTP username (e.g. shop@monsterrobot.party)",
"smtp_pass": "Receipt email — SMTP password",
"smtp_from": "Receipt email — From address (defaults to username)",
"smtp_from_name": "Receipt email — From name (e.g. Monster Robot Records)",
}

View File

@ -3,244 +3,246 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — sales</title>
<title>RecordGod — Sales</title>
<script defer src="/nav.js?v=5"></script>
<style>
:root{--pink:#d10f7a;--btn:#e0117f;--ink:#f4f5f7;--pn:#ffffff;--pn2:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
:root{--pink:#d10f7a;--btn:#e0117f;--bg:#f4f5f7;--pn:#fff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ink);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
html,body{margin:0;background:var(--bg);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.btn{background:var(--btn);color:#fff;padding:10px 14px}
.ghost{background:#fff;color:#2a2a30;border:1px solid var(--line);padding:8px 11px}
.ghost.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
input,select,textarea{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--pn2);color:#1b1b22;font:14px system-ui}
.big{padding:13px 16px;font-size:15px;width:100%}
input,select,textarea{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#1b1b22;font:14px system-ui}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--pink)}
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:50}
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
.wrap{max-width:1240px;margin:0 auto;padding:16px}
h1.pg{font-size:24px;margin:4px 0 14px;font-weight:600}
.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin-bottom:16px}
.tabs button{background:none;color:var(--mut);padding:9px 14px;border-radius:8px 8px 0 0;font-weight:600;font-size:13px}
.tabs button.on{color:var(--pink);background:var(--pn);border:1px solid var(--line);border-bottom-color:var(--pn)}
.wrap{max-width:1180px;margin:0 auto;padding:16px}
.top{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.top h1{font-size:20px;margin:0;font-weight:600}.top h1 b{color:var(--pink)}
.tabs{display:flex;gap:4px;flex:1}
.tabs button{background:none;color:var(--mut);padding:8px 16px;border-radius:8px;font-weight:600}
.tabs button.on{color:var(--pink);background:#fdeef6}
.tab{display:none}.tab.on{display:block}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.panel h2{font-size:14px;font-weight:600;margin:0 0 10px;color:#33333a}
.grid{display:grid;grid-template-columns:1.35fr 1fr;gap:16px}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.panel h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#33333a}
.grid{display:grid;grid-template-columns:1.4fr 1fr;gap:14px;align-items:start}
@media(max-width:880px){.grid{grid-template-columns:1fr}}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.lbl{color:var(--mut);font-size:12px;min-width:74px}
.res{position:relative}
.drop{position:absolute;left:0;right:0;top:calc(100% + 4px);background:#fff;border:1px solid var(--line);border-radius:9px;max-height:280px;overflow:auto;z-index:20;box-shadow:0 6px 20px rgba(0,0,0,.12)}
.lbl{color:var(--mut);font-size:12px;min-width:80px}
.res{position:relative;flex:1}
.drop{position:absolute;left:0;right:0;top:calc(100% + 4px);background:#fff;border:1px solid var(--line);border-radius:9px;max-height:300px;overflow:auto;z-index:20;box-shadow:0 6px 20px rgba(0,0,0,.12)}
.ri{display:flex;gap:10px;align-items:center;padding:8px;cursor:pointer}.ri:hover{background:#f5f5f8}
.ri img,.ri .ph{width:38px;height:38px;border-radius:5px;object-fit:cover;background:#ececf0;flex-shrink:0}
.ri .t{flex:1;min-width:0}.ri .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}.warn{color:var(--warn)}
.chip{display:inline-flex;gap:8px;align-items:center;background:#fdeef6;color:var(--pink);padding:6px 10px;border-radius:20px;font-weight:600}
.chip{display:inline-flex;gap:8px;align-items:center;background:#fdeef6;color:var(--pink);padding:6px 11px;border-radius:20px;font-weight:600;cursor:pointer}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{padding:7px 6px;border-bottom:1px solid #ededf2;text-align:left}
th{color:var(--mut);font-weight:500}
.qty{width:46px}.disc{width:66px}
.tot{display:flex;justify-content:space-between;align-items:center;padding:4px 0}
.tot.big{font-size:22px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
.tot.big b{color:var(--pink)}
.pillbtns{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}.pillbtns button{flex:1}
.promo{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #ededf2}
td,th{padding:7px 6px;border-bottom:1px solid #ededf2;text-align:left}th{color:var(--mut);font-weight:500}
.qty{width:48px}.num{width:74px}
.tot{display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:13px}
.tot.grand{font-size:15px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
.tot.grand b{color:var(--pink);font-size:24px}
.pm{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:12px 0 4px}
.pm button{padding:11px 6px;background:#fff;border:1px solid var(--line);border-radius:8px;color:#33333a;font-weight:600}
.pm button.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
.x{color:var(--mut);cursor:pointer}
.hold{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid #ededf2;font-size:13px}
iframe{width:100%;height:72vh;border:1px solid var(--line);border-radius:12px;background:#fff}
#rcpt{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:60}
#rcpt .card{background:#fff;color:#111;width:340px;max-height:86vh;overflow:auto;border-radius:10px;padding:20px;font:13px/1.5 ui-monospace,monospace}
#rcpt h3{margin:0 0 2px;text-align:center}#rcpt .rt{display:flex;justify-content:space-between}
@media print{body *{visibility:hidden}#rcpt,#rcpt *{visibility:visible}#rcpt{position:absolute;inset:0;background:#fff}#rcpt .noprint{display:none}}
.ovl{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;z-index:60}
.ovl .card{background:#fff;border-radius:12px;padding:18px;width:360px;max-width:92vw}
#rcptCard{font:13px/1.6 ui-monospace,monospace}
</style>
</head>
<body>
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · sales</h1>
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · Sales</h1>
<div class="row"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="btn" onclick="signin()">Enter</button></div>
<div id="gerr" class="muted" style="margin-top:8px"></div></div></div>
<div class="wrap" id="app" style="display:none">
<h1 class="pg">Sales Management</h1>
<div class="tabs" id="tabs">
<button data-tab="sale" class="on" onclick="tab('sale')">Sales</button>
<button data-tab="plans" onclick="tab('plans')">Payment Plans</button>
<button data-tab="past" onclick="tab('past')">Past Sales</button>
<button data-tab="locate" onclick="tab('locate')">Locate</button>
<button data-tab="promo" onclick="tab('promo')">Promotions</button>
<button data-tab="import" onclick="tab('import')">Import</button>
<button data-tab="settings" onclick="tab('settings')">Settings</button>
<div class="top">
<h1>Record<b>God</b> Sales</h1>
<div class="tabs" id="tabs">
<button data-tab="register" class="on" onclick="tab('register')">🛒 Register</button>
<button data-tab="history" onclick="tab('history')">🧾 History</button>
<button data-tab="settings" onclick="tab('settings')">⚙️ Settings</button>
</div>
<span id="who" class="muted"></span>
</div>
<!-- ── SALES ─────────────────────────────────────────── -->
<div id="tab-sale" class="tab on">
<div class="panel">
<div class="row" style="justify-content:space-between">
<div class="row res" style="flex:1;min-width:280px">
<span class="lbl">Customer</span>
<span id="custChip" class="chip">👤 Guest Sale</span>
<input id="custQ" placeholder="search customers…" style="flex:1" autocomplete="off">
<div id="custDrop" class="drop" style="display:none"></div>
</div>
<div class="row">
<span class="muted">or</span>
<input id="newName" placeholder="Name" style="width:130px">
<input id="newEmail" placeholder="Email" style="width:150px">
<button class="ghost" onclick="addCustomer()">Add</button>
</div>
</div>
</div>
<!-- ════ REGISTER ════ -->
<div id="tab-register" class="tab on">
<div class="grid">
<!-- cart side -->
<div>
<div class="panel"><h2>Scan / enter SKU · Release ID · Barcode</h2>
<div class="row res">
<input id="q" placeholder="scan barcode or enter SKU / Release ID…" style="flex:1" autocomplete="off">
<button class="btn" onclick="scan()">Scan</button>
<button class="ghost" onclick="manualAdd()">Manual Add</button>
<div id="res" class="drop" style="display:none"></div>
<div class="panel">
<div class="row" style="justify-content:space-between">
<div class="row" style="flex:1">
<span id="custChip" class="chip" onclick="resetCust()">👤 Guest</span>
<div class="res"><input id="custQ" placeholder="attach customer…" autocomplete="off" style="width:100%">
<div id="custDrop" class="drop" style="display:none"></div></div>
</div>
<button class="ghost" onclick="newCustDlg()"> new</button>
</div>
</div>
<div class="panel"><h2>🛒 Cart</h2>
<div class="panel">
<div class="row"><div class="res">
<input id="q" placeholder="🔍 scan barcode or search title / artist / SKU…" autocomplete="off" style="width:100%" autofocus>
<div id="res" class="drop" style="display:none"></div></div>
<button class="ghost" onclick="bulkDlg()">bulk</button>
<button class="ghost" onclick="manualAdd()">manual</button>
</div>
</div>
<div class="panel">
<table><thead><tr><th>Item</th><th>Qty</th><th>Price</th><th>Disc</th><th>Line</th><th></th></tr></thead>
<tbody id="cart"></tbody></table>
<div id="empty" class="muted" style="padding:10px 0">cart is empty — scan or search to add items</div>
</div>
</div>
<!-- checkout / receipt side -->
<div>
<div class="panel"><h2>Total</h2>
<div class="panel" id="checkout">
<div class="tot"><span class="muted">Subtotal</span><span id="t-sub">$0.00</span></div>
<div id="promoLines"></div>
<div class="tot"><span class="muted">Cart discount $</span><input id="cartDisc" class="disc" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Trade-in credit</span><input id="trade" class="disc" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Tax %</span><input id="tax" class="disc" type="number" step="0.1" value="0" oninput="render()"></div>
<div class="tot big"><span>Total</span><b id="t-total">$0.00</b></div>
<div class="pillbtns">
<button class="btn" onclick="complete('cash')">Cash</button>
<button class="btn" onclick="complete('card')">Card</button>
<button class="btn" onclick="complete('eftpos')">EFTPOS</button>
<div class="tot"><span class="muted">Manual discount</span><input id="cartDisc" class="num" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Trade-in credit</span><input id="trade" class="num" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Tax %</span><input id="tax" class="num" type="number" step="0.1" value="0" oninput="render()"></div>
<div class="tot grand"><span>Total</span><b id="t-total">$0.00</b></div>
<div class="pm">
<button data-m="cash" onclick="setMethod('cash')">💵 Cash</button>
<button data-m="card" onclick="setMethod('card')">💳 Card</button>
<button data-m="eftpos" onclick="setMethod('eftpos')">EFTPOS</button>
<button data-m="split" onclick="setMethod('split')">⵹ Split</button>
<button data-m="layby" onclick="setMethod('layby')">⏸ Layby</button>
</div>
<div id="msg" class="muted"></div>
<div id="pCash" class="pp" style="display:none">
<div class="tot"><span class="muted">Tendered</span><input id="tendered" class="num" type="number" step="0.01" oninput="render()"></div>
<div class="tot"><span class="muted">Change</span><b id="change" class="pink">$0.00</b></div>
</div>
<div id="pSplit" class="pp" style="display:none">
<div id="splitRows"></div>
<button class="ghost" onclick="addSplit()" style="margin-top:6px"> add tender</button>
<div class="muted" id="splitMsg" style="margin-top:4px"></div>
</div>
<div id="pLayby" class="pp" style="display:none">
<div class="tot"><span class="muted">Deposit</span><input id="dep" class="num" type="number" step="0.01" value="0"></div>
<div class="tot"><span class="muted">Due date</span><input id="due" type="date"></div>
</div>
<input id="saleNote" placeholder="note (optional, prints on receipt)" style="width:100%;margin-top:10px">
<button class="btn big" id="completeBtn" style="margin-top:10px" onclick="complete()">Complete sale</button>
<div id="msg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>Promotions on now <span class="muted">— toggle in Promotions tab</span></h2>
<div id="activePromos" class="muted">none active</div>
<div class="panel" id="receiptPane" style="display:none">
<div id="rcptCard"></div>
<div class="row" style="margin-top:12px">
<button class="btn" style="flex:1" onclick="printReceipt()">🖨 Print</button>
<button class="ghost" onclick="emailDlg()">✉️ Email</button>
<button class="ghost pink" onclick="newSale()"> New sale</button>
</div>
<div id="rcptMsg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>Promotions on now <span class="muted">— manage in Settings</span></h2>
<div id="activePromos" class="muted">none active</div></div>
</div>
</div>
</div>
<!-- ── PAYMENT PLANS ─────────────────────────────────── -->
<div id="tab-plans" class="tab">
<div class="panel"><h2>💳 Put current cart on a payment plan</h2>
<div class="row"><span class="lbl">Deposit</span><input id="dep" class="disc" type="number" step="0.01" value="0">
<span class="lbl">Due date</span><input id="due" type="date">
<button class="btn" onclick="hold()">Hold cart &amp; take deposit</button></div>
<div class="muted" style="margin-top:6px">Builds from the Sales-tab cart: reserves the items, banks the deposit, balance due by the date.</div>
<div id="planMsg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>📋 Open plans &amp; holds</h2><div id="holds" class="muted">loading…</div></div>
</div>
<!-- ── PAST SALES ────────────────────────────────────── -->
<div id="tab-past" class="tab">
<!-- ════ HISTORY ════ -->
<div id="tab-history" class="tab">
<div class="panel"><h2>📋 Open laybys &amp; holds</h2><div id="holds" class="muted">loading…</div></div>
<div class="panel"><h2>Past sales</h2>
<div class="row"><input id="pastQ" placeholder="search sale # or customer…" style="flex:1" oninput="loadPast()"></div>
<table style="margin-top:10px"><thead><tr><th>Sale #</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th><th></th></tr></thead>
<input id="pastQ" placeholder="search sale # or customer…" style="width:100%;margin-bottom:8px" oninput="loadPast()">
<table><thead><tr><th>Sale #</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th><th></th></tr></thead>
<tbody id="pastRows"></tbody></table>
<div id="pastEmpty" class="muted" style="padding:10px 0"></div>
</div>
</div>
<!-- ── LOCATE (the search workstation, embedded) ─────── -->
<div id="tab-locate" class="tab">
<div class="muted" style="margin-bottom:8px">Find any item and light up its bin on the store map. <a class="pink" href="/search" target="_blank">open full screen ↗</a></div>
<iframe id="locFrame" data-src="/search" title="search and locate"></iframe>
</div>
<!-- ── PROMOTIONS ────────────────────────────────────── -->
<div id="tab-promo" class="tab">
<div class="panel"><h2>Promotions &amp; discounts <span class="muted">— switch on to apply at the counter</span></h2>
<div id="promoList" class="muted">loading…</div>
</div>
</div>
<!-- ── IMPORT ────────────────────────────────────────── -->
<div id="tab-import" class="tab">
<div class="panel"><h2>Bulk import sale</h2>
<div class="muted" style="margin-bottom:8px">One SKU / Release ID / barcode per line — resolves each to in-stock inventory, rings them up as one sale and marks them sold.</div>
<textarea id="impList" rows="8" style="width:100%" placeholder="12345&#10;ABC-001&#10;0123456789012"></textarea>
<div class="row" style="margin-top:8px"><button class="btn" onclick="runImport()">Resolve &amp; ring up</button>
<span id="impMsg" class="muted"></span></div>
<div id="impOut" class="muted" style="margin-top:8px"></div>
</div>
</div>
<!-- ── SETTINGS ──────────────────────────────────────── -->
<!-- ════ SETTINGS ════ -->
<div id="tab-settings" class="tab">
<div class="panel"><h2>Sales settings</h2>
<div class="row" style="margin-bottom:8px"><span class="lbl">Currency</span><input id="setCur" style="width:60px"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Tax %</span><input id="setTax" class="disc" type="number" step="0.1">
<select id="setTaxType"><option value="exclusive">added on top (exclusive)</option><option value="inclusive">included in price (inclusive)</option></select></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Default discount %</span><input id="setDisc" class="disc" type="number" step="0.1"></div>
<button class="btn" onclick="saveSettings()">Save settings</button>
<span id="setMsg" class="muted" style="margin-left:10px"></span>
<div class="panel"><h2>Receipt &amp; checkout</h2>
<div class="row" style="margin-bottom:8px"><span class="lbl">Store name</span><input id="setName" style="flex:1"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Address</span><input id="setAddr" style="flex:1" placeholder="prints under the store name"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Receipt footer</span><input id="setFooter" style="flex:1" placeholder="thanks for digging"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Currency</span><input id="setCur" style="width:60px">
<span class="lbl" style="min-width:40px">Tax %</span><input id="setTax" class="num" type="number" step="0.1">
<select id="setTaxType"><option value="exclusive">added on top</option><option value="inclusive">included (GST incl)</option></select></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Default disc %</span><input id="setDisc" class="num" type="number" step="0.1"></div>
<button class="btn" onclick="saveSettings()">Save</button><span id="setMsg" class="muted" style="margin-left:10px"></span>
</div>
<div class="panel"><h2>✉️ Receipt email — test</h2>
<div class="muted" style="margin-bottom:8px">SMTP credentials live in <a class="pink" href="/admin" target="_blank">admin → Connections</a> (admin-only). Send a sample to check they work.</div>
<div class="row"><input id="testEmail" type="email" placeholder="you@example.com" style="flex:1"><button class="btn" onclick="emailTest()">Send test</button></div>
<div id="testMsg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>Promotions &amp; discounts <span class="muted">— switch on to apply at the counter</span></h2>
<div id="promoList" class="muted">loading…</div></div>
</div>
</div>
<!-- printable receipt -->
<div id="rcpt" onclick="if(event.target.id==='rcpt')closeRcpt()"><div class="card" id="rcptCard"></div></div>
<!-- modals -->
<div id="dlg" class="ovl" onclick="if(event.target.id==='dlg')close_('dlg')"><div class="card" id="dlgCard"></div></div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'', cart=[], customer={id:0,name:'Guest Sale'};
let CUR='$', promos=[]; // promos = all discounts; manual_active+live drive the calc
let TOKEN=localStorage.getItem('rg_token')||'', cart=[], customer={id:0,name:'Guest'};
let CUR='$', promos=[], method='cash', splits=[], lastSale=null;
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
const money=n=>CUR+Number(n||0).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
const post=(u,b)=>fetch(u,{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(r=>r.json());
async function signin(){
TOKEN=($('#tok')&&$('#tok').value.trim())||TOKEN;
const r=await fetch('/admin/stats',{headers:hdr()});
if(!r.ok){ if($('#gerr'))$('#gerr').textContent='invalid token'; return; }
const me=await fetch('/admin/me',{headers:hdr()}); if(!me.ok){ if($('#gerr'))$('#gerr').textContent='invalid token'; return; }
$('#who').textContent=(await me.json()).name||'';
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='block';
loadSettings(); loadPromos(); loadHolds();
loadSettings(); loadPromos(); setMethod('cash');
}
function tab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
$('#tab-'+name).classList.add('on');
if(name==='past') loadPast();
if(name==='promo') loadPromos(true);
if(name==='sale') loadPromos();
if(name==='locate'){ const f=$('#locFrame'); if(!f.src) f.src=f.dataset.src; }
if(name==='history'){ loadPast(); loadHolds(); }
if(name==='settings'){ loadPromos(true); }
}
function close_(id){ $('#'+id).style.display='none'; }
/* ── customer ── */
let cst;
$('#custQ').addEventListener('input',e=>{ clearTimeout(cst); cst=setTimeout(()=>custSearch(e.target.value),180); });
async function custSearch(q){
const d=await get('/sales/customers?q='+encodeURIComponent(q));
const list=[{id:0,name:'Guest Sale',email:''}].concat(d.customers||[]);
$('#custDrop').style.display='block';
$('#custDrop').innerHTML=list.map(c=>`<div class="ri" onclick='pickCust(${JSON.stringify(c).replace(/'/g,"&#39;")})'>
<div class="t"><div class="nm">${esc(c.name)||'Guest Sale'}</div><div class="muted">${esc(c.email||'')}</div></div></div>`).join('');
$('#custDrop').innerHTML=(d.customers||[]).map(c=>`<div class="ri" onclick='pickCust(${JSON.stringify(c).replace(/'/g,"&#39;")})'>
<div class="t"><div class="nm">${esc(c.name)||'Guest'}</div><div class="muted">${esc(c.email||'')}</div></div></div>`).join('')||'<div class="muted" style="padding:8px">no match</div>';
}
function pickCust(c){ customer={id:c.id,name:c.name||'Guest',email:c.email||''}; $('#custChip').innerHTML='👤 '+esc(customer.name); $('#custDrop').style.display='none'; $('#custQ').value=''; }
function resetCust(){ customer={id:0,name:'Guest'}; $('#custChip').innerHTML='👤 Guest'; }
function newCustDlg(){
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 10px">New customer</h3>
<input id="ncName" placeholder="Name" style="width:100%;margin-bottom:8px">
<input id="ncEmail" type="email" placeholder="Email" style="width:100%;margin-bottom:8px">
<input id="ncPhone" placeholder="Phone" style="width:100%;margin-bottom:12px">
<div class="row"><button class="btn" style="flex:1" onclick="addCustomer()">Add &amp; attach</button><button class="ghost" onclick="close_('dlg')">Cancel</button></div>`;
$('#dlg').style.display='flex'; setTimeout(()=>$('#ncName').focus(),50);
}
function pickCust(c){ customer={id:c.id,name:c.name||'Guest Sale'}; $('#custChip').innerHTML='👤 '+esc(customer.name); $('#custDrop').style.display='none'; $('#custQ').value=''; }
async function addCustomer(){
const name=$('#newName').value.trim(), email=$('#newEmail').value.trim();
if(!name){ alert('name required'); return; }
const [fn,...rest]=name.split(' ');
const d=await fetch('/sales/customers',{method:'POST',headers:hdr(),body:JSON.stringify({first_name:fn,last_name:rest.join(' '),email})}).then(r=>r.json());
if(d.ok){ pickCust({id:d.id,name:d.name}); $('#newName').value='';$('#newEmail').value=''; }
const name=$('#ncName').value.trim(); if(!name){ return; }
const [fn,...r]=name.split(' ');
const d=await post('/sales/customers',{first_name:fn,last_name:r.join(' '),email:$('#ncEmail').value.trim(),phone:$('#ncPhone').value.trim()});
if(d.ok){ pickCust({id:d.id,name:d.name,email:$('#ncEmail').value.trim()}); close_('dlg'); }
}
document.addEventListener('click',e=>{ if(!e.target.closest('.res')) document.querySelectorAll('.drop').forEach(d=>d.style.display='none'); });
/* ── ring up ── */
/* ── cart ── */
let st;
$('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),180); });
$('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),170); });
$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter'){clearTimeout(st);scan();} });
async function doSearch(q){
if(!q.trim()){ $('#res').style.display='none'; return; }
@ -248,151 +250,205 @@ async function doSearch(q){
$('#res').style.display='block';
$('#res').innerHTML=(d.items||[]).map(it=>`<div class="ri" onclick='add(${JSON.stringify(it).replace(/'/g,"&#39;")})'>
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div><div class="muted">${esc(it.artist||'')} · ${it.condition||''} ${it.crate?'· 📍 '+esc(it.crate):''}</div></div>
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div><div class="muted">${esc(it.artist||'')} · ${it.condition||''}${it.crate?' · 📍 '+esc(it.crate):''}</div></div>
<div class="pink">${money(it.price)}</div></div>`).join('')||'<div class="muted" style="padding:8px">no in-stock match</div>';
}
async function scan(){ // exact resolve on Enter / Scan: first match straight into the cart
async function scan(){
const q=$('#q').value.trim(); if(!q) return;
const d=await get('/sales/search?q='+encodeURIComponent(q));
if(d.items&&d.items.length) add(d.items[0]); else $('#msg').innerHTML='<span class="warn">no in-stock match for "'+esc(q)+'"</span>';
}
function manualAdd(){
const name=prompt('Item name (manual line):'); if(!name) return;
const price=parseFloat(prompt('Price:','0'))||0;
cart.push({sku:'MANUAL-'+Date.now(),title:name,qty:1,price,discount:0,crate:null,manual:true}); $('#res').style.display='none'; render();
const name=prompt('Item name:'); if(!name) return;
cart.push({sku:'MANUAL-'+Date.now(),title:name,qty:1,price:parseFloat(prompt('Price:','0'))||0,discount:0,manual:true}); $('#res').style.display='none'; render();
}
function add(it){
const ex=cart.find(c=>c.sku===it.sku);
if(ex) ex.qty++; else cart.push({sku:it.sku,title:it.title||it.sku,qty:1,price:it.price||0,discount:0,crate:it.crate});
$('#q').value=''; $('#res').style.display='none'; $('#q').focus(); render();
}
function totals(){
const gross=cart.reduce((s,c)=>s+c.price*c.qty,0), ld=cart.reduce((s,c)=>s+(+c.discount||0),0), sub=gross-ld;
const active=promos.filter(p=>p.manual_active&&p.live);
const promoPct=Math.min(active.reduce((s,p)=>s+(+p.percentage||0),0),100), promoAmt=sub*promoPct/100;
const cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const after=Math.max(0,sub-promoAmt-cd), tax=after*tx/100, total=Math.max(0,after+tax-tr);
return {gross,ld,sub,active,promoAmt,cd,tr,tx,tax,total};
}
function render(){
$('#empty').style.display=cart.length?'none':'block';
$('#cart').innerHTML=cart.map((c,i)=>`<tr>
<td>${esc(c.title)}${c.manual?' <span class="muted">(manual)</span>':''}${c.crate?`<div class="muted">📍 ${esc(c.crate)}</div>`:''}</td>
<td><input class="qty" type="number" min="1" value="${c.qty}" oninput="upd(${i},'qty',this.value)"></td>
<td><input class="disc" type="number" step="0.01" value="${c.price}" oninput="upd(${i},'price',this.value)"></td>
<td><input class="disc" type="number" step="0.01" value="${c.discount}" oninput="upd(${i},'discount',this.value)"></td>
<td><input class="num" type="number" step="0.01" value="${c.price}" oninput="upd(${i},'price',this.value)"></td>
<td><input class="num" type="number" step="0.01" value="${c.discount}" oninput="upd(${i},'discount',this.value)"></td>
<td>${money(c.price*c.qty-c.discount)}</td><td><span class="x" onclick="rm(${i})"></span></td></tr>`).join('');
const gross=cart.reduce((s,c)=>s+c.price*c.qty,0), ld=cart.reduce((s,c)=>s+(+c.discount||0),0);
const sub=gross-ld;
const active=promos.filter(p=>p.manual_active&&p.live);
const promoPct=Math.min(active.reduce((s,p)=>s+(+p.percentage||0),0),100);
const promoAmt=sub*promoPct/100;
$('#promoLines').innerHTML=active.map(p=>`<div class="tot"><span class="muted">${esc(p.name)} ${p.percentage}%</span><span>${money(sub*p.percentage/100)}</span></div>`).join('');
const cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const after=sub-promoAmt-cd, tax=after*tx/100, total=after+tax-tr;
$('#t-sub').textContent=money(sub);
$('#t-total').textContent=money(Math.max(0,total));
const t=totals();
$('#promoLines').innerHTML=t.active.map(p=>`<div class="tot"><span class="muted">${esc(p.name)} ${p.percentage}%</span><span>${money(t.sub*p.percentage/100)}</span></div>`).join('');
$('#t-sub').textContent=money(t.sub);
$('#t-total').textContent=money(t.total);
const tend=+$('#tendered').value||0; $('#change').textContent=money(Math.max(0,tend-t.total));
if(method==='split') renderSplit(t.total);
}
function upd(i,k,v){ cart[i][k]= k==='qty'?Math.max(1,+v|0):(+v||0); render(); }
function rm(i){ cart.splice(i,1); render(); }
/* ── checkout ── */
function setMethod(m){
method=m;
document.querySelectorAll('.pm button').forEach(b=>b.classList.toggle('on',b.dataset.m===m));
['Cash','Split','Layby'].forEach(p=>$('#p'+p).style.display='none');
if(m==='cash'){ $('#pCash').style.display='block'; $('#tendered').focus&&setTimeout(()=>$('#tendered').focus(),30); }
if(m==='split'){ $('#pSplit').style.display='block'; if(!splits.length) splits=[{method:'cash',amount:0}]; renderSplit(totals().total); }
if(m==='layby'){ $('#pLayby').style.display='block'; }
$('#completeBtn').textContent = m==='layby'?'Hold layby & take deposit':'Complete '+m+' sale';
}
function addSplit(){ splits.push({method:'card',amount:0}); renderSplit(totals().total); }
function renderSplit(total){
$('#splitRows').innerHTML=splits.map((s,i)=>`<div class="row" style="margin-bottom:5px">
<select onchange="splits[${i}].method=this.value"><option ${s.method==='cash'?'selected':''}>cash</option><option ${s.method==='card'?'selected':''}>card</option><option ${s.method==='eftpos'?'selected':''}>eftpos</option></select>
<input class="num" type="number" step="0.01" value="${s.amount}" oninput="splits[${i}].amount=+this.value||0;splitSum()">
<span class="x" onclick="splits.splice(${i},1);renderSplit(totals().total)"></span></div>`).join('');
splitSum();
}
function splitSum(){
const sum=splits.reduce((s,x)=>s+(+x.amount||0),0), total=totals().total;
$('#splitMsg').innerHTML=`tendered ${money(sum)} of ${money(total)} · ${sum>=total-0.005?'<span class="ok">ok</span>':'<span class="warn">'+money(total-sum)+' short</span>'}`;
}
function payload(){
const gross=cart.reduce((s,c)=>s+c.price*c.qty,0), ld=cart.reduce((s,c)=>s+(+c.discount||0),0), sub=gross-ld;
const active=promos.filter(p=>p.manual_active&&p.live);
const promoAmt=sub*Math.min(active.reduce((s,p)=>s+(+p.percentage||0),0),100)/100;
const t=totals();
return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})),
cart_discount:promoAmt+(+$('#cartDisc').value||0), trade_in:+$('#trade').value||0,
tax_rate:+$('#tax').value||0, customer_id:customer.id };
cart_discount:t.promoAmt+t.cd, trade_in:t.tr, tax_rate:t.tx, customer_id:customer.id,
notes:$('#saleNote').value.trim()||null };
}
async function complete(method){
async function complete(){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
const t=totals(); let body={...payload()};
if(method==='layby'){
body.payment_method='layby'; body.hold={deposit:+$('#dep').value||0, due_date:$('#due').value||null};
} else if(method==='split'){
const sum=splits.reduce((s,x)=>s+(+x.amount||0),0);
if(sum<t.total-0.005){ $('#msg').innerHTML='<span class="warn">split is '+money(t.total-sum)+' short</span>'; return; }
body.payment_method='split'; body.split=splits.filter(s=>s.amount>0);
} else {
body.payment_method=method;
if(method==='cash'){ const tend=+$('#tendered').value||0;
if(tend && tend<t.total-0.005){ $('#msg').innerHTML='<span class="warn">tendered less than total</span>'; return; }
body.tendered=tend||t.total; }
}
$('#msg').textContent='processing…';
const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})}).then(r=>r.json());
if(d.ok){ $('#msg').innerHTML=`<span class="ok">✓ sale ${d.sale_number} · ${money(d.total)} ${method} · ${esc(customer.name)}</span>`;
cart=[]; render(); loadHolds(); } else $('#msg').textContent='failed';
}
async function hold(){
if(!cart.length){ $('#planMsg').textContent='cart is empty — add items on the Sales tab first'; return; }
const dep=+$('#dep').value||0, due=$('#due').value||null;
const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:'hold',hold:{deposit:dep,due_date:due}})}).then(r=>r.json());
if(d.ok){ $('#planMsg').innerHTML=`<span class="ok">✓ held ${d.sale_number} · deposit ${money(d.amount_paid)} · balance ${money(d.balance)}</span>`;
cart=[]; $('#dep').value=0; render(); loadHolds(); }
}
async function loadHolds(){
const d=await get('/sales?status=holds');
$('#holds').innerHTML=(d.sales&&d.sales.length)? d.sales.map(s=>`<div class="hold">
<span style="flex:1">${s.sale_number} · bal <b class="pink">${money(s.balance)}</b>${s.hold_expires_at?` · due ${String(s.hold_expires_at).slice(0,10)}`:''}</span>
<button class="ghost" onclick="payHold(${s.id},${s.balance})">Collect</button></div>`).join('') : '<div class="muted">no open plans</div>';
}
async function payHold(id,bal){
const amt=parseFloat(prompt('Amount to collect (balance '+money(bal)+'):', bal.toFixed(2)));
if(!amt) return;
const d=await fetch('/sales/'+id+'/pay',{method:'POST',headers:hdr(),body:JSON.stringify({amount:amt,method:'cash'})}).then(r=>r.json());
if(d.ok) loadHolds();
const d=await post('/sales',body);
if(d.ok){ lastSale=d.sale_id; showReceipt(d.sale_id); cart=[]; splits=[]; $('#saleNote').value=''; $('#tendered').value=''; $('#dep').value=0; render(); }
else $('#msg').innerHTML='<span class="warn">failed</span>';
}
/* ── past sales + receipt ── */
/* ── receipt ── */
let rcptHtml='';
async function showReceipt(id){
const d=await get('/sales/'+id+'/receipt'); rcptHtml=d.html||''; lastSale=id;
$('#rcptCard').innerHTML=rcptHtml;
$('#checkout').style.display='none'; $('#receiptPane').style.display='block';
$('#receiptPane').dataset.email=d.customer_email||customer.email||'';
$('#rcptMsg').innerHTML='<span class="ok">✓ sale '+esc(d.sale_number||'')+' complete</span>';
}
function printReceipt(){
const w=window.open('','_blank','width=380,height=680'); if(!w){ alert('allow pop-ups to print the receipt'); return; }
w.document.write('<!doctype html><html><head><meta charset=utf-8><title>Receipt</title><style>body{margin:16px;background:#fff}</style></head><body>'+rcptHtml+'</body></html>');
w.document.close(); w.focus(); setTimeout(()=>{ w.print(); }, 250);
}
function newSale(){ $('#receiptPane').style.display='none'; $('#checkout').style.display='block'; resetCust(); $('#msg').textContent=''; setMethod('cash'); $('#q').focus(); }
function emailDlg(){
const def=$('#receiptPane').dataset.email||'';
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 10px">Email receipt</h3>
<input id="emTo" type="email" value="${esc(def)}" placeholder="customer@example.com" style="width:100%;margin-bottom:12px">
<div class="row"><button class="btn" style="flex:1" onclick="sendEmail()">Send</button><button class="ghost" onclick="close_('dlg')">Cancel</button></div>
<div id="emMsg" class="muted" style="margin-top:8px"></div>`;
$('#dlg').style.display='flex'; setTimeout(()=>$('#emTo').focus(),50);
}
async function sendEmail(){
const to=$('#emTo').value.trim(); if(!to||to.indexOf('@')<0){ $('#emMsg').textContent='enter a valid email'; return; }
$('#emMsg').textContent='sending…';
const r=await fetch('/sales/'+lastSale+'/email',{method:'POST',headers:hdr(),body:JSON.stringify({email:to})});
const d=await r.json().catch(()=>({}));
if(r.ok&&d.ok){ close_('dlg'); $('#rcptMsg').innerHTML='<span class="ok">✓ emailed to '+esc(to)+'</span>'; }
else $('#emMsg').innerHTML='<span class="warn">'+esc(d.detail||'send failed — check SMTP in Connections')+'</span>';
}
/* ── layby / holds ── */
async function loadHolds(){
const d=await get('/sales?status=holds');
$('#holds').innerHTML=(d.sales&&d.sales.length)? d.sales.map(s=>`<div class="row" style="padding:6px 0;border-bottom:1px solid #ededf2">
<span style="flex:1">${esc(s.sale_number)} · bal <b class="pink">${money(s.balance)}</b>${s.hold_expires_at?' · due '+String(s.hold_expires_at).slice(0,10):''}</span>
<button class="ghost" onclick="collect(${s.id},${s.balance})">Collect</button></div>`).join('') : '<div class="muted">no open laybys</div>';
}
async function collect(id,bal){
const amt=parseFloat(prompt('Collect amount (balance '+money(bal)+'):', bal.toFixed(2))); if(!amt) return;
const d=await post('/sales/'+id+'/pay',{amount:amt,method:'cash'}); if(d.ok){ loadHolds();
if(d.completed){ showReceipt(id); tab('register'); } }
}
/* ── past sales ── */
let pst;
async function loadPast(){
clearTimeout(pst); pst=setTimeout(async()=>{
const d=await get('/sales/past?q='+encodeURIComponent($('#pastQ').value||''));
$('#pastEmpty').textContent=(d.sales&&d.sales.length)?'':'no sales found';
$('#pastRows').innerHTML=(d.sales||[]).map(s=>`<tr>
<td class="pink">${esc(s.sale_number||('#'+s.id))}</td><td>${s.sale_date?String(s.sale_date).slice(0,10):'—'}</td>
<td>${esc(s.customer)}</td><td>${s.items}</td><td>${money(s.total)}</td>
<td><span class="muted">${esc(s.payment_status||s.status||'')}</span></td>
<td><button class="ghost" onclick="receipt(${s.id})">Receipt</button></td></tr>`).join('');
},150);
}
async function receipt(id){
const d=await get('/sales/'+id);
const s=d.sale, it=d.items||[];
$('#rcptCard').innerHTML=`<h3>RecordGod</h3><div style="text-align:center" class="muted">${esc(s.sale_number||'')}</div>
<div class="muted" style="text-align:center;margin-bottom:8px">${s.sale_date?String(s.sale_date).slice(0,16).replace('T',' '):''}</div><hr>
${it.map(i=>`<div class="rt"><span>${i.qty}× ${esc(i.item_name||i.sku)}</span><span>${money(i.line_total)}</span></div>`).join('')}
<hr><div class="rt"><b>Total</b><b>${money(s.total)}</b></div>
<div class="rt muted"><span>${esc(s.payment_method||'')}</span><span>${esc(s.payment_status||s.status||'')}</span></div>
<div style="text-align:center;margin-top:10px" class="muted">thanks for digging 🎶</div>
<div class="noprint" style="display:flex;gap:8px;margin-top:14px"><button class="btn" style="flex:1" onclick="window.print()">Print</button><button class="ghost" onclick="closeRcpt()">Close</button></div>`;
$('#rcpt').style.display='flex';
}
function closeRcpt(){ $('#rcpt').style.display='none'; }
function loadPast(){ clearTimeout(pst); pst=setTimeout(async()=>{
const d=await get('/sales/past?q='+encodeURIComponent($('#pastQ').value||''));
$('#pastEmpty').textContent=(d.sales&&d.sales.length)?'':'no sales found';
$('#pastRows').innerHTML=(d.sales||[]).map(s=>`<tr>
<td class="pink">${esc(s.sale_number||('#'+s.id))}</td><td>${s.sale_date?String(s.sale_date).slice(0,10):'—'}</td>
<td>${esc(s.customer)}</td><td>${s.items}</td><td>${money(s.total)}</td>
<td><span class="muted">${esc(s.payment_status||s.status||'')}</span></td>
<td><button class="ghost" onclick="reprint(${s.id})">Receipt</button></td></tr>`).join('');
},150); }
async function reprint(id){ tab('register'); await showReceipt(id); }
/* ── promotions ── */
async function loadPromos(forList){
promos=(await get('/sales/discounts?active_only=false')).discounts||[];
const active=promos.filter(p=>p.manual_active&&p.live);
$('#activePromos').innerHTML=active.length? active.map(p=>`<span class="chip" style="margin:2px">${esc(p.name)} ${p.percentage}%</span>`).join('') : 'none active';
$('#activePromos').innerHTML=active.length? active.map(p=>`<span class="chip" style="margin:2px;cursor:default">${esc(p.name)} ${p.percentage}%</span>`).join('') : 'none active';
render();
if(forList) $('#promoList').innerHTML=promos.length? promos.map(p=>`<div class="promo">
<span><b>${esc(p.name)}</b> · ${p.percentage}% <span class="muted">${esc(p.discount_type||'')}${p.description?' · '+esc(p.description):''}</span>${p.live?'':' <span class="warn">(scheduled/expired)</span>'}</span>
<button class="ghost ${p.manual_active?'on':''}" onclick="togglePromo(${p.id})">${p.manual_active?'ON':'off'}</button></div>`).join('') : '<div class="muted">no discounts defined yet — they migrate from WowPlatter</div>';
if(forList) $('#promoList').innerHTML=promos.length? promos.map(p=>`<div class="row" style="justify-content:space-between;padding:6px 0;border-bottom:1px solid #ededf2">
<span><b>${esc(p.name)}</b> · ${p.percentage}% <span class="muted">${esc(p.discount_type||'')}${p.live?'':' · scheduled/expired'}</span></span>
<button class="ghost ${p.manual_active?'on':''}" onclick="togglePromo(${p.id})">${p.manual_active?'ON':'off'}</button></div>`).join('') : '<div class="muted">no discounts defined yet</div>';
}
async function togglePromo(id){ await fetch('/sales/discounts/'+id+'/toggle',{method:'POST',headers:hdr()}); loadPromos(true); }
/* ── import ── */
async function runImport(){
const lines=$('#impList').value.split('\n').map(s=>s.trim()).filter(Boolean);
if(!lines.length) return;
$('#impMsg').textContent='resolving '+lines.length+'…';
const found=[], miss=[];
/* ── bulk add ── */
function bulkDlg(){
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 8px">Bulk add to cart</h3>
<div class="muted" style="margin-bottom:6px">One SKU / Release ID / barcode per line.</div>
<textarea id="bulkList" rows="7" style="width:100%;margin-bottom:8px" placeholder="12345&#10;ABC-001"></textarea>
<div class="row"><button class="btn" style="flex:1" onclick="runBulk()">Resolve &amp; add</button><button class="ghost" onclick="close_('dlg')">Cancel</button></div>
<div id="bulkMsg" class="muted" style="margin-top:6px"></div>`;
$('#dlg').style.display='flex';
}
async function runBulk(){
const lines=$('#bulkList').value.split('\n').map(s=>s.trim()).filter(Boolean); if(!lines.length) return;
$('#bulkMsg').textContent='resolving '+lines.length+'…'; let n=0, miss=[];
for(const ln of lines){ const d=await get('/sales/search?q='+encodeURIComponent(ln));
if(d.items&&d.items.length) found.push(d.items[0]); else miss.push(ln); }
if(!found.length){ $('#impMsg').textContent=''; $('#impOut').innerHTML='<span class="warn">nothing resolved</span>'; return; }
const body={items:found.map(it=>({sku:it.sku,title:it.title,qty:1,unit_price:it.price||0,discount:0})),
cart_discount:0,trade_in:0,tax_rate:0,payment_method:'import',customer_id:customer.id};
const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(r=>r.json());
$('#impMsg').textContent='';
$('#impOut').innerHTML=`<span class="ok">✓ ${found.length} sold as ${d.sale_number} · ${money(d.total)}</span>`+
(miss.length?`<div class="warn">not found: ${miss.map(esc).join(', ')}</div>`:'');
loadHolds();
if(d.items&&d.items.length){ add(d.items[0]); n++; } else miss.push(ln); }
$('#bulkMsg').innerHTML=`<span class="ok">✓ added ${n}</span>`+(miss.length?` <span class="warn">· not found: ${miss.map(esc).join(', ')}</span>`:'');
if(n) setTimeout(()=>close_('dlg'),700);
}
/* ── settings ── */
async function loadSettings(){
const s=await get('/sales/settings');
CUR=s.currency_symbol||'$';
$('#setCur').value=CUR; $('#setTax').value=s.tax_rate; $('#setDisc').value=s.discount_rate;
$('#setTaxType').value=s.tax_type||'exclusive';
const s=await get('/sales/settings'); CUR=s.currency_symbol||'$';
$('#setName').value=s.store_name||''; $('#setAddr').value=s.store_address||''; $('#setFooter').value=s.receipt_footer||'';
$('#setCur').value=CUR; $('#setTax').value=s.tax_rate; $('#setDisc').value=s.discount_rate; $('#setTaxType').value=s.tax_type||'exclusive';
if(!(+$('#tax').value)) $('#tax').value=s.tax_rate||0;
render();
}
async function saveSettings(){
const body={currency_symbol:$('#setCur').value,tax_rate:$('#setTax').value,discount_rate:$('#setDisc').value,tax_type:$('#setTaxType').value};
await fetch('/sales/settings',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
CUR=body.currency_symbol||'$'; $('#setMsg').textContent='saved'; setTimeout(()=>$('#setMsg').textContent='',1500); render();
await post('/sales/settings',{currency_symbol:$('#setCur').value,tax_rate:$('#setTax').value,discount_rate:$('#setDisc').value,
tax_type:$('#setTaxType').value,store_name:$('#setName').value,store_address:$('#setAddr').value,receipt_footer:$('#setFooter').value});
CUR=$('#setCur').value||'$'; $('#setMsg').textContent='saved'; setTimeout(()=>$('#setMsg').textContent='',1500); render();
}
async function emailTest(){
const to=$('#testEmail').value.trim(); if(!to||to.indexOf('@')<0){ $('#testMsg').textContent='enter a valid email'; return; }
$('#testMsg').textContent='sending…';
const r=await fetch('/sales/email-test',{method:'POST',headers:hdr(),body:JSON.stringify({email:to})});
const d=await r.json().catch(()=>({}));
$('#testMsg').innerHTML=(r.ok&&d.ok)?`<span class="ok">✓ test sent to ${esc(to)} from ${esc(d.from||'')}</span>`:`<span class="warn">${esc(d.detail||'failed — set smtp_* in admin → Connections')}</span>`;
}
if(TOKEN){ signin(); }