feat(pos): full Sales console + migrate customers/discounts/settings
This commit is contained in:
parent
584fd140ad
commit
eb502efaf4
@ -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",
|
||||||
|
|||||||
@ -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()]}
|
||||||
|
|||||||
35
migrate.py
35
migrate.py
@ -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}")
|
||||||
|
|||||||
39
schema.sql
39
schema.sql
@ -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.
|
||||||
|
|||||||
381
site/pos.html
381
site/pos.html
@ -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 & 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">
|
||||||
|
<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 & 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 ──────────────────────────────────────── -->
|
||||||
|
<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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[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,"'")})'>
|
||||||
|
<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,"'")})'>
|
$('#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>'}
|
${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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user