feat(pos): Sales tab — ring-up, discounts, payment plans, holds, locate
This commit is contained in:
parent
e8e526f48e
commit
80ff669cac
34
app/main.py
34
app/main.py
@ -21,6 +21,7 @@ from .settings_routes import router as settings_router # noqa: E402
|
||||
from .admin_routes import router as admin_router # noqa: E402
|
||||
from .layout_routes import router as layout_router # noqa: E402
|
||||
from .shop_routes import router as shop_router # noqa: E402
|
||||
from .sales_routes import router as sales_router # noqa: E402
|
||||
from .db import engine # noqa: E402
|
||||
from sqlalchemy import text as _sqltext # noqa: E402
|
||||
|
||||
@ -31,16 +32,37 @@ app.include_router(settings_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(layout_router)
|
||||
app.include_router(shop_router)
|
||||
app.include_router(sales_router)
|
||||
|
||||
|
||||
_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())",
|
||||
# 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 discount_amount numeric(10,2) DEFAULT 0",
|
||||
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS payment_status text DEFAULT 'paid'",
|
||||
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_paid numeric(10,2) DEFAULT 0",
|
||||
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS hold_expires_at timestamptz",
|
||||
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS split_payments jsonb",
|
||||
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0",
|
||||
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
|
||||
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)",
|
||||
# auto-id for new POS rows (migrated tables came without sequences)
|
||||
"CREATE SEQUENCE IF NOT EXISTS sales_id_seq",
|
||||
"ALTER TABLE sales ALTER COLUMN id SET DEFAULT nextval('sales_id_seq')",
|
||||
"SELECT setval('sales_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM sales)))",
|
||||
"CREATE SEQUENCE IF NOT EXISTS sale_items_id_seq",
|
||||
"ALTER TABLE sale_items ALTER COLUMN id SET DEFAULT nextval('sale_items_id_seq')",
|
||||
"SELECT setval('sale_items_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM sale_items)))",
|
||||
]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _ensure_tables():
|
||||
# new tables that aren't part of the original schema.sql load — create lazily on deploy
|
||||
# lazy migrations on deploy (store_config + POS columns/sequences)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(_sqltext(
|
||||
"CREATE TABLE IF NOT EXISTS store_config "
|
||||
"(store_id int PRIMARY KEY, config jsonb NOT NULL, "
|
||||
"updated_at timestamptz NOT NULL DEFAULT now())"))
|
||||
for _stmt in _STARTUP_DDL:
|
||||
await conn.execute(_sqltext(_stmt))
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
@ -58,7 +80,7 @@ def _page(filename):
|
||||
return _serve
|
||||
|
||||
|
||||
for _stub in ("shop", "builder", "admin", "dash"):
|
||||
for _stub in ("shop", "builder", "admin", "dash", "pos"):
|
||||
app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False)
|
||||
|
||||
# Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.
|
||||
|
||||
155
app/sales_routes.py
Normal file
155
app/sales_routes.py
Normal file
@ -0,0 +1,155 @@
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from .auth import require_token
|
||||
from .db import get_db
|
||||
|
||||
# Point-of-sale — ring up sales, line/cart discounts, split tender, payment plans (holds),
|
||||
# trade-in credit. Ported from WowPlatter's sales engine onto RecordGod's own tables.
|
||||
router = APIRouter(prefix="/sales", tags=["sales"])
|
||||
|
||||
|
||||
class LineIn(BaseModel):
|
||||
sku: str
|
||||
title: str | None = None
|
||||
qty: int = 1
|
||||
unit_price: float
|
||||
discount: float = 0
|
||||
|
||||
|
||||
class SaleIn(BaseModel):
|
||||
items: list[LineIn]
|
||||
cart_discount: float = 0
|
||||
tax_rate: float = 0
|
||||
payment_method: str = "cash"
|
||||
split: list[dict] | None = None
|
||||
hold: dict | None = None
|
||||
trade_in: float = 0
|
||||
customer: str | None = None
|
||||
|
||||
|
||||
class PayIn(BaseModel):
|
||||
amount: float
|
||||
method: str = "cash"
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
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."""
|
||||
if not q.strip():
|
||||
return {"items": []}
|
||||
rows = await db.execute(text("""
|
||||
SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb,
|
||||
i.price::float AS price, i.condition, i.crate_id, c.label_text AS crate
|
||||
FROM inventory i
|
||||
LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
||||
LEFT JOIN crate c ON c.id = i.crate_id
|
||||
WHERE i.store_id = 1 AND i.in_stock
|
||||
AND (i.sku ILIKE :q OR i.identifier = :raw
|
||||
OR coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)
|
||||
ORDER BY i.updated_at DESC NULLS LAST LIMIT 25
|
||||
"""), {"q": f"%{q.strip()}%", "raw": q.strip()})
|
||||
return {"items": [dict(r) for r in rows.mappings()]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
if not body.items:
|
||||
raise HTTPException(400, "no items")
|
||||
gross = sum(li.unit_price * li.qty for li in body.items)
|
||||
line_disc = sum(li.discount for li in body.items)
|
||||
sub_after = gross - line_disc - body.cart_discount
|
||||
tax = round(sub_after * body.tax_rate / 100, 2)
|
||||
total = round(sub_after + tax - body.trade_in, 2)
|
||||
|
||||
is_hold = bool(body.hold)
|
||||
deposit = float(body.hold.get("deposit", 0)) if is_hold else 0
|
||||
amount_paid = deposit if is_hold else total
|
||||
status, pay_status = ("held", "hold") if is_hold else ("completed", "paid")
|
||||
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_id = (await db.execute(text("""
|
||||
INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total,
|
||||
status, payment_method, payment_status, amount_paid, trade_in_credit,
|
||||
hold_expires_at, split_payments, sale_date, created_at)
|
||||
VALUES (:sn, NULL, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade,
|
||||
:due, CAST(:split AS jsonb), now(), now())
|
||||
RETURNING id
|
||||
"""), {"sn": sale_number, "sub": gross - line_disc, "disc": line_disc + body.cart_discount,
|
||||
"tax": tax, "total": total, "st": status, "pm": body.payment_method, "ps": pay_status,
|
||||
"paid": amount_paid, "trade": body.trade_in, "due": hold_due,
|
||||
"split": json.dumps(body.split) if body.split else None})).first()[0]
|
||||
|
||||
for li in body.items:
|
||||
await db.execute(text("""
|
||||
INSERT INTO sale_items (sale_id, sku, item_name, qty, unit_price, line_total,
|
||||
discount_amount, original_price)
|
||||
VALUES (:sid, :sku, :name, :qty, :up, :lt, :disc, :op)
|
||||
"""), {"sid": sale_id, "sku": li.sku, "name": li.title or li.sku, "qty": li.qty,
|
||||
"up": li.unit_price, "lt": li.unit_price * li.qty - li.discount,
|
||||
"disc": li.discount, "op": li.unit_price})
|
||||
|
||||
# take the items off the floor (sold, or reserved on a hold) so they can't be double-sold
|
||||
await db.execute(text("""
|
||||
UPDATE inventory SET in_stock = false, status = :ist,
|
||||
sold_date = CASE WHEN :ist = 'sold' THEN now() ELSE NULL END
|
||||
WHERE sku = ANY(:skus) AND store_id = 1
|
||||
"""), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]})
|
||||
await db.commit()
|
||||
return {"ok": True, "sale_id": sale_id, "sale_number": sale_number, "total": total,
|
||||
"amount_paid": amount_paid, "balance": round(total - amount_paid, 2), "status": status}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_sales(status: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
|
||||
where, params = "", {}
|
||||
if status == "holds":
|
||||
where = "WHERE payment_status = 'hold'"
|
||||
elif status:
|
||||
where, params = "WHERE status = :st", {"st": status}
|
||||
rows = await db.execute(text(f"""
|
||||
SELECT id, sale_number, total::float AS total, coalesce(amount_paid,0)::float AS amount_paid,
|
||||
(total - coalesce(amount_paid,0))::float AS balance, status, payment_status,
|
||||
payment_method, hold_expires_at, sale_date
|
||||
FROM sales {where} ORDER BY sale_date DESC NULLS LAST LIMIT 60
|
||||
"""), params)
|
||||
return {"sales": [dict(r) for r in rows.mappings()]}
|
||||
|
||||
|
||||
@router.get("/{sale_id}")
|
||||
async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
|
||||
s = (await db.execute(text("SELECT * FROM sales WHERE id=:i"), {"i": sale_id})).mappings().first()
|
||||
if not s:
|
||||
raise HTTPException(404, "not found")
|
||||
items = [dict(r) for r in (await db.execute(text(
|
||||
"SELECT sku, item_name, qty, unit_price::float AS unit_price, line_total::float AS line_total, "
|
||||
"discount_amount::float AS discount FROM sale_items WHERE sale_id=:i"), {"i": sale_id})).mappings()]
|
||||
return {"sale": dict(s), "items": items}
|
||||
|
||||
|
||||
@router.post("/{sale_id}/pay")
|
||||
async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""Payment toward a held / payment-plan sale. Fully paid → completes it + sells the items."""
|
||||
s = (await db.execute(text("SELECT total::float AS total, coalesce(amount_paid,0)::float AS paid FROM sales WHERE id=:i"),
|
||||
{"i": sale_id})).mappings().first()
|
||||
if not s:
|
||||
raise HTTPException(404, "not found")
|
||||
new_paid = round(s["paid"] + body.amount, 2)
|
||||
done = new_paid >= s["total"] - 0.005
|
||||
await db.execute(text(
|
||||
"UPDATE sales SET amount_paid=:p, payment_status=:ps, status=:st WHERE id=:i"),
|
||||
{"p": new_paid, "ps": "paid" if done else "hold",
|
||||
"st": "completed" if done else "held", "i": sale_id})
|
||||
if done:
|
||||
await db.execute(text("""
|
||||
UPDATE inventory SET status='sold', sold_date=now()
|
||||
WHERE sku IN (SELECT sku FROM sale_items WHERE sale_id=:i) AND store_id = 1
|
||||
"""), {"i": sale_id})
|
||||
await db.commit()
|
||||
return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done}
|
||||
@ -3,6 +3,7 @@
|
||||
(function () {
|
||||
const LINKS = [
|
||||
{ href: '/admin', label: 'Admin' },
|
||||
{ href: '/pos', label: 'Sales' },
|
||||
{ href: '/builder', label: 'Builder' },
|
||||
{ href: '/shop', label: 'Shop' },
|
||||
{ href: '/store/', label: '3D store' },
|
||||
|
||||
163
site/pos.html
Normal file
163
site/pos.html
Normal file
@ -0,0 +1,163 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — sales</title>
|
||||
<script defer src="/nav.js"></script>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0b0b0d;--pn:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
|
||||
*{box-sizing:border-box}
|
||||
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}
|
||||
.btn{background:var(--pink);color:#1a0a14;padding:10px 14px}
|
||||
.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}
|
||||
#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)}
|
||||
#app{display:none;grid-template-columns:1.3fr 1fr;gap:18px;padding:18px;max-width:1200px;margin:0 auto}
|
||||
.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}
|
||||
.row{display:flex;gap:8px;align-items:center}
|
||||
.res{max-height:240px;overflow:auto;margin-top:8px}
|
||||
.ri{display:flex;gap:10px;align-items:center;padding:7px;border-radius:8px;cursor:pointer}
|
||||
.ri:hover{background:#17171c}.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 .t .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
td,th{padding:6px 5px;border-bottom:1px solid #1b1b20;text-align:left}
|
||||
th{color:var(--mut);font-weight:500}
|
||||
.qty{width:46px}.disc{width:62px}
|
||||
.tot{display:flex;justify-content:space-between;padding:4px 0}
|
||||
.tot.big{font-size:22px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
|
||||
.tot.big b{color:var(--pink)}
|
||||
.pillbtns{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
|
||||
.pillbtns button{flex:1}
|
||||
.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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · sales</h1>
|
||||
<div class="row"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="btn" onclick="signin()">Enter</button></div>
|
||||
<div id="gerr" class="muted" style="margin-top:8px"></div></div></div>
|
||||
|
||||
<div id="app">
|
||||
<div>
|
||||
<div class="panel"><h2>🔍 Ring up <span class="muted">— search title / artist / SKU / scan</span></h2>
|
||||
<div class="row"><input id="q" placeholder="search or scan to add…" style="flex:1" autocomplete="off"></div>
|
||||
<div id="res" class="res"></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 — search above to add items</div></div>
|
||||
</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 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>💳 Payment plan / hold</h2>
|
||||
<div class="row"><span class="muted">Deposit</span><input id="dep" class="disc" type="number" step="0.01" value="0">
|
||||
<span class="muted">Due</span><input id="due" type="date"><button class="ghost" onclick="hold()">Put on hold</button></div>
|
||||
<div class="muted" style="margin-top:6px">Reserves the items, takes the deposit, balance due by the date.</div></div>
|
||||
|
||||
<div class="panel"><h2>📋 Open holds <span class="muted">— collect balance</span></h2>
|
||||
<div id="holds" class="muted">loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
let TOKEN=localStorage.getItem('rg_token')||'', cart=[];
|
||||
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
|
||||
const money=n=>'$'+Number(n||0).toFixed(2);
|
||||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
|
||||
async function signin(){
|
||||
TOKEN=$('#tok').value.trim();
|
||||
const r=await fetch('/sales?status=holds',{headers:hdr()});
|
||||
if(!r.ok){$('#gerr').textContent='invalid token';return;}
|
||||
localStorage.setItem('rg_token',TOKEN);
|
||||
$('#gate').style.display='none'; $('#app').style.display='grid';
|
||||
$('#q').focus(); loadHolds();
|
||||
}
|
||||
|
||||
let st;
|
||||
$('#q')&&($('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),180); }));
|
||||
async function doSearch(q){
|
||||
if(!q.trim()){ $('#res').innerHTML=''; return; }
|
||||
const d=await fetch('/sales/search?q='+encodeURIComponent(q),{headers:hdr()}).then(r=>r.json());
|
||||
$('#res').innerHTML=d.items.map((it,i)=>`<div class="ri" onclick='add(${JSON.stringify(it).replace(/'/g,"'")})'>
|
||||
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
|
||||
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div><div class="muted">${esc(it.artist||'')} · ${it.condition||''} ${it.crate?'· 📍 '+esc(it.crate):''}</div></div>
|
||||
<div class="pink">${money(it.price)}</div></div>`).join('')||'<div class="muted" style="padding:8px">no in-stock match</div>';
|
||||
}
|
||||
function add(it){
|
||||
const ex=cart.find(c=>c.sku===it.sku);
|
||||
if(ex) ex.qty++; else cart.push({sku:it.sku,title:it.title||it.sku,qty:1,price:it.price||0,discount:0,crate:it.crate});
|
||||
$('#q').value=''; $('#res').innerHTML=''; $('#q').focus(); render();
|
||||
}
|
||||
function render(){
|
||||
$('#empty').style.display=cart.length?'none':'block';
|
||||
$('#cart').innerHTML=cart.map((c,i)=>`<tr>
|
||||
<td>${esc(c.title)}${c.crate?`<div class="muted">📍 ${esc(c.crate)}</div>`:''}</td>
|
||||
<td><input class="qty" type="number" min="1" value="${c.qty}" oninput="upd(${i},'qty',this.value)"></td>
|
||||
<td><input class="disc" type="number" step="0.01" value="${c.price}" oninput="upd(${i},'price',this.value)"></td>
|
||||
<td><input class="disc" type="number" step="0.01" value="${c.discount}" oninput="upd(${i},'discount',this.value)"></td>
|
||||
<td>${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 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;
|
||||
$('#t-sub').textContent=money(sub);
|
||||
$('#t-total').textContent=money(Math.max(0,total));
|
||||
}
|
||||
function upd(i,k,v){ cart[i][k]= k==='qty'?Math.max(1,+v|0):(+v||0); render(); }
|
||||
function rm(i){ cart.splice(i,1); render(); }
|
||||
|
||||
function payload(){
|
||||
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 };
|
||||
}
|
||||
async function complete(method){
|
||||
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
|
||||
$('#msg').textContent='processing…';
|
||||
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})});
|
||||
const d=await r.json();
|
||||
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ sale ${d.sale_number} · ${money(d.total)} ${method}</span>`; cart=[]; render(); loadHolds(); }
|
||||
else $('#msg').textContent='failed';
|
||||
}
|
||||
async function hold(){
|
||||
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
|
||||
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 r.json();
|
||||
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(); }
|
||||
}
|
||||
async function loadHolds(){
|
||||
const d=await fetch('/sales?status=holds',{headers:hdr()}).then(r=>r.json());
|
||||
$('#holds').innerHTML=d.sales.length? d.sales.map(s=>`<div class="hold">
|
||||
<span style="flex:1">${s.sale_number} · bal <b class="pink">${money(s.balance)}</b>${s.hold_expires_at?` · due ${String(s.hold_expires_at).slice(0,10)}`:''}</span>
|
||||
<button class="ghost" onclick="payHold(${s.id},${s.balance})">Collect</button></div>`).join('')
|
||||
: '<div class="muted">no open holds</div>';
|
||||
}
|
||||
async function payHold(id,bal){
|
||||
const amt=parseFloat(prompt('Amount to collect (balance '+money(bal)+'):', bal.toFixed(2)));
|
||||
if(!amt) return;
|
||||
const d=await fetch('/sales/'+id+'/pay',{method:'POST',headers:hdr(),body:JSON.stringify({amount:amt,method:'cash'})}).then(r=>r.json());
|
||||
if(d.ok){ loadHolds(); $('#msg').innerHTML=d.completed?`<span style="color:var(--ok)">✓ paid off</span>`:`<span class="muted">balance ${money(d.balance)}</span>`; }
|
||||
}
|
||||
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user