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:
parent
b46930d468
commit
5351cfb9c2
60
app/mailer.py
Normal file
60
app/mailer.py
Normal 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"]
|
||||
@ -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
84
app/receipts.py
Normal 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>"""
|
||||
@ -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
|
||||
|
||||
@ -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)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
550
site/pos.html
550
site/pos.html
@ -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 & 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 & 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 & 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 & 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 ABC-001 0123456789012"></textarea>
|
||||
<div class="row" style="margin-top:8px"><button class="btn" onclick="runImport()">Resolve & 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 & 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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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,"'")})'>
|
||||
<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,"'")})'>
|
||||
<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 & 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,"'")})'>
|
||||
${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 ABC-001"></textarea>
|
||||
<div class="row"><button class="btn" style="flex:1" onclick="runBulk()">Resolve & 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(); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user