feat(pos): full Sales console + migrate customers/discounts/settings

This commit is contained in:
type-two 2026-06-22 02:40:35 +10:00
parent 584fd140ad
commit eb502efaf4
5 changed files with 501 additions and 76 deletions

View File

@ -37,6 +37,15 @@ app.include_router(sales_router)
_STARTUP_DDL = [ _STARTUP_DDL = [
"CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())", "CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())",
# POS reference tables (populated by migrate.py; created here so prod has them on deploy)
"CREATE TABLE IF NOT EXISTS customer (id bigint PRIMARY KEY, wp_user_id bigint, first_name text NOT NULL DEFAULT '', last_name text DEFAULT '', email text, phone text, address text, is_guest boolean NOT NULL DEFAULT false, created_at timestamptz DEFAULT now())",
"CREATE SEQUENCE IF NOT EXISTS customer_id_seq",
"ALTER TABLE customer ALTER COLUMN id SET DEFAULT nextval('customer_id_seq')",
"SELECT setval('customer_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM customer)))",
"CREATE TABLE IF NOT EXISTS sales_discount (id bigint PRIMARY KEY, name text NOT NULL, percentage numeric(5,2) NOT NULL DEFAULT 0, is_active boolean NOT NULL DEFAULT true, manual_active boolean NOT NULL DEFAULT false, discount_type text DEFAULT 'manual', description text, bubble_text text, bubble_color text, start_at timestamptz, end_at timestamptz, created_at timestamptz DEFAULT now())",
"CREATE TABLE IF NOT EXISTS sales_setting (setting_key text PRIMARY KEY, setting_value text, is_active boolean NOT NULL DEFAULT true, updated_at timestamptz DEFAULT now())",
# a Guest customer always available at the counter
"INSERT INTO customer (id, first_name, last_name, email, is_guest) VALUES (0, 'Guest', 'Sale', NULL, true) ON CONFLICT (id) DO NOTHING",
# POS fields on the migrated sales tables # POS fields on the migrated sales tables
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS subtotal numeric(10,2)", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS subtotal numeric(10,2)",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",

View File

@ -30,7 +30,7 @@ class SaleIn(BaseModel):
split: list[dict] | None = None split: list[dict] | None = None
hold: dict | None = None hold: dict | None = None
trade_in: float = 0 trade_in: float = 0
customer: str | None = None customer_id: int | None = None # 0 = guest sale; None when unset
class PayIn(BaseModel): class PayIn(BaseModel):
@ -38,6 +38,14 @@ class PayIn(BaseModel):
method: str = "cash" method: str = "cash"
class CustomerIn(BaseModel):
first_name: str
last_name: str | None = ""
email: str | None = None
phone: str | None = None
address: str | None = None
@router.get("/search") @router.get("/search")
async def search_items(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)): async def search_items(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
"""Find in-stock items to ring up — title/artist/sku/barcode. The counter's search bar.""" """Find in-stock items to ring up — title/artist/sku/barcode. The counter's search bar."""
@ -74,14 +82,15 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
hold_due = body.hold.get("due_date") if is_hold else None hold_due = body.hold.get("due_date") if is_hold else None
sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper() sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0)
sale_id = (await db.execute(text(""" sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total, INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total,
status, payment_method, payment_status, amount_paid, trade_in_credit, status, payment_method, payment_status, amount_paid, trade_in_credit,
hold_expires_at, split_payments, sale_date, created_at) hold_expires_at, split_payments, sale_date, created_at)
VALUES (:sn, NULL, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade, VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade,
:due, CAST(:split AS jsonb), now(), now()) :due, CAST(:split AS jsonb), now(), now())
RETURNING id RETURNING id
"""), {"sn": sale_number, "sub": gross - line_disc, "disc": line_disc + body.cart_discount, """), {"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, "tax": tax, "total": total, "st": status, "pm": body.payment_method, "ps": pay_status,
"paid": amount_paid, "trade": body.trade_in, "due": hold_due, "paid": amount_paid, "trade": body.trade_in, "due": hold_due,
"split": json.dumps(body.split) if body.split else None})).first()[0] "split": json.dumps(body.split) if body.split else None})).first()[0]
@ -153,3 +162,101 @@ async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token),
"""), {"i": sale_id}) """), {"i": sale_id})
await db.commit() await db.commit()
return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done} return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done}
# ── Customers ────────────────────────────────────────────────────────────────────────────────
@router.get("/customers")
async def list_customers(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if q.strip():
where = ("WHERE first_name ILIKE :q OR last_name ILIKE :q OR email ILIKE :q "
"OR (first_name || ' ' || coalesce(last_name,'')) ILIKE :q")
params = {"q": f"%{q.strip()}%"}
rows = await db.execute(text(f"""
SELECT id, trim(first_name || ' ' || coalesce(last_name,'')) AS name, email, phone, is_guest
FROM customer {where} ORDER BY is_guest DESC, first_name, last_name LIMIT 300
"""), params)
return {"customers": [dict(r) for r in rows.mappings()]}
@router.post("/customers")
async def add_customer(body: CustomerIn, ident=Depends(require_token), db=Depends(get_db)):
if not body.first_name.strip():
raise HTTPException(400, "first name required")
row = (await db.execute(text("""
INSERT INTO customer (first_name, last_name, email, phone, address, is_guest)
VALUES (:fn, :ln, :em, :ph, :ad, false) RETURNING id
"""), {"fn": body.first_name.strip(), "ln": (body.last_name or "").strip(),
"em": body.email, "ph": body.phone, "ad": body.address})).first()
await db.commit()
return {"ok": True, "id": row[0], "name": f"{body.first_name} {body.last_name or ''}".strip()}
# ── Discounts / promotions ───────────────────────────────────────────────────────────────────
@router.get("/discounts")
async def list_discounts(active_only: bool = Query(True), ident=Depends(require_token), db=Depends(get_db)):
where = "WHERE is_active" if active_only else ""
rows = await db.execute(text(f"""
SELECT id, name, percentage::float AS percentage, discount_type, description, manual_active,
bubble_text, bubble_color,
(is_active AND (start_at IS NULL OR start_at <= now())
AND (end_at IS NULL OR end_at >= now())) AS live
FROM sales_discount {where} ORDER BY manual_active DESC, percentage DESC
"""))
return {"discounts": [dict(r) for r in rows.mappings()]}
@router.post("/discounts/{discount_id}/toggle")
async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Depends(get_db)):
row = (await db.execute(text(
"UPDATE sales_discount SET manual_active = NOT manual_active WHERE id=:i RETURNING manual_active"
), {"i": discount_id})).first()
if not row:
raise HTTPException(404, "not found")
await db.commit()
return {"ok": True, "manual_active": row[0]}
# ── Settings (currency / tax / default discount) ─────────────────────────────────────────────
@router.get("/settings")
async def get_settings(ident=Depends(require_token), db=Depends(get_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")}
@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")}
for k, v in m.items():
if v is None:
continue
await db.execute(text("""
INSERT INTO sales_setting (setting_key, setting_value, updated_at) VALUES (:k, :v, now())
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now()
"""), {"k": k, "v": str(v)})
await db.commit()
return {"ok": True}
# ── Past sales (the migrated history — receipts / lookup / refund) ────────────────────────────
@router.get("/past")
async def past_sales(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if q.strip():
where = ("WHERE s.sale_number ILIKE :q "
"OR trim(c.first_name || ' ' || coalesce(c.last_name,'')) ILIKE :q")
params = {"q": f"%{q.strip()}%"}
rows = await db.execute(text(f"""
SELECT s.id, s.sale_number, s.total::float AS total, s.status, s.payment_method,
s.payment_status, s.sale_date,
coalesce(nullif(trim(c.first_name || ' ' || coalesce(c.last_name,'')), ''), 'Guest') AS customer,
(SELECT count(*) FROM sale_items si WHERE si.sale_id = s.id) AS items
FROM sales s LEFT JOIN customer c ON c.id = s.customer_id
{where} ORDER BY s.sale_date DESC NULLS LAST LIMIT 100
"""), params)
return {"sales": [dict(r) for r in rows.mappings()]}

View File

@ -69,7 +69,7 @@ def main():
pg.execute(open(HERE / "schema.sql").read()) pg.execute(open(HERE / "schema.sql").read())
pg.commit() pg.commit()
if truncate: if truncate:
pg.execute("TRUNCATE inventory, sale_items, sales, crate") pg.execute("TRUNCATE inventory, sale_items, sales, crate, customer, sales_discount, sales_setting")
c = my.cursor() c = my.cursor()
@ -136,6 +136,30 @@ def main():
n_orphan = sum(1 for i in items if i["sale_id"] not in sale_ids) n_orphan = sum(1 for i in items if i["sale_id"] not in sale_ids)
items = [i for i in items if i["sale_id"] in sale_ids] items = [i for i in items if i["sale_id"] in sale_ids]
# --- customers (the POS customer picker) ---------------------------------------------------
c.execute("SELECT * FROM wp_rmp_disc_sales_customers")
customers = [dict(
id=r["id"], wp_user_id=r["wp_user_id"], first_name=r["first_name"] or "",
last_name=r["last_name"] or "", email=r["email"], phone=r["phone"],
address=r.get("address") or r.get("address_line1"), is_guest=bool(r["is_guest"]),
created_at=r["created_at"] or now,
) for r in c.fetchall()]
# --- named discounts / promotions ----------------------------------------------------------
c.execute("SELECT * FROM wp_rmp_disc_sales_discounts")
discounts = [dict(
id=r["id"], name=r["name"], percentage=r["percentage"], is_active=bool(r["is_active"]),
manual_active=bool(r["manual_active"]), discount_type=r["discount_type"],
description=r["description"], bubble_text=r["bubble_text"], bubble_color=r["bubble_color"],
start_at=r["start_at"], end_at=r["end_at"], created_at=r["created_at"] or now,
) for r in c.fetchall()]
# --- sales settings (currency / tax / default discount) ------------------------------------
c.execute("SELECT * FROM wp_rmp_disc_sales_settings")
settings = [dict(setting_key=r["setting_key"], setting_value=r["setting_value"],
is_active=bool(r["is_active"]), updated_at=r["updated_at"] or now)
for r in c.fetchall()]
# order matters: crate + sales before things that could reference them # order matters: crate + sales before things that could reference them
n = {} n = {}
n["crate"] = insert_many(pg, "crate", ["id", "name", "label_text", "purpose", "notes"], crates) n["crate"] = insert_many(pg, "crate", ["id", "name", "label_text", "purpose", "notes"], crates)
@ -147,6 +171,15 @@ def main():
n["sale_items"] = insert_many(pg, "sale_items", n["sale_items"] = insert_many(pg, "sale_items",
["id", "sale_id", "sku", "release_id", "item_name", "qty", ["id", "sale_id", "sku", "release_id", "item_name", "qty",
"unit_price", "line_total"], items) "unit_price", "line_total"], items)
n["customer"] = insert_many(pg, "customer",
["id", "wp_user_id", "first_name", "last_name", "email", "phone",
"address", "is_guest", "created_at"], customers)
n["sales_discount"] = insert_many(pg, "sales_discount",
["id", "name", "percentage", "is_active", "manual_active",
"discount_type", "description", "bubble_text", "bubble_color",
"start_at", "end_at", "created_at"], discounts)
n["sales_setting"] = insert_many(pg, "sales_setting",
["setting_key", "setting_value", "is_active", "updated_at"], settings)
pg.commit() pg.commit()
for k, v in n.items(): for k, v in n.items():
print(f" {k:22} {v:>6}") print(f" {k:22} {v:>6}")

View File

@ -105,5 +105,44 @@ CREATE TABLE IF NOT EXISTS app_secret (
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
-- POS customers (from wp_rmp_disc_sales_customers) — the counter's customer dropdown + add-new.
CREATE TABLE IF NOT EXISTS customer (
id bigint PRIMARY KEY,
wp_user_id bigint,
first_name text NOT NULL DEFAULT '',
last_name text DEFAULT '',
email text,
phone text,
address text,
is_guest boolean NOT NULL DEFAULT false,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS customer_name_idx ON customer (lower(first_name), lower(last_name));
CREATE INDEX IF NOT EXISTS customer_email_idx ON customer (lower(email));
-- Named discounts / promotions (from wp_rmp_disc_sales_discounts) — the Promotions tab + POS promo picker.
CREATE TABLE IF NOT EXISTS sales_discount (
id bigint PRIMARY KEY,
name text NOT NULL,
percentage numeric(5,2) NOT NULL DEFAULT 0,
is_active boolean NOT NULL DEFAULT true,
manual_active boolean NOT NULL DEFAULT false, -- currently switched on at the counter
discount_type text DEFAULT 'manual', -- manual|promotion|tax
description text,
bubble_text text,
bubble_color text,
start_at timestamptz,
end_at timestamptz,
created_at timestamptz DEFAULT now()
);
-- Sales settings (from wp_rmp_disc_sales_settings) — currency / tax / default discount, key-value.
CREATE TABLE IF NOT EXISTS sales_setting (
setting_key text PRIMARY KEY,
setting_value text,
is_active boolean NOT NULL DEFAULT true,
updated_at timestamptz DEFAULT now()
);
-- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search -- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search
-- measurably falls short, not before. -- measurably falls short, not before.

View File

@ -6,35 +6,50 @@
<title>RecordGod — sales</title> <title>RecordGod — sales</title>
<script defer src="/nav.js?v=3"></script> <script defer src="/nav.js?v=3"></script>
<style> <style>
:root{--pink:#ff5db1;--ink:#0b0b0d;--pn:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046} :root{--pink:#ff5db1;--ink:#0b0b0d;--pn:#141418;--pn2:#0e0e11;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif} html,body{margin:0;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui} button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.btn{background:var(--pink);color:#1a0a14;padding:10px 14px} .btn{background:var(--pink);color:#1a0a14;padding:10px 14px}
.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line);padding:8px 11px} .ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line);padding:8px 11px}
input,select{padding:8px 9px;border:1px solid var(--line);border-radius:7px;background:#0e0e11;color:#eee;font:14px system-ui} .ghost.on{background:#221a20;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:#eee;font:14px system-ui}
#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(--ink);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)} #gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
#app{display:none;grid-template-columns:1.3fr 1fr;gap:18px;padding:18px;max-width:1200px;margin:0 auto} .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)}
.tab{display:none}.tab.on{display:block}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px} .panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px}
.panel h2{font-size:14px;font-weight:600;margin:0 0 10px} .panel h2{font-size:14px;font-weight:600;margin:0 0 10px;color:#ddd}
.row{display:flex;gap:8px;align-items:center} .grid{display:grid;grid-template-columns:1.35fr 1fr;gap:16px}
.res{max-height:240px;overflow:auto;margin-top:8px} .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.ri{display:flex;gap:10px;align-items:center;padding:7px;border-radius:8px;cursor:pointer} .lbl{color:var(--mut);font-size:12px;min-width:74px}
.ri:hover{background:#17171c}.ri img,.ri .ph{width:38px;height:38px;border-radius:5px;object-fit:cover;background:#222;flex-shrink:0} .res{position:relative}
.ri .t{flex:1;min-width:0}.ri .t .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .drop{position:absolute;left:0;right:0;top:calc(100% + 4px);background:#15151a;border:1px solid var(--line);border-radius:9px;max-height:280px;overflow:auto;z-index:20}
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)} .ri{display:flex;gap:10px;align-items:center;padding:8px;cursor:pointer}.ri:hover{background:#1d1d24}
.ri img,.ri .ph{width:38px;height:38px;border-radius:5px;object-fit:cover;background:#222;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:#221a20;color:var(--pink);padding:6px 10px;border-radius:20px;font-weight:600}
table{width:100%;border-collapse:collapse;font-size:13px} table{width:100%;border-collapse:collapse;font-size:13px}
td,th{padding:6px 5px;border-bottom:1px solid #1b1b20;text-align:left} td,th{padding:7px 6px;border-bottom:1px solid #1b1b20;text-align:left}
th{color:var(--mut);font-weight:500} th{color:var(--mut);font-weight:500}
.qty{width:46px}.disc{width:62px} .qty{width:46px}.disc{width:66px}
.tot{display:flex;justify-content:space-between;padding:4px 0} .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{font-size:22px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
.tot.big b{color:var(--pink)} .tot.big b{color:var(--pink)}
.pillbtns{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0} .pillbtns{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}.pillbtns button{flex:1}
.pillbtns button{flex:1} .promo{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1b1b20}
.hold{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid #1b1b20;font-size:13px}
.x{color:var(--mut);cursor:pointer} .x{color:var(--mut);cursor:pointer}
.hold{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid #1b1b20;font-size:13px}
iframe{width:100%;height:72vh;border:1px solid var(--line);border-radius:12px;background:var(--pn2)}
#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}}
</style> </style>
</head> </head>
<body> <body>
@ -42,83 +57,230 @@
<div class="row"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="btn" onclick="signin()">Enter</button></div> <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 id="gerr" class="muted" style="margin-top:8px"></div></div></div>
<div id="app"> <div class="wrap" id="app" style="display:none">
<div> <h1 class="pg">Sales Management</h1>
<div class="panel"><h2>🔍 Ring up <span class="muted">— search title / artist / SKU / scan</span></h2> <div class="tabs" id="tabs">
<div class="row"><input id="q" placeholder="search or scan to add…" style="flex:1" autocomplete="off"></div> <button data-tab="sale" class="on" onclick="tab('sale')">Sales</button>
<div id="res" class="res"></div></div> <button data-tab="plans" onclick="tab('plans')">Payment Plans</button>
<div class="panel"><h2>🛒 Cart</h2> <button data-tab="past" onclick="tab('past')">Past Sales</button>
<table><thead><tr><th>Item</th><th>Qty</th><th>Price</th><th>Disc</th><th>Line</th><th></th></tr></thead> <button data-tab="locate" onclick="tab('locate')">Locate</button>
<tbody id="cart"></tbody></table> <button data-tab="promo" onclick="tab('promo')">Promotions</button>
<div id="empty" class="muted" style="padding:10px 0">cart is empty — search above to add items</div></div> <button data-tab="import" onclick="tab('import')">Import</button>
<button data-tab="settings" onclick="tab('settings')">Settings</button>
</div> </div>
<div> <!-- ── SALES ─────────────────────────────────────────── -->
<div class="panel"><h2>Total</h2> <div id="tab-sale" class="tab on">
<div class="tot"><span class="muted">Subtotal</span><span id="t-sub">$0.00</span></div> <div class="panel">
<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="row" style="justify-content:space-between">
<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="row res" style="flex:1;min-width:280px">
<div class="tot"><span class="muted">Tax %</span><input id="tax" class="disc" type="number" step="0.1" value="0" oninput="render()"></div> <span class="lbl">Customer</span>
<div class="tot big"><span>Total</span><b id="t-total">$0.00</b></div> <span id="custChip" class="chip">👤 Guest Sale</span>
<div class="pillbtns"> <input id="custQ" placeholder="search customers…" style="flex:1" autocomplete="off">
<button class="btn" onclick="complete('cash')">Cash</button> <div id="custDrop" class="drop" style="display:none"></div>
<button class="btn" onclick="complete('card')">Card</button> </div>
<button class="btn" onclick="complete('eftpos')">EFTPOS</button> <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>
<div id="msg" class="muted"></div></div> </div>
<div class="panel"><h2>💳 Payment plan / hold</h2> <div class="grid">
<div class="row"><span class="muted">Deposit</span><input id="dep" class="disc" type="number" step="0.01" value="0"> <div>
<span class="muted">Due</span><input id="due" type="date"><button class="ghost" onclick="hold()">Put on hold</button></div> <div class="panel"><h2>Scan / enter SKU · Release ID · Barcode</h2>
<div class="muted" style="margin-top:6px">Reserves the items, takes the deposit, balance due by the date.</div></div> <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>
</div>
<div class="panel"><h2>🛒 Cart</h2>
<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>
<div class="panel"><h2>📋 Open holds <span class="muted">— collect balance</span></h2> <div>
<div id="holds" class="muted">loading…</div></div> <div class="panel"><h2>Total</h2>
<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>
<div id="msg" class="muted"></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>
</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">
<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>
<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 ──────────────────────────────────────── -->
<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>
</div> </div>
</div> </div>
<!-- printable receipt -->
<div id="rcpt" onclick="if(event.target.id==='rcpt')closeRcpt()"><div class="card" id="rcptCard"></div></div>
<script> <script>
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'', cart=[]; 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
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'}); const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
const money=n=>'$'+Number(n||0).toFixed(2); const money=n=>CUR+Number(n||0).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
async function signin(){ async function signin(){
TOKEN=$('#tok').value.trim(); TOKEN=($('#tok')&&$('#tok').value.trim())||TOKEN;
const r=await fetch('/sales?status=holds',{headers:hdr()}); const r=await fetch('/admin/stats',{headers:hdr()});
if(!r.ok){$('#gerr').textContent='invalid token';return;} if(!r.ok){ if($('#gerr'))$('#gerr').textContent='invalid token'; return; }
localStorage.setItem('rg_token',TOKEN); localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='grid'; $('#gate').style.display='none'; $('#app').style.display='block';
$('#q').focus(); loadHolds(); loadSettings(); loadPromos(); loadHolds();
} }
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; }
}
/* ── 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('');
}
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=''; }
}
document.addEventListener('click',e=>{ if(!e.target.closest('.res')) document.querySelectorAll('.drop').forEach(d=>d.style.display='none'); });
/* ── ring up ── */
let st; let st;
$('#q')&&($('#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),180); });
$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter'){clearTimeout(st);scan();} });
async function doSearch(q){ async function doSearch(q){
if(!q.trim()){ $('#res').innerHTML=''; return; } if(!q.trim()){ $('#res').style.display='none'; return; }
const d=await fetch('/sales/search?q='+encodeURIComponent(q),{headers:hdr()}).then(r=>r.json()); const d=await get('/sales/search?q='+encodeURIComponent(q));
$('#res').innerHTML=d.items.map((it,i)=>`<div class="ri" onclick='add(${JSON.stringify(it).replace(/'/g,"&#39;")})'> $('#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>'} ${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>'; <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
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();
}
function add(it){ function add(it){
const ex=cart.find(c=>c.sku===it.sku); 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}); 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').innerHTML=''; $('#q').focus(); render(); $('#q').value=''; $('#res').style.display='none'; $('#q').focus(); render();
} }
function render(){ function render(){
$('#empty').style.display=cart.length?'none':'block'; $('#empty').style.display=cart.length?'none':'block';
$('#cart').innerHTML=cart.map((c,i)=>`<tr> $('#cart').innerHTML=cart.map((c,i)=>`<tr>
<td>${esc(c.title)}${c.crate?`<div class="muted">📍 ${esc(c.crate)}</div>`:''}</td> <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="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.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="disc" 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(''); <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 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 cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const sub=gross-ld, after=sub-cd, total=after+after*tx/100-tr; const after=sub-promoAmt-cd, tax=after*tx/100, total=after+tax-tr;
$('#t-sub').textContent=money(sub); $('#t-sub').textContent=money(sub);
$('#t-total').textContent=money(Math.max(0,total)); $('#t-total').textContent=money(Math.max(0,total));
} }
@ -126,38 +288,113 @@ 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(); } function rm(i){ cart.splice(i,1); render(); }
function payload(){ 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;
return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})), return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})),
cart_discount:+$('#cartDisc').value||0, trade_in:+$('#trade').value||0, tax_rate:+$('#tax').value||0 }; cart_discount:promoAmt+(+$('#cartDisc').value||0), trade_in:+$('#trade').value||0,
tax_rate:+$('#tax').value||0, customer_id:customer.id };
} }
async function complete(method){ async function complete(method){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; } if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
$('#msg').textContent='processing…'; $('#msg').textContent='processing…';
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})}); const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})}).then(r=>r.json());
const d=await r.json(); if(d.ok){ $('#msg').innerHTML=`<span class="ok">✓ sale ${d.sale_number} · ${money(d.total)} ${method} · ${esc(customer.name)}</span>`;
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ sale ${d.sale_number} · ${money(d.total)} ${method}</span>`; cart=[]; render(); loadHolds(); } cart=[]; render(); loadHolds(); } else $('#msg').textContent='failed';
else $('#msg').textContent='failed';
} }
async function hold(){ async function hold(){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; } 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 dep=+$('#dep').value||0, due=$('#due').value||null;
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:'hold',hold:{deposit:dep,due_date:due}})}); 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());
const d=await r.json(); if(d.ok){ $('#planMsg').innerHTML=`<span class="ok">✓ held ${d.sale_number} · deposit ${money(d.amount_paid)} · balance ${money(d.balance)}</span>`;
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ held ${d.sale_number} · deposit ${money(d.amount_paid)} · balance ${money(d.balance)}</span>`; cart=[]; $('#dep').value=0; render(); loadHolds(); } cart=[]; $('#dep').value=0; render(); loadHolds(); }
} }
async function loadHolds(){ async function loadHolds(){
const d=await fetch('/sales?status=holds',{headers:hdr()}).then(r=>r.json()); const d=await get('/sales?status=holds');
$('#holds').innerHTML=d.sales.length? d.sales.map(s=>`<div class="hold"> $('#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> <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('') <button class="ghost" onclick="payHold(${s.id},${s.balance})">Collect</button></div>`).join('') : '<div class="muted">no open plans</div>';
: '<div class="muted">no open holds</div>';
} }
async function payHold(id,bal){ async function payHold(id,bal){
const amt=parseFloat(prompt('Amount to collect (balance '+money(bal)+'):', bal.toFixed(2))); const amt=parseFloat(prompt('Amount to collect (balance '+money(bal)+'):', bal.toFixed(2)));
if(!amt) return; 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()); 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(); $('#msg').innerHTML=d.completed?`<span style="color:var(--ok)">✓ paid off</span>`:`<span class="muted">balance ${money(d.balance)}</span>`; } if(d.ok) loadHolds();
} }
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
/* ── past sales + receipt ── */
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'; }
/* ── 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';
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>';
}
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=[];
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();
}
/* ── 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';
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();
}
if(TOKEN){ signin(); }
</script> </script>
</body> </body>
</html> </html>