visual overhaul + accounting corrections
This commit is contained in:
1 parent
77198e5b47
commit
70e62342ee
17 files changed
+1268
-389
No files matched your search
@@ -2,3 +2,4 @@
|
|||||||
/__pycache__/*
|
/__pycache__/*
|
||||||
/.venv/*
|
/.venv/*
|
||||||
/data/*
|
/data/*
|
||||||
|
test/*
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time.
|
A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time.
|
||||||
|
|
||||||
The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, cash-outs, paid amounts, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later.
|
The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ The idea is pretty simple: public pages for stats and session history, plus a sm
|
|||||||
- per-player stat pages
|
- per-player stat pages
|
||||||
- admin login for recording events
|
- admin login for recording events
|
||||||
- open / closed session tracking
|
- open / closed session tracking
|
||||||
- payout tracking with `paid` events
|
- actual cash payout tracking with `paid_out` events
|
||||||
- front debt collections and write-offs with `front_collected` / `front_writeoff` events
|
- debt repayments and write-offs with `debt_repayment` / `writeoff` events
|
||||||
- session and player charts
|
- session and player charts
|
||||||
- CSV import / export from the admin page
|
- CSV import / export from the admin page
|
||||||
- append-only `entries.csv` ledger instead of overwriting old rows
|
- append-only `entries.csv` ledger instead of overwriting old rows
|
||||||
@@ -38,11 +38,12 @@ Current event types:
|
|||||||
|
|
||||||
- `buyin`
|
- `buyin`
|
||||||
- `front`
|
- `front`
|
||||||
- `front_collected`
|
- `debt_repayment`
|
||||||
- `front_writeoff`
|
- `writeoff`
|
||||||
- `cashout`
|
- `cashout`
|
||||||
- `paid`
|
- `paid_out`
|
||||||
- `rollover_in`
|
- `rollover_in`
|
||||||
|
- `payout_carry_in`
|
||||||
- `rollover_out`
|
- `rollover_out`
|
||||||
- `note`
|
- `note`
|
||||||
- `session_open`
|
- `session_open`
|
||||||
@@ -52,9 +53,9 @@ A few examples:
|
|||||||
|
|
||||||
- another `buyin` for a rebuy
|
- another `buyin` for a rebuy
|
||||||
- another `cashout` if chip counts are corrected later
|
- another `cashout` if chip counts are corrected later
|
||||||
- a `paid` event when someone is actually settled up
|
- a `paid_out` event when someone is actually settled up
|
||||||
- a `front_collected` event when a front is repaid outside poker
|
- a `debt_repayment` event when a front is repaid outside poker
|
||||||
- a `front_writeoff` event when a front will not be collected
|
- a `writeoff` event when a front will not be collected
|
||||||
- a `note` event for bookkeeping context
|
- a `note` event for bookkeeping context
|
||||||
- `session_open` / `session_close` to mark whether a game night is still live
|
- `session_open` / `session_close` to mark whether a game night is still live
|
||||||
|
|
||||||
@@ -62,11 +63,16 @@ Accounting in the app keeps poker results separate from banker cashflow:
|
|||||||
|
|
||||||
- poker investment is `buyin + front + rollover_in`
|
- poker investment is `buyin + front + rollover_in`
|
||||||
- poker net is `cashout - poker investment`
|
- poker net is `cashout - poker investment`
|
||||||
- real cash in is `buyin + front_collected`
|
- real cash in is `buyin + debt_repayment`
|
||||||
- real cash out is `paid`
|
- real cash out is `paid_out`
|
||||||
- `rollover_out` settles the source session without counting as cash out
|
- `rollover_out` settles the source session without counting as cash out
|
||||||
- `rollover_in` enters play in the destination session without counting as cash in
|
- `rollover_in` enters play in the destination session without counting as cash in
|
||||||
- `front_writeoff` resolves a receivable without counting as cash in
|
- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in
|
||||||
|
- `writeoff` resolves a receivable without counting as cash in
|
||||||
|
|
||||||
|
Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`.
|
||||||
|
The app reads those historical names as aliases for `paid_out`,
|
||||||
|
`debt_repayment`, and `writeoff`.
|
||||||
|
|
||||||
It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through.
|
It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through.
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ from stats import (
|
|||||||
session_sort_key,
|
session_sort_key,
|
||||||
unique_player_names,
|
unique_player_names,
|
||||||
)
|
)
|
||||||
from storage import CSV_HEADERS, append_event, ensure_data_file, load_events
|
from storage import (
|
||||||
|
CSV_HEADERS,
|
||||||
|
EventRow,
|
||||||
|
append_event,
|
||||||
|
ensure_data_file,
|
||||||
|
load_events,
|
||||||
|
write_events,
|
||||||
|
)
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
||||||
@@ -40,13 +47,15 @@ ENV_PATH = BASE_DIR / ".env"
|
|||||||
ELIGIBLE_MIN_SESSIONS = 3
|
ELIGIBLE_MIN_SESSIONS = 3
|
||||||
ADMIN_EVENT_TYPES = [
|
ADMIN_EVENT_TYPES = [
|
||||||
("buyin", "Buy-in", "var(--accent)"),
|
("buyin", "Buy-in", "var(--accent)"),
|
||||||
("front", "Fronted", "var(--warn)"),
|
("front", "Front", "var(--warn)"),
|
||||||
("rollover_in", "Rollover-in", "var(--rolled)"),
|
("rollover_in", "Rollover-in", "var(--rolled)"),
|
||||||
("cashout", "Cash-out", "var(--rank-2)"),
|
("payout_carry_in", "Payout carry-in", "var(--rolled)"),
|
||||||
("paid", "Paid-out", "var(--pos)"),
|
("cashout", "Cashout result", "var(--rank-2)"),
|
||||||
|
("paid_out", "Paid out cash", "var(--pos)"),
|
||||||
("rollover_out", "Rollover-out", "var(--rolled)"),
|
("rollover_out", "Rollover-out", "var(--rolled)"),
|
||||||
("note", "Note", "var(--muted)"),
|
("note", "Note", "var(--muted)"),
|
||||||
]
|
]
|
||||||
|
SESSION_MARKER_TYPES = {"session_open", "session_close"}
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(env_path: Path) -> None:
|
def load_local_env(env_path: Path) -> None:
|
||||||
@@ -80,6 +89,54 @@ def next_session_id(sessions, session_date: str) -> str:
|
|||||||
return f"{session_date}-{highest + 1:02d}"
|
return f"{session_date}-{highest + 1:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def prunable_empty_session_ids(events: list[EventRow], sessions) -> set[str]:
|
||||||
|
empty_session_ids = {
|
||||||
|
session.session_id for session in sessions if len(session.entries) == 0
|
||||||
|
}
|
||||||
|
session_events: dict[str, list[EventRow]] = {}
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
session_id = event["session_id"].strip() or event["session_date"].strip()
|
||||||
|
if session_id in empty_session_ids:
|
||||||
|
session_events.setdefault(session_id, []).append(event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_id
|
||||||
|
for session_id, rows in session_events.items()
|
||||||
|
if rows
|
||||||
|
and all(
|
||||||
|
row["event_type"] in SESSION_MARKER_TYPES
|
||||||
|
and not row["player_name"].strip()
|
||||||
|
and row["amount_cents"] == 0
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pending_payout_carry_items(sessions) -> list[dict[str, object]]:
|
||||||
|
carried_out: dict[str, int] = {}
|
||||||
|
carried_in: dict[str, int] = {}
|
||||||
|
|
||||||
|
for session_summary in sessions:
|
||||||
|
for entry in session_summary.entries:
|
||||||
|
carried_out[entry.player_name] = (
|
||||||
|
carried_out.get(entry.player_name, 0) + entry.rollover_out_cents
|
||||||
|
)
|
||||||
|
carried_in[entry.player_name] = (
|
||||||
|
carried_in.get(entry.player_name, 0)
|
||||||
|
+ entry.rollover_in_cents
|
||||||
|
+ entry.payout_carry_in_cents
|
||||||
|
)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for player_name, amount_out in carried_out.items():
|
||||||
|
pending_cents = amount_out - carried_in.get(player_name, 0)
|
||||||
|
if pending_cents > 0:
|
||||||
|
items.append({"player_name": player_name, "amount_cents": pending_cents})
|
||||||
|
|
||||||
|
return sorted(items, key=lambda item: str(item["player_name"]).casefold())
|
||||||
|
|
||||||
|
|
||||||
load_local_env(ENV_PATH)
|
load_local_env(ENV_PATH)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -219,33 +276,24 @@ def session_detail(session_id: str) -> str:
|
|||||||
flash("That session was not found.", "error")
|
flash("That session was not found.", "error")
|
||||||
return redirect(url_for("sessions"))
|
return redirect(url_for("sessions"))
|
||||||
|
|
||||||
target_index = next(
|
|
||||||
(
|
|
||||||
index
|
|
||||||
for index, session in enumerate(sessions)
|
|
||||||
if session.session_id == session_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if target_index is None:
|
|
||||||
flash("That session was not found.", "error")
|
|
||||||
return redirect(url_for("sessions"))
|
|
||||||
|
|
||||||
target_session = sessions[target_index]
|
|
||||||
chronological_sessions = sorted(sessions, key=session_sort_key)
|
chronological_sessions = sorted(sessions, key=session_sort_key)
|
||||||
session_number = next(
|
chronological_index = next(
|
||||||
(
|
index
|
||||||
index
|
for index, session in enumerate(chronological_sessions)
|
||||||
for index, session in enumerate(chronological_sessions, start=1)
|
if session.session_id == session_id
|
||||||
if session.session_id == session_id
|
|
||||||
),
|
|
||||||
1,
|
|
||||||
)
|
)
|
||||||
|
target_session = chronological_sessions[chronological_index]
|
||||||
|
session_number = chronological_index + 1
|
||||||
prev_session = (
|
prev_session = (
|
||||||
sessions[target_index + 1] if target_index < len(sessions) - 1 else None
|
chronological_sessions[chronological_index - 1]
|
||||||
|
if chronological_index > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
next_session = (
|
||||||
|
chronological_sessions[chronological_index + 1]
|
||||||
|
if chronological_index < len(chronological_sessions) - 1
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
next_session = sessions[target_index - 1] if target_index > 0 else None
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"session_detail.html",
|
"session_detail.html",
|
||||||
@@ -478,7 +526,7 @@ def admin_write_off_front() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if entry is None or entry.player_owes_cents <= 0:
|
if entry is None or entry.player_owes_cents <= 0:
|
||||||
flash("That player does not have an outstanding front debt.", "error")
|
flash("That player does not have an outstanding debt.", "error")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
if amount_cents > entry.player_owes_cents:
|
if amount_cents > entry.player_owes_cents:
|
||||||
@@ -493,13 +541,13 @@ def admin_write_off_front() -> str:
|
|||||||
session_id=target.session_id,
|
session_id=target.session_id,
|
||||||
session_date=target.session_date,
|
session_date=target.session_date,
|
||||||
player_name=entry.player_name,
|
player_name=entry.player_name,
|
||||||
event_type="front_writeoff",
|
event_type="writeoff",
|
||||||
amount_cents=amount_cents,
|
amount_cents=amount_cents,
|
||||||
note=note or "Front debt written off.",
|
note=note or "Debt written off.",
|
||||||
actor=app.config["ADMIN_USERNAME"],
|
actor=app.config["ADMIN_USERNAME"],
|
||||||
)
|
)
|
||||||
|
|
||||||
flash("Front debt written off.", "success")
|
flash("Debt written off.", "success")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@@ -546,7 +594,7 @@ def admin_collect_front() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if entry is None or entry.player_owes_cents <= 0:
|
if entry is None or entry.player_owes_cents <= 0:
|
||||||
flash("That player does not have an outstanding front debt.", "error")
|
flash("That player does not have an outstanding debt.", "error")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
if amount_cents > entry.player_owes_cents:
|
if amount_cents > entry.player_owes_cents:
|
||||||
@@ -561,13 +609,13 @@ def admin_collect_front() -> str:
|
|||||||
session_id=target.session_id,
|
session_id=target.session_id,
|
||||||
session_date=target.session_date,
|
session_date=target.session_date,
|
||||||
player_name=entry.player_name,
|
player_name=entry.player_name,
|
||||||
event_type="front_collected",
|
event_type="debt_repayment",
|
||||||
amount_cents=amount_cents,
|
amount_cents=amount_cents,
|
||||||
note=note or "Front debt collected.",
|
note=note or "Debt repayment collected.",
|
||||||
actor=app.config["ADMIN_USERNAME"],
|
actor=app.config["ADMIN_USERNAME"],
|
||||||
)
|
)
|
||||||
|
|
||||||
flash("Front debt collected.", "success")
|
flash("Debt repayment collected.", "success")
|
||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@@ -601,6 +649,86 @@ def admin_open_session() -> str:
|
|||||||
return redirect(url_for("admin_dashboard"))
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/prune-empty-sessions")
|
||||||
|
def admin_prune_empty_sessions() -> str:
|
||||||
|
if not is_admin():
|
||||||
|
flash("Admin login required.", "error")
|
||||||
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
|
events = load_events(DATA_PATH)
|
||||||
|
sessions = build_session_summaries(events)
|
||||||
|
prunable_session_ids = prunable_empty_session_ids(events, sessions)
|
||||||
|
|
||||||
|
if not prunable_session_ids:
|
||||||
|
flash("No empty marker-only sessions to prune.", "success")
|
||||||
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
pruned_events = [
|
||||||
|
event
|
||||||
|
for event in events
|
||||||
|
if (event["session_id"].strip() or event["session_date"].strip())
|
||||||
|
not in prunable_session_ids
|
||||||
|
]
|
||||||
|
write_events(DATA_PATH, pruned_events)
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f"Pruned {len(prunable_session_ids)} empty session"
|
||||||
|
f"{'s' if len(prunable_session_ids) != 1 else ''}.",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/apply-payout-carry-in")
|
||||||
|
def admin_apply_payout_carry_in() -> str:
|
||||||
|
if not is_admin():
|
||||||
|
flash("Admin login required.", "error")
|
||||||
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
|
session_id = request.form.get("session_id", "").strip()
|
||||||
|
player_name = request.form.get("player_name", "").strip()
|
||||||
|
|
||||||
|
events = load_events(DATA_PATH)
|
||||||
|
sessions = build_session_summaries(events)
|
||||||
|
by_id = {session.session_id: session for session in sessions}
|
||||||
|
target = by_id.get(session_id)
|
||||||
|
|
||||||
|
if target is None or target.status != "open":
|
||||||
|
flash("Select a valid open session.", "error")
|
||||||
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
if not any(entry.player_name == player_name for entry in target.entries):
|
||||||
|
flash("Add that player to the open session before applying carry-in.", "error")
|
||||||
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
pending_by_player = {
|
||||||
|
str(item["player_name"]): int(item["amount_cents"])
|
||||||
|
for item in pending_payout_carry_items(sessions)
|
||||||
|
}
|
||||||
|
amount_cents = pending_by_player.get(player_name, 0)
|
||||||
|
|
||||||
|
if amount_cents <= 0:
|
||||||
|
flash("No pending payout carry-in found for that player.", "error")
|
||||||
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
append_event(
|
||||||
|
DATA_PATH,
|
||||||
|
session_id=target.session_id,
|
||||||
|
session_date=target.session_date,
|
||||||
|
player_name=player_name,
|
||||||
|
event_type="payout_carry_in",
|
||||||
|
amount_cents=amount_cents,
|
||||||
|
note="Payout carry-in from prior carry-out.",
|
||||||
|
actor=app.config["ADMIN_USERNAME"],
|
||||||
|
)
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("admin_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin", methods=["GET", "POST"])
|
@app.route("/admin", methods=["GET", "POST"])
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
if not is_admin():
|
if not is_admin():
|
||||||
@@ -658,8 +786,18 @@ def admin_dashboard():
|
|||||||
|
|
||||||
events = load_events(DATA_PATH)
|
events = load_events(DATA_PATH)
|
||||||
sessions = build_session_summaries(events)
|
sessions = build_session_summaries(events)
|
||||||
|
empty_session_count = len(prunable_empty_session_ids(events, sessions))
|
||||||
|
pending_carry_items = pending_payout_carry_items(sessions)
|
||||||
|
pending_carry_total_cents = sum(
|
||||||
|
int(item["amount_cents"]) for item in pending_carry_items
|
||||||
|
)
|
||||||
open_sessions = [session for session in sessions if session.status == "open"]
|
open_sessions = [session for session in sessions if session.status == "open"]
|
||||||
live_session = open_sessions[0] if open_sessions else None
|
live_session = open_sessions[0] if open_sessions else None
|
||||||
|
live_session_player_names = (
|
||||||
|
{entry.player_name for entry in live_session.entries}
|
||||||
|
if live_session is not None
|
||||||
|
else set()
|
||||||
|
)
|
||||||
live_session_no = ""
|
live_session_no = ""
|
||||||
live_unresolved_count = 0
|
live_unresolved_count = 0
|
||||||
if live_session is not None:
|
if live_session is not None:
|
||||||
@@ -670,7 +808,7 @@ def admin_dashboard():
|
|||||||
if entry.current_due_cents > 0 or entry.player_owes_cents > 0
|
if entry.current_due_cents > 0 or entry.player_owes_cents > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
recent_sessions = sessions[:8]
|
recent_sessions = sessions
|
||||||
recent_events = list(reversed(events[-20:]))
|
recent_events = list(reversed(events[-20:]))
|
||||||
event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES}
|
event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES}
|
||||||
event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES}
|
event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES}
|
||||||
@@ -689,29 +827,34 @@ def admin_dashboard():
|
|||||||
if entry.player_owes_cents > 0
|
if entry.player_owes_cents > 0
|
||||||
]
|
]
|
||||||
|
|
||||||
|
cash_in_cents = sum(session.total_banker_cash_in_cents for session in sessions)
|
||||||
|
cash_out_cents = sum(session.total_banker_cash_out_cents for session in sessions)
|
||||||
|
house_owes_players_cents = (
|
||||||
|
sum(session.total_current_due_to_player_cents for session in sessions)
|
||||||
|
+ pending_carry_total_cents
|
||||||
|
)
|
||||||
|
players_owe_house_cents = sum(
|
||||||
|
session.total_current_due_to_house_cents for session in sessions
|
||||||
|
)
|
||||||
admin_totals = {
|
admin_totals = {
|
||||||
"cash_in_cents": sum(session.total_cash_in_cents for session in sessions),
|
"cash_in_cents": cash_in_cents,
|
||||||
"cash_out_cents": sum(session.total_paid_out_cents for session in sessions),
|
"cash_out_cents": cash_out_cents,
|
||||||
"house_owes_players_cents": sum(
|
"house_owes_players_cents": house_owes_players_cents,
|
||||||
session.total_current_due_to_player_cents for session in sessions
|
"players_owe_house_cents": players_owe_house_cents,
|
||||||
),
|
"pending_carry_cents": pending_carry_total_cents,
|
||||||
"players_owe_house_cents": sum(
|
|
||||||
session.total_current_due_to_house_cents for session in sessions
|
|
||||||
),
|
|
||||||
"collected_cents": sum(
|
"collected_cents": sum(
|
||||||
session.total_front_collected_cents for session in sessions
|
session.total_debt_repayment_cents for session in sessions
|
||||||
),
|
),
|
||||||
"written_off_cents": sum(
|
"written_off_cents": sum(
|
||||||
session.total_front_writeoff_cents for session in sessions
|
session.total_writeoff_cents for session in sessions
|
||||||
),
|
),
|
||||||
"net_book_position_cents": sum(
|
"net_book_position_cents": (
|
||||||
session.total_net_book_position_cents for session in sessions
|
cash_in_cents
|
||||||
|
- cash_out_cents
|
||||||
|
+ players_owe_house_cents
|
||||||
|
- house_owes_players_cents
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
admin_totals["paid_out_cents"] = admin_totals["cash_out_cents"]
|
|
||||||
admin_totals["still_owed_cents"] = admin_totals["house_owes_players_cents"]
|
|
||||||
admin_totals["players_owe_cents"] = admin_totals["players_owe_house_cents"]
|
|
||||||
admin_totals["open_balance_cents"] = admin_totals["net_book_position_cents"]
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"admin_dashboard.html",
|
"admin_dashboard.html",
|
||||||
@@ -720,8 +863,10 @@ def admin_dashboard():
|
|||||||
recent_events=recent_events,
|
recent_events=recent_events,
|
||||||
debt_entries=debt_entries,
|
debt_entries=debt_entries,
|
||||||
live_session=live_session,
|
live_session=live_session,
|
||||||
|
live_session_player_names=live_session_player_names,
|
||||||
live_session_no=live_session_no,
|
live_session_no=live_session_no,
|
||||||
live_unresolved_count=live_unresolved_count,
|
live_unresolved_count=live_unresolved_count,
|
||||||
|
pending_carry_items=pending_carry_items,
|
||||||
player_names=unique_player_names(events),
|
player_names=unique_player_names(events),
|
||||||
append_form=append_form,
|
append_form=append_form,
|
||||||
event_types=ADMIN_EVENT_TYPES,
|
event_types=ADMIN_EVENT_TYPES,
|
||||||
@@ -730,6 +875,7 @@ def admin_dashboard():
|
|||||||
admin_today=datetime.now().date().isoformat(),
|
admin_today=datetime.now().date().isoformat(),
|
||||||
session_label=session_label,
|
session_label=session_label,
|
||||||
admin_totals=admin_totals,
|
admin_totals=admin_totals,
|
||||||
|
empty_session_count=empty_session_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+341
-39
@@ -109,6 +109,13 @@ h1, h2, h3, h4 { margin: 0; }
|
|||||||
.page--public { max-width: var(--w-public); }
|
.page--public { max-width: var(--w-public); }
|
||||||
.page--session { max-width: var(--w-session); }
|
.page--session { max-width: var(--w-session); }
|
||||||
.page--admin { max-width: var(--w-admin); }
|
.page--admin { max-width: var(--w-admin); }
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.page--admin,
|
||||||
|
.page--session {
|
||||||
|
padding-left: 28px;
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
FLASH MESSAGES
|
FLASH MESSAGES
|
||||||
@@ -198,7 +205,8 @@ h3 {
|
|||||||
}
|
}
|
||||||
.session-number {
|
.session-number {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-bottom: 8px;
|
font-size: 12.5px;
|
||||||
|
margin-bottom: 9px;
|
||||||
}
|
}
|
||||||
.hero__title {
|
.hero__title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -284,10 +292,10 @@ h3 {
|
|||||||
}
|
}
|
||||||
.stat--hero .stat__val { font-size: 34px; }
|
.stat--hero .stat__val { font-size: 34px; }
|
||||||
.player-rank {
|
.player-rank {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 7px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12.5px;
|
||||||
letter-spacing: .12em;
|
letter-spacing: .12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -521,10 +529,15 @@ h3 {
|
|||||||
.tbl .col-acct { display: none; }
|
.tbl .col-acct { display: none; }
|
||||||
.tbl.is-full .col-acct { display: table-cell; }
|
.tbl.is-full .col-acct { display: table-cell; }
|
||||||
.tbl--session {
|
.tbl--session {
|
||||||
min-width: 920px;
|
min-width: 860px;
|
||||||
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
.tbl--session.is-full {
|
.tbl--session.is-full {
|
||||||
min-width: 1240px;
|
min-width: 1180px;
|
||||||
|
}
|
||||||
|
.tbl--session th,
|
||||||
|
.tbl--session td {
|
||||||
|
padding-inline: 12px;
|
||||||
}
|
}
|
||||||
.tbl--session th:nth-child(7),
|
.tbl--session th:nth-child(7),
|
||||||
.tbl--session td:nth-child(7) {
|
.tbl--session td:nth-child(7) {
|
||||||
@@ -616,6 +629,7 @@ td a:hover { color: var(--accent); }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
.badge--paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
||||||
|
.badge--settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
.badge--partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
|
.badge--partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
|
||||||
.badge--unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
.badge--unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
||||||
.badge--owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); }
|
.badge--owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); }
|
||||||
@@ -641,6 +655,7 @@ td a:hover { color: var(--accent); }
|
|||||||
.status-open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
|
.status-open { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
|
||||||
.status-closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
.status-closed { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
.status-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
.status-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
||||||
|
.status-settled { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
.status-partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
|
.status-partial { color: var(--warn); background: var(--warn-tint); box-shadow: inset 0 0 0 1px var(--warn-tint-bd); }
|
||||||
.status-unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
.status-unpaid { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
||||||
.status-owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); }
|
.status-owes { color: var(--owes); background: var(--owes-tint); box-shadow: inset 0 0 0 1px var(--owes-tint-bd); }
|
||||||
@@ -665,7 +680,11 @@ td a:hover { color: var(--accent); }
|
|||||||
.pill-cashout { color: var(--rank-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
.pill-cashout { color: var(--rank-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
.pill-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
.pill-paid { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
||||||
.pill-rollover_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
.pill-rollover_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
|
.pill-payout_carry_in { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
.pill-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
.pill-rollover_out { color: var(--rolled); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
|
.pill-paid_out { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
||||||
|
.pill-debt_repayment { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
||||||
|
.pill-writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
||||||
.pill-front_collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
.pill-front_collected { color: var(--pos); background: var(--pos-tint); box-shadow: inset 0 0 0 1px var(--pos-tint-bd); }
|
||||||
.pill-front_writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
.pill-front_writeoff { color: var(--neg); background: var(--neg-tint); box-shadow: inset 0 0 0 1px var(--neg-tint-bd); }
|
||||||
.pill-note { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
.pill-note { color: var(--muted-2); background: var(--surface-raised); box-shadow: inset 0 0 0 1px var(--border); }
|
||||||
@@ -779,10 +798,19 @@ td a:hover { color: var(--accent); }
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.result-mix__legend-row strong {
|
.result-mix__legend-row strong {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(2ch, auto) 6ch;
|
||||||
|
gap: 8px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.result-mix__legend-row strong b,
|
||||||
|
.result-mix__legend-row strong em {
|
||||||
|
font: inherit;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.result-mix__key {
|
.result-mix__key {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -887,17 +915,35 @@ td a:hover { color: var(--accent); }
|
|||||||
}
|
}
|
||||||
.event-card--inline {
|
.event-card--inline {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(82px, 120px) max-content minmax(0, 1fr) minmax(88px, auto) minmax(74px, auto);
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
padding: 9px 16px;
|
padding: 9px 16px;
|
||||||
}
|
}
|
||||||
.event-card--inline .event-card-top {
|
.event-card--inline .event-card-top {
|
||||||
gap: 8px;
|
display: contents;
|
||||||
|
}
|
||||||
|
.event-card--inline .event-card-top strong {
|
||||||
|
grid-column: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.event-card--inline .pill {
|
.event-card--inline .pill {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
font-size: 9.5px;
|
font-size: 9.5px;
|
||||||
}
|
}
|
||||||
|
.event-card--inline .event-note {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
.event-card--inline .event-amount {
|
||||||
|
grid-column: 4;
|
||||||
|
}
|
||||||
|
.event-card--inline .tiny-text {
|
||||||
|
grid-column: 5;
|
||||||
|
}
|
||||||
.event-note {
|
.event-note {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -906,10 +952,10 @@ td a:hover { color: var(--accent); }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; }
|
.event-amount { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--num); margin: 0; white-space: nowrap; text-align: right; }
|
||||||
.muted-text { color: var(--muted); font-size: 13.5px; margin: 0; }
|
.muted-text { color: var(--muted); font-size: 13.5px; margin: 0; }
|
||||||
.muted-text.small, .tiny-text { font-size: 12px; }
|
.muted-text.small, .tiny-text { font-size: 12px; }
|
||||||
.tiny-text { color: var(--faintest); }
|
.tiny-text { color: var(--faintest); margin: 0; text-align: right; white-space: nowrap; }
|
||||||
.note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; }
|
.note-list { margin: 0; padding-left: 16px; color: var(--muted); font-size: 13px; }
|
||||||
.note-list li + li { margin-top: 4px; }
|
.note-list li + li { margin-top: 4px; }
|
||||||
|
|
||||||
@@ -971,6 +1017,10 @@ td a:hover { color: var(--accent); }
|
|||||||
border-color: var(--border-hi);
|
border-color: var(--border-hi);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
.secondary-button:disabled, .ghost-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
.table-action-button {
|
.table-action-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1028,20 +1078,17 @@ td a:hover { color: var(--accent); }
|
|||||||
/* Admin workspace */
|
/* Admin workspace */
|
||||||
.admin-workspace {
|
.admin-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
|
grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr) minmax(260px, .9fr);
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.admin-workspace__main { flex: 2; min-width: 360px; }
|
.admin-workspace > .panel {
|
||||||
.admin-workspace__side { flex: 1; min-width: 300px; }
|
|
||||||
.admin-workspace .col-main,
|
|
||||||
.admin-workspace .col-side {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s4);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.admin-bottom-grid {
|
.admin-bottom-grid {
|
||||||
|
--admin-bottom-body-h: 340px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
|
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
@@ -1049,19 +1096,22 @@ td a:hover { color: var(--accent); }
|
|||||||
margin-top: var(--s4);
|
margin-top: var(--s4);
|
||||||
}
|
}
|
||||||
.admin-bottom-grid > .panel {
|
.admin-bottom-grid > .panel {
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.admin-bottom-grid > .panel:last-child {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.admin-bottom-grid > .panel:last-child .event-feed {
|
.admin-bottom-grid .table-wrap,
|
||||||
flex: 1;
|
.admin-bottom-grid .event-feed {
|
||||||
align-content: start;
|
height: var(--admin-bottom-body-h);
|
||||||
max-height: none;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.admin-bottom-grid .table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.admin-bottom-grid .event-feed {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cash position strip */
|
/* Cash position strip */
|
||||||
.cash-strip {
|
.cash-strip {
|
||||||
@@ -1144,9 +1194,97 @@ td a:hover { color: var(--accent); }
|
|||||||
.section-marker__dot--rolled { background: var(--rolled); }
|
.section-marker__dot--rolled { background: var(--rolled); }
|
||||||
|
|
||||||
/* Debt / admin tools */
|
/* Debt / admin tools */
|
||||||
.debt-panel { display: grid; gap: 18px; }
|
.debt-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: var(--s4);
|
||||||
|
}
|
||||||
.debt-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.debt-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.debt-action-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
|
.debt-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, .85fr) minmax(0, 2fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px 18px;
|
||||||
|
}
|
||||||
|
.debt-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 245px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.debt-list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface-sunk);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.debt-list-item strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13.5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.debt-list-item span {
|
||||||
|
display: block;
|
||||||
|
color: var(--faintest);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.debt-list-item b {
|
||||||
|
color: var(--owes);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.debt-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 30px 20px 34px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.debt-empty__mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 64px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--surface-sunk);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--pos);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.debt-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.debt-action-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.debt-form {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.debt-form h3 {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.debt-form textarea {
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
.import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); }
|
.import-form input[type="file"] { width: 100%; margin: 4px 0 8px; color: var(--muted); }
|
||||||
.import-form input[type="file"]::file-selector-button {
|
.import-form input[type="file"]::file-selector-button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -1165,12 +1303,17 @@ td a:hover { color: var(--accent); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Admin: append ledger event */
|
/* Admin: append ledger event */
|
||||||
.append__body { padding: 20px 22px; }
|
.append {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.append__body {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
.append__grid {
|
.append__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.field { display: block; }
|
.field { display: block; }
|
||||||
.field__label {
|
.field__label {
|
||||||
@@ -1216,7 +1359,13 @@ select.control { cursor: pointer; }
|
|||||||
border-color: var(--accent-chip-bd);
|
border-color: var(--accent-chip-bd);
|
||||||
box-shadow: 0 0 0 3px var(--accent-a22);
|
box-shadow: 0 0 0 3px var(--accent-a22);
|
||||||
}
|
}
|
||||||
.append__note { margin-bottom: 18px; }
|
.append__note {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.append__textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
.append__footer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
.append__footer { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
.append__preview { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); }
|
.append__preview { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); }
|
||||||
.evtag-pill {
|
.evtag-pill {
|
||||||
@@ -1231,9 +1380,44 @@ select.control { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Admin: session controls */
|
/* Admin: session controls */
|
||||||
.panel--pad { padding: 20px 22px; }
|
.panel--pad { padding: 18px 20px; }
|
||||||
.panel__title--mb { margin: 0 0 16px; }
|
.panel__title--mb { margin: 0 0 16px; }
|
||||||
|
.session-control-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.session-summary-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.session-summary-strip > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: var(--surface-sunk);
|
||||||
|
}
|
||||||
|
.session-summary-strip strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.import-export-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.livecard {
|
.livecard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 140px;
|
||||||
padding: 15px 16px;
|
padding: 15px 16px;
|
||||||
background: var(--surface-sunk);
|
background: var(--surface-sunk);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
@@ -1243,6 +1427,7 @@ select.control { cursor: pointer; }
|
|||||||
.livecard__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
.livecard__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||||
.dot { width: 7px; height: 7px; border-radius: 50%; }
|
.dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||||
.dot--live { background: var(--pos); animation: livepulse 1.6s ease-in-out infinite; }
|
.dot--live { background: var(--pos); animation: livepulse 1.6s ease-in-out infinite; }
|
||||||
|
.dot--idle { background: var(--muted-2); }
|
||||||
.livecard__name { font-weight: 600; color: var(--text-strong); font-size: 14px; }
|
.livecard__name { font-weight: 600; color: var(--text-strong); font-size: 14px; }
|
||||||
.livecard__no { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest); }
|
.livecard__no { font-family: var(--font-mono); font-size: 10.5px; color: var(--faintest); }
|
||||||
.livecard__metrics { display: flex; gap: 18px; margin-bottom: 14px; }
|
.livecard__metrics { display: flex; gap: 18px; margin-bottom: 14px; }
|
||||||
@@ -1292,14 +1477,68 @@ select.control { cursor: pointer; }
|
|||||||
box-shadow: 0 4px 16px var(--accent-a30);
|
box-shadow: 0 4px 16px var(--accent-a30);
|
||||||
}
|
}
|
||||||
.btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; }
|
.btn-newsession--primary:hover { color: var(--accent-ink); opacity: .88; }
|
||||||
|
.prune-session-form {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.prune-session-form .secondary-button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.carry-recs {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
.carry-rec {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--surface-sunk);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.carry-rec span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.carry-rec strong {
|
||||||
|
color: var(--rolled);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.carry-rec form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.carry-rec em {
|
||||||
|
color: var(--faintest);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Admin: CSV tools */
|
/* Admin: CSV tools */
|
||||||
.csv__desc { margin: 0 0 16px; font-size: 12.5px; color: var(--faint); }
|
.csv__desc { margin: 0 0 12px; font-size: 12.5px; color: var(--faint); }
|
||||||
|
.import-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.csv__btns { display: flex; gap: 10px; margin-bottom: 12px; }
|
.csv__btns { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||||
.csv-btn-ghost {
|
.csv-btn-ghost {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 10px;
|
padding: 9px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--field);
|
background: var(--field);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
@@ -1310,8 +1549,11 @@ select.control { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
.csv-btn-ghost:hover { border-color: var(--border-hi); }
|
.csv-btn-ghost:hover { border-color: var(--border-hi); }
|
||||||
.csv__drop {
|
.csv__drop {
|
||||||
display: block;
|
display: grid;
|
||||||
padding: 18px;
|
flex: 1;
|
||||||
|
min-height: 112px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px 14px;
|
||||||
border: 1px dashed var(--border-strong);
|
border: 1px dashed var(--border-strong);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1326,17 +1568,65 @@ select.control { cursor: pointer; }
|
|||||||
.form-shell { display: grid; justify-content: center; padding-top: 40px; }
|
.form-shell { display: grid; justify-content: center; padding-top: 40px; }
|
||||||
.form-shell.narrow { grid-template-columns: minmax(320px, 480px); }
|
.form-shell.narrow { grid-template-columns: minmax(320px, 480px); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(5, 5, 8, .68);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
.modal-backdrop[hidden] { display: none; }
|
||||||
|
.modal-card {
|
||||||
|
width: min(100%, 440px);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
.modal-card__head {
|
||||||
|
padding: 18px 20px 0;
|
||||||
|
}
|
||||||
|
.modal-card__text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.modal-card__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
background: var(--surface-sunk);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
================================================================ */
|
================================================================ */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.split > .col-main, .split > .col-side { min-width: 100%; }
|
.split > .col-main, .split > .col-side { min-width: 100%; }
|
||||||
.admin-workspace__main, .admin-workspace__side { min-width: 100%; }
|
|
||||||
.admin-workspace { grid-template-columns: 1fr; }
|
.admin-workspace { grid-template-columns: 1fr; }
|
||||||
.admin-workspace .col-main, .admin-workspace .col-side { min-width: 0; }
|
.append__textarea { min-height: 120px; }
|
||||||
|
.debt-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.admin-bottom-grid { grid-template-columns: 1fr; }
|
.admin-bottom-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 861px) and (max-width: 1180px) {
|
||||||
|
.admin-workspace {
|
||||||
|
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.player-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
@@ -1351,7 +1641,19 @@ select.control { cursor: pointer; }
|
|||||||
.recon-metric-grid { grid-template-columns: 1fr; }
|
.recon-metric-grid { grid-template-columns: 1fr; }
|
||||||
.player-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.player-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.event-card--inline {
|
.event-card--inline {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) max-content auto;
|
||||||
|
}
|
||||||
|
.event-card--inline .event-card-top strong {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.event-card--inline .pill {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
.event-card--inline .event-amount {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
.event-card--inline .event-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.event-card--inline .tiny-text {
|
.event-card--inline .tiny-text {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ PLAYER_PALETTE = [
|
|||||||
BREAK_EVEN_TOLERANCE_CENTS = 100
|
BREAK_EVEN_TOLERANCE_CENTS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def session_sequence_number(session_id: str, session_date: str) -> int:
|
||||||
|
suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1)
|
||||||
|
|
||||||
|
if session_id.strip() == session_date.strip():
|
||||||
|
return 1
|
||||||
|
if suffix.isdigit():
|
||||||
|
return int(suffix)
|
||||||
|
if suffix.lower().startswith("s") and suffix[1:].isdigit():
|
||||||
|
return int(suffix[1:])
|
||||||
|
return 9999
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SessionEntry:
|
class SessionEntry:
|
||||||
session_id: str
|
session_id: str
|
||||||
@@ -40,19 +52,32 @@ class SessionEntry:
|
|||||||
cash_out_cents: int = 0
|
cash_out_cents: int = 0
|
||||||
paid_cents: int = 0
|
paid_cents: int = 0
|
||||||
rollover_in_cents: int = 0
|
rollover_in_cents: int = 0
|
||||||
|
payout_carry_in_cents: int = 0
|
||||||
rollover_out_cents: int = 0
|
rollover_out_cents: int = 0
|
||||||
notes: list[str] = field(default_factory=list)
|
notes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def invested_cents(self) -> int:
|
def invested_cents(self) -> int:
|
||||||
# Poker performance: everything that entered play, including house fronts
|
# Poker performance: money/chips that entered play. This is intentionally
|
||||||
# and prior unpaid winnings rolled into this session.
|
# broader than real cash because fronts and rollover-ins affect results.
|
||||||
return self.buy_in_cents + self.front_cents + self.rollover_in_cents
|
return self.buy_in_cents + self.front_cents + self.rollover_in_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def net_cents(self) -> int:
|
def net_cents(self) -> int:
|
||||||
return self.cash_out_cents - self.invested_cents
|
return self.cash_out_cents - self.invested_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paid_out_cents(self) -> int:
|
||||||
|
return self.paid_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debt_repayment_cents(self) -> int:
|
||||||
|
return self.front_collected_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def writeoff_cents(self) -> int:
|
||||||
|
return self.front_writeoff_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gross_payout_cents(self) -> int:
|
def gross_payout_cents(self) -> int:
|
||||||
# Settlement: cashout is a gross chip count/result, not proof that cash
|
# Settlement: cashout is a gross chip count/result, not proof that cash
|
||||||
@@ -64,33 +89,42 @@ class SessionEntry:
|
|||||||
def player_owes_gross_cents(self) -> int:
|
def player_owes_gross_cents(self) -> int:
|
||||||
return max(self.front_cents - self.cash_out_cents, 0)
|
return max(self.front_cents - self.cash_out_cents, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gross_due_to_house_cents(self) -> int:
|
||||||
|
return self.player_owes_gross_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settled_to_player_cents(self) -> int:
|
def settled_to_player_cents(self) -> int:
|
||||||
# Rollover-out resolves the source session's payable even though it is
|
# Rollover-out resolves the source session's payable even though it is
|
||||||
# not real cash out. The destination session records it as rollover-in.
|
# not real cash out. The destination session records it as rollover-in.
|
||||||
return self.paid_cents + self.rollover_out_cents
|
return self.paid_out_cents + self.rollover_out_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_due_to_player_cents(self) -> int:
|
def current_due_to_player_cents(self) -> int:
|
||||||
return max(self.gross_payout_cents - self.settled_to_player_cents, 0)
|
return max(
|
||||||
|
self.gross_payout_cents
|
||||||
|
+ self.payout_carry_in_cents
|
||||||
|
- self.settled_to_player_cents,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settled_to_house_cents(self) -> int:
|
def settled_to_house_cents(self) -> int:
|
||||||
# Front collections are real cash in. Writeoffs are not cash, but they
|
# Front collections are real cash in. Writeoffs are not cash, but they
|
||||||
# do resolve the receivable.
|
# do resolve the receivable.
|
||||||
return self.front_collected_cents + self.front_writeoff_cents
|
return self.debt_repayment_cents + self.writeoff_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_due_to_house_cents(self) -> int:
|
def current_due_to_house_cents(self) -> int:
|
||||||
return max(self.player_owes_gross_cents - self.settled_to_house_cents, 0)
|
return max(self.gross_due_to_house_cents - self.settled_to_house_cents, 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def real_cash_in_cents(self) -> int:
|
def real_cash_in_cents(self) -> int:
|
||||||
return self.buy_in_cents + self.front_collected_cents
|
return self.buy_in_cents + self.debt_repayment_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def real_cash_out_cents(self) -> int:
|
def real_cash_out_cents(self) -> int:
|
||||||
return self.paid_cents
|
return self.paid_out_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def payout_due_cents(self) -> int:
|
def payout_due_cents(self) -> int:
|
||||||
@@ -114,11 +148,11 @@ class SessionEntry:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def raw_player_owes_cents(self) -> int:
|
def raw_player_owes_cents(self) -> int:
|
||||||
return self.player_owes_gross_cents
|
return self.gross_due_to_house_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def front_shortfall_cents(self) -> int:
|
def front_shortfall_cents(self) -> int:
|
||||||
return self.player_owes_gross_cents
|
return self.gross_due_to_house_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def overpaid_front_cents(self) -> int:
|
def overpaid_front_cents(self) -> int:
|
||||||
@@ -127,29 +161,31 @@ class SessionEntry:
|
|||||||
@property
|
@property
|
||||||
def front_writeoff_applied_cents(self) -> int:
|
def front_writeoff_applied_cents(self) -> int:
|
||||||
return min(
|
return min(
|
||||||
self.front_writeoff_cents,
|
self.writeoff_cents,
|
||||||
max(self.player_owes_gross_cents - self.front_collected_applied_cents, 0),
|
max(self.gross_due_to_house_cents - self.front_collected_applied_cents, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def front_collected_applied_cents(self) -> int:
|
def front_collected_applied_cents(self) -> int:
|
||||||
return min(self.front_collected_cents, self.player_owes_gross_cents)
|
return min(self.debt_repayment_cents, self.gross_due_to_house_cents)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def front_resolved_cents(self) -> int:
|
def front_resolved_cents(self) -> int:
|
||||||
return min(self.settled_to_house_cents, self.player_owes_gross_cents)
|
return min(self.settled_to_house_cents, self.gross_due_to_house_cents)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def payout_status(self) -> str:
|
def payout_status(self) -> str:
|
||||||
if self.current_due_to_house_cents > 0:
|
if self.current_due_to_house_cents > 0:
|
||||||
return "owes"
|
return "owes"
|
||||||
if self.front_writeoff_cents > 0:
|
if self.writeoff_cents > 0:
|
||||||
return "written_off"
|
return "written_off"
|
||||||
if self.front_collected_cents > 0:
|
if self.debt_repayment_cents > 0:
|
||||||
return "collected"
|
return "collected"
|
||||||
if self.gross_payout_cents <= 0:
|
if self.gross_payout_cents <= 0:
|
||||||
return "none"
|
return "none"
|
||||||
if self.current_due_to_player_cents <= 0:
|
if self.current_due_to_player_cents <= 0:
|
||||||
|
if self.paid_out_cents < self.gross_payout_cents:
|
||||||
|
return "settled"
|
||||||
return "paid"
|
return "paid"
|
||||||
if self.settled_to_player_cents <= 0:
|
if self.settled_to_player_cents <= 0:
|
||||||
return "unpaid"
|
return "unpaid"
|
||||||
@@ -180,6 +216,10 @@ class SessionSummary:
|
|||||||
def total_rollover_in_cents(self) -> int:
|
def total_rollover_in_cents(self) -> int:
|
||||||
return sum(entry.rollover_in_cents for entry in self.entries)
|
return sum(entry.rollover_in_cents for entry in self.entries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_payout_carry_in_cents(self) -> int:
|
||||||
|
return sum(entry.payout_carry_in_cents for entry in self.entries)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_invested_cents(self) -> int:
|
def total_invested_cents(self) -> int:
|
||||||
return sum(entry.invested_cents for entry in self.entries)
|
return sum(entry.invested_cents for entry in self.entries)
|
||||||
@@ -206,7 +246,11 @@ class SessionSummary:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def total_paid_cents(self) -> int:
|
def total_paid_cents(self) -> int:
|
||||||
return sum(entry.paid_cents for entry in self.entries)
|
return self.total_paid_out_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_paid_out_cents(self) -> int:
|
||||||
|
return sum(entry.paid_out_cents for entry in self.entries)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_rollover_out_cents(self) -> int:
|
def total_rollover_out_cents(self) -> int:
|
||||||
@@ -222,7 +266,11 @@ class SessionSummary:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def total_player_owes_gross_cents(self) -> int:
|
def total_player_owes_gross_cents(self) -> int:
|
||||||
return sum(entry.player_owes_gross_cents for entry in self.entries)
|
return sum(entry.gross_due_to_house_cents for entry in self.entries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_gross_due_to_house_cents(self) -> int:
|
||||||
|
return self.total_player_owes_gross_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_settled_to_house_cents(self) -> int:
|
def total_settled_to_house_cents(self) -> int:
|
||||||
@@ -234,19 +282,39 @@ class SessionSummary:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def total_front_writeoff_cents(self) -> int:
|
def total_front_writeoff_cents(self) -> int:
|
||||||
return sum(entry.front_writeoff_cents for entry in self.entries)
|
return self.total_writeoff_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_front_collected_cents(self) -> int:
|
def total_front_collected_cents(self) -> int:
|
||||||
return sum(entry.front_collected_cents for entry in self.entries)
|
return self.total_debt_repayment_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_debt_repayment_cents(self) -> int:
|
||||||
|
return sum(entry.debt_repayment_cents for entry in self.entries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_writeoff_cents(self) -> int:
|
||||||
|
return sum(entry.writeoff_cents for entry in self.entries)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_cash_in_cents(self) -> int:
|
def total_cash_in_cents(self) -> int:
|
||||||
return sum(entry.real_cash_in_cents for entry in self.entries)
|
return sum(entry.real_cash_in_cents for entry in self.entries)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_paid_out_cents(self) -> int:
|
def total_real_cash_in_cents(self) -> int:
|
||||||
return sum(entry.real_cash_out_cents for entry in self.entries)
|
return self.total_cash_in_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_real_cash_out_cents(self) -> int:
|
||||||
|
return self.total_paid_out_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_banker_cash_in_cents(self) -> int:
|
||||||
|
return self.total_real_cash_in_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_banker_cash_out_cents(self) -> int:
|
||||||
|
return self.total_real_cash_out_cents
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_current_due_cents(self) -> int:
|
def total_current_due_cents(self) -> int:
|
||||||
@@ -290,10 +358,13 @@ class PlayerStats:
|
|||||||
total_front_writeoff_cents: int
|
total_front_writeoff_cents: int
|
||||||
current_player_owes_cents: int
|
current_player_owes_cents: int
|
||||||
total_rollover_in_cents: int
|
total_rollover_in_cents: int
|
||||||
|
total_payout_carry_in_cents: int
|
||||||
total_invested_cents: int
|
total_invested_cents: int
|
||||||
total_cash_out_cents: int
|
total_cash_out_cents: int
|
||||||
|
total_gross_payout_cents: int
|
||||||
total_paid_cents: int
|
total_paid_cents: int
|
||||||
total_rollover_out_cents: int
|
total_rollover_out_cents: int
|
||||||
|
current_due_to_player_cents: int
|
||||||
total_net_cents: int
|
total_net_cents: int
|
||||||
roi_pct: float
|
roi_pct: float
|
||||||
current_win_streak: int
|
current_win_streak: int
|
||||||
@@ -306,6 +377,30 @@ class PlayerStats:
|
|||||||
worst_session_net_cents: int
|
worst_session_net_cents: int
|
||||||
rank_change: int = 0
|
rank_change: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_debt_repayment_cents(self) -> int:
|
||||||
|
return self.total_front_collected_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_writeoff_cents(self) -> int:
|
||||||
|
return self.total_front_writeoff_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_due_to_house_cents(self) -> int:
|
||||||
|
return self.current_player_owes_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_paid_out_cents(self) -> int:
|
||||||
|
return self.total_paid_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_real_cash_out_cents(self) -> int:
|
||||||
|
return self.total_paid_out_cents
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_real_cash_in_cents(self) -> int:
|
||||||
|
return self.total_buy_in_cents + self.total_debt_repayment_cents
|
||||||
|
|
||||||
|
|
||||||
def apply_rank_changes(
|
def apply_rank_changes(
|
||||||
current_board: list[PlayerStats], prev_board: list[PlayerStats]
|
current_board: list[PlayerStats], prev_board: list[PlayerStats]
|
||||||
@@ -331,34 +426,38 @@ def cents_to_dollars(cents: int) -> Markup:
|
|||||||
def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]:
|
def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]:
|
||||||
session_id = session.session_id.strip()
|
session_id = session.session_id.strip()
|
||||||
session_date = session.session_date.strip()
|
session_date = session.session_date.strip()
|
||||||
|
return (
|
||||||
|
session_date,
|
||||||
|
session_sequence_number(session_id, session_date),
|
||||||
|
session.opened_at,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
|
||||||
if session_id == session_date:
|
|
||||||
return (session_date, 1, session.opened_at, session_id)
|
|
||||||
|
|
||||||
suffix = session_id.replace(f"{session_date}-", "")
|
def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]:
|
||||||
|
return (
|
||||||
|
entry.session_date,
|
||||||
|
session_sequence_number(entry.session_id, entry.session_date),
|
||||||
|
entry.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session_display_suffix(session_id: str, session_date: str) -> str:
|
||||||
|
suffix = session_id.strip().replace(f"{session_date.strip()}-", "", 1)
|
||||||
|
|
||||||
|
if session_id.strip() == session_date.strip():
|
||||||
|
return ""
|
||||||
if suffix.isdigit():
|
if suffix.isdigit():
|
||||||
return (session_date, int(suffix), session.opened_at, session_id)
|
return f"S{int(suffix)}"
|
||||||
|
|
||||||
if suffix.lower().startswith("s") and suffix[1:].isdigit():
|
if suffix.lower().startswith("s") and suffix[1:].isdigit():
|
||||||
return (session_date, int(suffix[1:]), session.opened_at, session_id)
|
return f"S{int(suffix[1:])}"
|
||||||
|
return suffix
|
||||||
return (session_date, 9999, session.opened_at, session_id)
|
|
||||||
|
|
||||||
|
|
||||||
def session_chart_label(session: SessionSummary) -> str:
|
def session_chart_label(session: SessionSummary) -> str:
|
||||||
session_id = session.session_id.strip()
|
suffix = session_display_suffix(session.session_id, session.session_date)
|
||||||
|
date_label = safe_date_label(session.session_date)
|
||||||
if session_id == session.session_date:
|
return f"{date_label} · {suffix}" if suffix else date_label
|
||||||
return safe_date_label(session.session_date)
|
|
||||||
|
|
||||||
suffix = session_id.replace(f"{session.session_date}-", "")
|
|
||||||
if suffix.isdigit():
|
|
||||||
return f"{safe_date_label(session.session_date)} · S{int(suffix)}"
|
|
||||||
|
|
||||||
if suffix.lower().startswith("s") and suffix[1:].isdigit():
|
|
||||||
return f"{safe_date_label(session.session_date)} · S{int(suffix[1:])}"
|
|
||||||
|
|
||||||
return f"{safe_date_label(session.session_date)} · {suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def safe_date_label(raw_date: str) -> str:
|
def safe_date_label(raw_date: str) -> str:
|
||||||
@@ -369,13 +468,7 @@ def safe_date_label(raw_date: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def session_label(session: SessionSummary) -> str:
|
def session_label(session: SessionSummary) -> str:
|
||||||
if session.session_id == session.session_date:
|
return session_chart_label(session)
|
||||||
return safe_date_label(session.session_date)
|
|
||||||
|
|
||||||
suffix = session.session_id.replace(f"{session.session_date}-", "")
|
|
||||||
if suffix.isdigit():
|
|
||||||
return f"{safe_date_label(session.session_date)} · S{int(suffix)}"
|
|
||||||
return f"{safe_date_label(session.session_date)} · {suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def color_for_name(name: str, names: list[str]) -> str:
|
def color_for_name(name: str, names: list[str]) -> str:
|
||||||
@@ -450,14 +543,22 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
|
|||||||
entry.front_cents += event["amount_cents"]
|
entry.front_cents += event["amount_cents"]
|
||||||
elif event_type == "front_collected":
|
elif event_type == "front_collected":
|
||||||
entry.front_collected_cents += event["amount_cents"]
|
entry.front_collected_cents += event["amount_cents"]
|
||||||
|
elif event_type == "debt_repayment":
|
||||||
|
entry.front_collected_cents += event["amount_cents"]
|
||||||
elif event_type == "front_writeoff":
|
elif event_type == "front_writeoff":
|
||||||
entry.front_writeoff_cents += event["amount_cents"]
|
entry.front_writeoff_cents += event["amount_cents"]
|
||||||
|
elif event_type == "writeoff":
|
||||||
|
entry.front_writeoff_cents += event["amount_cents"]
|
||||||
elif event_type == "rollover_in":
|
elif event_type == "rollover_in":
|
||||||
entry.rollover_in_cents += event["amount_cents"]
|
entry.rollover_in_cents += event["amount_cents"]
|
||||||
|
elif event_type == "payout_carry_in":
|
||||||
|
entry.payout_carry_in_cents += event["amount_cents"]
|
||||||
elif event_type == "cashout":
|
elif event_type == "cashout":
|
||||||
entry.cash_out_cents += event["amount_cents"]
|
entry.cash_out_cents += event["amount_cents"]
|
||||||
elif event_type == "paid":
|
elif event_type == "paid":
|
||||||
entry.paid_cents += event["amount_cents"]
|
entry.paid_cents += event["amount_cents"]
|
||||||
|
elif event_type == "paid_out":
|
||||||
|
entry.paid_cents += event["amount_cents"]
|
||||||
elif event_type == "rollover_out":
|
elif event_type == "rollover_out":
|
||||||
entry.rollover_out_cents += event["amount_cents"]
|
entry.rollover_out_cents += event["amount_cents"]
|
||||||
|
|
||||||
@@ -484,19 +585,12 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
sessions.sort(
|
sessions.sort(key=session_sort_key, reverse=True)
|
||||||
key=lambda session: (
|
|
||||||
session.session_date,
|
|
||||||
session.opened_at,
|
|
||||||
session.session_id,
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return sessions
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]:
|
def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]:
|
||||||
ordered_entries = sorted(entries, key=lambda entry: entry.session_date)
|
ordered_entries = sorted(entries, key=entry_sort_key)
|
||||||
|
|
||||||
longest_win_streak = 0
|
longest_win_streak = 0
|
||||||
longest_loss_streak = 0
|
longest_loss_streak = 0
|
||||||
@@ -585,14 +679,17 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
|
|||||||
|
|
||||||
total_buy_in = sum(entry.buy_in_cents for entry in entries)
|
total_buy_in = sum(entry.buy_in_cents for entry in entries)
|
||||||
total_front = sum(entry.front_cents for entry in entries)
|
total_front = sum(entry.front_cents for entry in entries)
|
||||||
total_front_collected = sum(entry.front_collected_cents for entry in entries)
|
total_front_collected = sum(entry.debt_repayment_cents for entry in entries)
|
||||||
total_front_writeoff = sum(entry.front_writeoff_cents for entry in entries)
|
total_front_writeoff = sum(entry.writeoff_cents for entry in entries)
|
||||||
current_player_owes = sum(entry.player_owes_cents for entry in entries)
|
current_player_owes = sum(entry.player_owes_cents for entry in entries)
|
||||||
total_rollover_in = sum(entry.rollover_in_cents for entry in entries)
|
total_rollover_in = sum(entry.rollover_in_cents for entry in entries)
|
||||||
|
total_payout_carry_in = sum(entry.payout_carry_in_cents for entry in entries)
|
||||||
total_invested = sum(entry.invested_cents for entry in entries)
|
total_invested = sum(entry.invested_cents for entry in entries)
|
||||||
total_cash_out = sum(entry.cash_out_cents for entry in entries)
|
total_cash_out = sum(entry.cash_out_cents for entry in entries)
|
||||||
total_paid = sum(entry.paid_cents for entry in entries)
|
total_gross_payout = sum(entry.gross_payout_cents for entry in entries)
|
||||||
|
total_paid = sum(entry.paid_out_cents for entry in entries)
|
||||||
total_rollover_out = sum(entry.rollover_out_cents for entry in entries)
|
total_rollover_out = sum(entry.rollover_out_cents for entry in entries)
|
||||||
|
current_due_to_player = sum(entry.current_due_to_player_cents for entry in entries)
|
||||||
total_net = sum(entry.net_cents for entry in entries)
|
total_net = sum(entry.net_cents for entry in entries)
|
||||||
roi_pct = (total_net / total_invested * 100) if total_invested else 0.0
|
roi_pct = (total_net / total_invested * 100) if total_invested else 0.0
|
||||||
|
|
||||||
@@ -614,10 +711,13 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
|
|||||||
total_front_writeoff_cents=total_front_writeoff,
|
total_front_writeoff_cents=total_front_writeoff,
|
||||||
current_player_owes_cents=current_player_owes,
|
current_player_owes_cents=current_player_owes,
|
||||||
total_rollover_in_cents=total_rollover_in,
|
total_rollover_in_cents=total_rollover_in,
|
||||||
|
total_payout_carry_in_cents=total_payout_carry_in,
|
||||||
total_invested_cents=total_invested,
|
total_invested_cents=total_invested,
|
||||||
total_cash_out_cents=total_cash_out,
|
total_cash_out_cents=total_cash_out,
|
||||||
|
total_gross_payout_cents=total_gross_payout,
|
||||||
total_paid_cents=total_paid,
|
total_paid_cents=total_paid,
|
||||||
total_rollover_out_cents=total_rollover_out,
|
total_rollover_out_cents=total_rollover_out,
|
||||||
|
current_due_to_player_cents=current_due_to_player,
|
||||||
total_net_cents=total_net,
|
total_net_cents=total_net,
|
||||||
roi_pct=roi_pct,
|
roi_pct=roi_pct,
|
||||||
current_win_streak=run_summary["current_win_streak"],
|
current_win_streak=run_summary["current_win_streak"],
|
||||||
|
|||||||
+16
@@ -39,8 +39,12 @@ VALID_EVENT_TYPES = {
|
|||||||
"front_writeoff",
|
"front_writeoff",
|
||||||
"cashout",
|
"cashout",
|
||||||
"paid",
|
"paid",
|
||||||
|
"paid_out",
|
||||||
"rollover_in",
|
"rollover_in",
|
||||||
|
"payout_carry_in",
|
||||||
"rollover_out",
|
"rollover_out",
|
||||||
|
"debt_repayment",
|
||||||
|
"writeoff",
|
||||||
"note",
|
"note",
|
||||||
"session_open",
|
"session_open",
|
||||||
"session_close",
|
"session_close",
|
||||||
@@ -120,3 +124,15 @@ def append_event(
|
|||||||
writer.writerow(event)
|
writer.writerow(event)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def write_events(csv_path: Path, events: list[EventRow]) -> None:
|
||||||
|
ensure_data_file(csv_path)
|
||||||
|
|
||||||
|
tmp_path = csv_path.with_suffix(f"{csv_path.suffix}.tmp")
|
||||||
|
with tmp_path.open("w", newline="", encoding="utf-8") as file:
|
||||||
|
writer = csv.DictWriter(file, fieldnames=CSV_HEADERS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(events)
|
||||||
|
|
||||||
|
tmp_path.replace(csv_path)
|
||||||
+278
-170
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Admin · boker.club{% endblock %}
|
{% block title %}Admin · myboker.org{% endblock %}
|
||||||
{% block page_class %}page--admin{% endblock %}
|
{% block page_class %}page--admin{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero__title">Ledger controls</h1>
|
<h1 class="hero__title">Ledger controls</h1>
|
||||||
<p class="hero__sub">Append events to the audit trail. All history is permanent.</p>
|
<p class="hero__sub">Append events to the audit trail. Player ledger history stays permanent.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only
|
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
|
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat stat--rail" style="--rail:var(--pos);">
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
<span class="kicker">Cash out</span>
|
<span class="kicker">Cash paid out</span>
|
||||||
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
|
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat stat--rail" style="--rail:var(--neg);">
|
<div class="stat stat--rail" style="--rail:var(--neg);">
|
||||||
@@ -38,125 +38,255 @@
|
|||||||
<div class="stat__val {% if admin_totals.net_book_position_cents == 0 %}num-muted{% elif admin_totals.net_book_position_cents > 0 %}num-pos{% else %}num-neg{% endif %}">
|
<div class="stat__val {% if admin_totals.net_book_position_cents == 0 %}num-muted{% elif admin_totals.net_book_position_cents > 0 %}num-pos{% else %}num-neg{% endif %}">
|
||||||
{{ admin_totals.net_book_position_cents | money }}
|
{{ admin_totals.net_book_position_cents | money }}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat__sub">cash in - cash out + receivables - payables</div>
|
<div class="stat__sub">cash in - cash paid out + receivables - payables</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Two-column workspace -->
|
<!-- Admin workspace -->
|
||||||
<div class="admin-workspace">
|
<div class="admin-workspace">
|
||||||
|
|
||||||
<!-- LEFT: append form + recent sessions -->
|
<form class="panel append" method="post" action="{{ url_for('admin_dashboard') }}">
|
||||||
<div class="col-main">
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Append ledger event</h2>
|
||||||
<form class="panel append" method="post" action="{{ url_for('admin_dashboard') }}">
|
<span class="panel__tag">fast entry</span>
|
||||||
<div class="panel__head">
|
</div>
|
||||||
<h2 class="panel__title">Append ledger event</h2>
|
<div class="append__body">
|
||||||
<span class="panel__tag">fast entry</span>
|
<div class="append__grid">
|
||||||
</div>
|
<label class="field">
|
||||||
<div class="append__body">
|
<span class="field__label">Session</span>
|
||||||
<div class="append__grid">
|
<select class="control" name="session_id" required>
|
||||||
<label class="field">
|
<option value="">Select session</option>
|
||||||
<span class="field__label">Session</span>
|
{% for s in open_sessions %}
|
||||||
<select class="control" name="session_id" required>
|
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
|
||||||
<option value="">Select session</option>
|
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
|
||||||
{% for s in open_sessions %}
|
</option>
|
||||||
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
|
{% endfor %}
|
||||||
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
|
</select>
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Player</span>
|
|
||||||
<input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
|
|
||||||
<datalist id="player_names_list">
|
|
||||||
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
|
|
||||||
</datalist>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Event type</span>
|
|
||||||
<select class="control" name="event_type" required id="eventTypeSelect">
|
|
||||||
{% for value, label, color in event_types %}
|
|
||||||
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Amount</span>
|
|
||||||
<span class="control control--money">
|
|
||||||
<span class="control__prefix">$</span>
|
|
||||||
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="field append__note">
|
|
||||||
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
|
|
||||||
<input class="control" name="note" placeholder="e.g. fronted buy-in, settle next week">
|
|
||||||
</label>
|
</label>
|
||||||
<div class="append__footer">
|
<label class="field">
|
||||||
<button type="submit" class="btn btn--primary">Append event</button>
|
<span class="field__label">Player</span>
|
||||||
<span class="append__preview">Will record as
|
<input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
|
||||||
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
|
<datalist id="player_names_list">
|
||||||
|
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Event type</span>
|
||||||
|
<select class="control" name="event_type" required id="eventTypeSelect">
|
||||||
|
{% for value, label, color in event_types %}
|
||||||
|
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Amount</span>
|
||||||
|
<span class="control control--money">
|
||||||
|
<span class="control__prefix">$</span>
|
||||||
|
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<label class="field append__note">
|
||||||
|
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
|
||||||
</div><!-- /.col-main -->
|
<textarea class="control append__textarea" name="note" placeholder="e.g. fronted buy-in, settle next week"></textarea>
|
||||||
|
</label>
|
||||||
<!-- RIGHT: session controls + CSV + raw events feed -->
|
<div class="append__footer">
|
||||||
<div class="col-side">
|
<button type="submit" class="btn btn--primary">Append event</button>
|
||||||
|
<span class="append__preview">Will record as
|
||||||
<div class="panel panel--pad">
|
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
|
||||||
<h2 class="panel__title panel__title--mb">Session controls</h2>
|
</span>
|
||||||
|
</div>
|
||||||
{% if live_session %}
|
{% if pending_carry_items %}
|
||||||
<div class="livecard">
|
<div class="carry-recs">
|
||||||
<div class="livecard__head">
|
<div class="micro">Recommended carry-ins</div>
|
||||||
<span class="dot dot--live"></span>
|
{% for item in pending_carry_items %}
|
||||||
<span class="livecard__name">{{ session_label(live_session) }}</span>
|
<div class="carry-rec">
|
||||||
<span class="livecard__no">{{ live_session_no }}</span>
|
<span>{{ item.player_name }}</span>
|
||||||
|
<strong>{{ item.amount_cents | money }}</strong>
|
||||||
|
{% if live_session and item.player_name in live_session_player_names %}
|
||||||
|
<form method="post" action="{{ url_for('admin_apply_payout_carry_in') }}">
|
||||||
|
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
|
||||||
|
<input type="hidden" name="player_name" value="{{ item.player_name }}">
|
||||||
|
<button class="table-action-button" type="submit">Apply</button>
|
||||||
|
</form>
|
||||||
|
{% elif live_session %}
|
||||||
|
<em>Add buy-in first</em>
|
||||||
|
{% else %}
|
||||||
|
<em>Open session</em>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="livecard__metrics">
|
{% endfor %}
|
||||||
<div><div class="micro">In play</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
|
|
||||||
<div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</div></div>
|
|
||||||
<div><div class="micro">Open</div><div class="mono16 mono16--warn">{{ live_unresolved_count }}</div></div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="{{ url_for('admin_session_state') }}">
|
|
||||||
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
|
|
||||||
<button class="btn-close" type="submit" name="state" value="closed">Close & settle session</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('admin_open_session') }}">
|
<div class="panel panel--pad session-control-panel">
|
||||||
<input type="hidden" name="session_date" value="{{ admin_today }}">
|
<h2 class="panel__title panel__title--mb">Session controls</h2>
|
||||||
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
|
<div class="session-summary-strip">
|
||||||
</form>
|
<div>
|
||||||
|
<span class="micro">Today</span>
|
||||||
|
<strong>{{ admin_today }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="micro">Empty</span>
|
||||||
|
<strong>{{ empty_session_count }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel panel--pad">
|
{% if live_session %}
|
||||||
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
|
<div class="livecard">
|
||||||
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
|
<div class="livecard__head">
|
||||||
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="import-form">
|
<span class="dot dot--live"></span>
|
||||||
<div class="csv__btns">
|
<span class="livecard__name">{{ session_label(live_session) }}</span>
|
||||||
<a class="csv-btn-ghost" href="{{ url_for('admin_export_csv') }}">↓ Export CSV</a>
|
<span class="livecard__no">{{ live_session_no }}</span>
|
||||||
<label class="csv-btn-ghost" for="csvTopImport">↑ Import CSV</label>
|
</div>
|
||||||
</div>
|
<div class="livecard__metrics">
|
||||||
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
|
<div><div class="micro">Invested</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
|
||||||
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
|
<div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</div></div>
|
||||||
|
<div><div class="micro">Open</div><div class="mono16 mono16--warn">{{ live_unresolved_count }}</div></div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('admin_session_state') }}"
|
||||||
|
{% if live_unresolved_count > 0 %}data-close-warning="true"{% endif %}>
|
||||||
|
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
|
||||||
|
<button class="btn-close" type="submit" name="state" value="closed">Close session</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="livecard livecard--empty">
|
||||||
|
<div class="livecard__head">
|
||||||
|
<span class="dot dot--idle"></span>
|
||||||
|
<span class="livecard__name">No open session</span>
|
||||||
|
<span class="livecard__no">idle</span>
|
||||||
|
</div>
|
||||||
|
<div class="livecard__metrics">
|
||||||
|
<div><div class="micro">Invested</div><div class="mono16">{{ 0 | money }}</div></div>
|
||||||
|
<div><div class="micro">Seated</div><div class="mono16">0</div></div>
|
||||||
|
<div><div class="micro">Open</div><div class="mono16">0</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div><!-- /.col-side -->
|
<form method="post" action="{{ url_for('admin_open_session') }}">
|
||||||
|
<input type="hidden" name="session_date" value="{{ admin_today }}">
|
||||||
|
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('admin_prune_empty_sessions') }}" class="prune-session-form">
|
||||||
|
<button class="secondary-button" type="submit" {% if empty_session_count == 0 %}disabled{% endif %}>
|
||||||
|
Prune empty sessions{% if empty_session_count > 0 %} ({{ empty_session_count }}){% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel panel--pad import-export-panel">
|
||||||
|
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
|
||||||
|
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
|
||||||
|
<form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="import-form">
|
||||||
|
<div class="csv__btns">
|
||||||
|
<a class="csv-btn-ghost" href="{{ url_for('admin_export_csv') }}">↓ Export CSV</a>
|
||||||
|
<label class="csv-btn-ghost" for="csvTopImport">↑ Import CSV</label>
|
||||||
|
</div>
|
||||||
|
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
|
||||||
|
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /.admin-workspace -->
|
</div><!-- /.admin-workspace -->
|
||||||
|
|
||||||
|
<!-- Debt resolution -->
|
||||||
|
<div class="panel debt-panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Front receivables</span>
|
||||||
|
<h2 class="panel__title">Debt resolution</h2>
|
||||||
|
</div>
|
||||||
|
<div class="debt-summary">
|
||||||
|
<span class="badge badge--owes">Open {{ admin_totals.players_owe_house_cents | money }}</span>
|
||||||
|
<span class="badge badge--paid">Cash repaid {{ admin_totals.collected_cents | money }}</span>
|
||||||
|
<span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if debt_entries %}
|
||||||
|
<div class="debt-layout">
|
||||||
|
<div class="debt-list">
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}{% set e = item.entry %}
|
||||||
|
<div class="debt-list-item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ e.player_name }}</strong>
|
||||||
|
<span>{{ session_label(s) }}</span>
|
||||||
|
</div>
|
||||||
|
<b>{{ e.player_owes_cents | money }}</b>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="debt-action-grid">
|
||||||
|
<form class="form-card debt-form" method="post" action="{{ url_for('admin_collect_front') }}">
|
||||||
|
<h3>Collect debt</h3>
|
||||||
|
<label>
|
||||||
|
<span>Outstanding debt</span>
|
||||||
|
<select name="debt_key" required>
|
||||||
|
<option value="">Select debt</option>
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}{% set e = item.entry %}
|
||||||
|
<option value="{{ s.session_id }}||{{ e.player_name }}">
|
||||||
|
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Amount collected</span>
|
||||||
|
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note</span>
|
||||||
|
<textarea name="note" rows="2" placeholder="E-transfer received, cash, etc."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="primary-button" type="submit">Mark collected</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="form-card debt-form" method="post" action="{{ url_for('admin_write_off_front') }}">
|
||||||
|
<h3>Write off debt</h3>
|
||||||
|
<label>
|
||||||
|
<span>Outstanding debt</span>
|
||||||
|
<select name="debt_key" required>
|
||||||
|
<option value="">Select debt</option>
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}{% set e = item.entry %}
|
||||||
|
<option value="{{ s.session_id }}||{{ e.player_name }}">
|
||||||
|
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Amount to write off</span>
|
||||||
|
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note</span>
|
||||||
|
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="secondary-button" type="submit">Write off debt</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="debt-empty">
|
||||||
|
<div class="debt-empty__mark">$0</div>
|
||||||
|
<p>No open player debts right now.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="admin-bottom-grid">
|
<div class="admin-bottom-grid">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel__head">
|
<div class="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<span class="kicker" style="margin:0;">Recent sessions</span>
|
<span class="kicker" style="margin:0;">All sessions</span>
|
||||||
<h2 class="panel__title">Current totals</h2>
|
<h2 class="panel__title">Current totals</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,8 +297,8 @@
|
|||||||
<th>Session</th>
|
<th>Session</th>
|
||||||
<th>Players</th>
|
<th>Players</th>
|
||||||
<th>Cash in</th>
|
<th>Cash in</th>
|
||||||
<th>Cash out</th>
|
<th>Cash paid out</th>
|
||||||
<th>House owes</th>
|
<th>House owes players</th>
|
||||||
<th>Players owe house</th>
|
<th>Players owe house</th>
|
||||||
<th>Book position</th>
|
<th>Book position</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -180,8 +310,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
|
<td><a href="{{ url_for('session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
|
||||||
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
|
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
|
||||||
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_cash_in_cents | money }}</td>
|
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_banker_cash_in_cents | money }}</td>
|
||||||
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_paid_out_cents | money }}</td>
|
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_banker_cash_out_cents | money }}</td>
|
||||||
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
|
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
|
||||||
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
|
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
|
||||||
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td>
|
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td>
|
||||||
@@ -231,6 +361,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="closeSessionModal" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="closeSessionModalTitle">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Open settlement</span>
|
||||||
|
<h2 class="panel__title" id="closeSessionModalTitle">Close session?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This session has unpaid or uncarried cashouts, or unresolved debts. Closing stops table entry, but the open settlement items will remain tracked.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="secondary-button" type="button" id="cancelCloseSession">Keep open</button>
|
||||||
|
<button class="btn btn--primary" type="button" id="confirmCloseSession">Close session</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
||||||
const eventTypePreview = document.getElementById('eventTypePreview');
|
const eventTypePreview = document.getElementById('eventTypePreview');
|
||||||
@@ -255,74 +399,38 @@ csvDrop?.addEventListener('drop', (event) => {
|
|||||||
csvInput.files = event.dataTransfer.files;
|
csvInput.files = event.dataTransfer.files;
|
||||||
csvInput.form?.submit();
|
csvInput.form?.submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeSessionModal = document.getElementById('closeSessionModal');
|
||||||
|
const cancelCloseSession = document.getElementById('cancelCloseSession');
|
||||||
|
const confirmCloseSession = document.getElementById('confirmCloseSession');
|
||||||
|
let pendingCloseForm = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('form[data-close-warning="true"]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (form.dataset.confirmed === 'true') return;
|
||||||
|
event.preventDefault();
|
||||||
|
pendingCloseForm = form;
|
||||||
|
closeSessionModal.hidden = false;
|
||||||
|
confirmCloseSession?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelCloseSession?.addEventListener('click', () => {
|
||||||
|
closeSessionModal.hidden = true;
|
||||||
|
pendingCloseForm = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
closeSessionModal?.addEventListener('click', (event) => {
|
||||||
|
if (event.target !== closeSessionModal) return;
|
||||||
|
closeSessionModal.hidden = true;
|
||||||
|
pendingCloseForm = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmCloseSession?.addEventListener('click', () => {
|
||||||
|
if (!pendingCloseForm) return;
|
||||||
|
pendingCloseForm.dataset.confirmed = 'true';
|
||||||
|
pendingCloseForm.submit();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Front debt resolution -->
|
|
||||||
{% if debt_entries %}
|
|
||||||
<div class="panel debt-panel" style="margin-top:20px;">
|
|
||||||
<div class="panel__head">
|
|
||||||
<div>
|
|
||||||
<span class="kicker" style="margin:0;">Front receivables</span>
|
|
||||||
<h2 class="panel__title">Debt resolution</h2>
|
|
||||||
</div>
|
|
||||||
<div class="debt-summary">
|
|
||||||
<span class="badge badge--owes">Open {{ admin_totals.players_owe_house_cents | money }}</span>
|
|
||||||
<span class="badge badge--paid">Repaid {{ admin_totals.collected_cents | money }}</span>
|
|
||||||
<span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="debt-action-grid" style="padding:20px;">
|
|
||||||
<form class="form-card" style="padding:0;background:transparent;" method="post" action="{{ url_for('admin_collect_front') }}">
|
|
||||||
<h3>Collect debt</h3>
|
|
||||||
<label>
|
|
||||||
<span>Outstanding debt</span>
|
|
||||||
<select name="debt_key" required>
|
|
||||||
<option value="">Select debt</option>
|
|
||||||
{% for item in debt_entries %}
|
|
||||||
{% set s = item.session %}{% set e = item.entry %}
|
|
||||||
<option value="{{ s.session_id }}||{{ e.player_name }}">
|
|
||||||
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Amount collected</span>
|
|
||||||
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Note</span>
|
|
||||||
<textarea name="note" rows="2" placeholder="E-transfer received, cash, etc."></textarea>
|
|
||||||
</label>
|
|
||||||
<button class="primary-button" type="submit">Mark collected</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form class="form-card" style="padding:0;background:transparent;" method="post" action="{{ url_for('admin_write_off_front') }}">
|
|
||||||
<h3>Write off debt</h3>
|
|
||||||
<label>
|
|
||||||
<span>Outstanding debt</span>
|
|
||||||
<select name="debt_key" required>
|
|
||||||
<option value="">Select debt</option>
|
|
||||||
{% for item in debt_entries %}
|
|
||||||
{% set s = item.session %}{% set e = item.entry %}
|
|
||||||
<option value="{{ s.session_id }}||{{ e.player_name }}">
|
|
||||||
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Amount to write off</span>
|
|
||||||
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Note</span>
|
|
||||||
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
|
|
||||||
</label>
|
|
||||||
<button class="secondary-button" type="submit">Write off front</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Admin Login · boker.club{% endblock %}
|
{% block title %}Admin Login · myboker.org{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="form-shell narrow">
|
<div class="form-shell narrow">
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Restricted access</p>
|
<p class="eyebrow">Restricted access</p>
|
||||||
<h1 style="font-size:28px;margin-bottom:8px;">Admin login</h1>
|
<h1 style="font-size:28px;margin-bottom:8px;">Admin login</h1>
|
||||||
<p class="muted-text" style="margin-bottom:8px;">Only boker.club manager needs credentials. All stats pages stay public.</p>
|
<p class="muted-text" style="margin-bottom:8px;">Only myboker.org manager needs credentials. All stats pages stay public.</p>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}boker.club{% endblock %}</title>
|
<title>{% block title %}myboker.org{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="topbar__inner">
|
<div class="topbar__inner">
|
||||||
<a class="brand" href="{{ url_for('leaderboard') }}">
|
<a class="brand" href="{{ url_for('leaderboard') }}">
|
||||||
<span class="brand__mark"></span>
|
<span class="brand__mark"></span>
|
||||||
<span class="brand__name">boker.club</span>
|
<span class="brand__name">myboker.org</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="mainnav">
|
<nav class="mainnav">
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'leaderboard' }}" href="{{ url_for('leaderboard') }}">Leaderboard</a>
|
<a class="navlink {{ 'is-active' if request.endpoint == 'leaderboard' }}" href="{{ url_for('leaderboard') }}">Leaderboard</a>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Leaderboard · boker.club{% endblock %}
|
{% block title %}Leaderboard · myboker.org{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;">
|
<div class="hero" style="display:flex;flex-wrap:wrap;gap:var(--s6);align-items:flex-start;justify-content:space-between;">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ player.player_name }} · boker.club{% endblock %}
|
{% block title %}{{ player.player_name }} · myboker.org{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div>
|
<div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="kicker">Cash-outs</span>
|
<span class="kicker">Gross cashouts</span>
|
||||||
<div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div>
|
<div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,15 +105,15 @@
|
|||||||
<div class="result-mix__legend">
|
<div class="result-mix__legend">
|
||||||
<div class="result-mix__legend-row">
|
<div class="result-mix__legend-row">
|
||||||
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span>
|
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span>
|
||||||
<strong>{{ player.winning_sessions }} · {{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</strong>
|
<strong><b>{{ player.winning_sessions }}</b><em>{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-mix__legend-row">
|
<div class="result-mix__legend-row">
|
||||||
<span><i class="result-mix__key result-mix__key--even"></i>Even</span>
|
<span><i class="result-mix__key result-mix__key--even"></i>Even</span>
|
||||||
<strong>{{ player.break_even_sessions }} · {{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</strong>
|
<strong><b>{{ player.break_even_sessions }}</b><em>{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-mix__legend-row">
|
<div class="result-mix__legend-row">
|
||||||
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span>
|
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span>
|
||||||
<strong>{{ player.losing_sessions }} · {{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</strong>
|
<strong><b>{{ player.losing_sessions }}</b><em>{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if result_total > 0 %}
|
{% if result_total > 0 %}
|
||||||
@@ -151,6 +151,16 @@
|
|||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Record</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.winning_sessions }}W</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.losing_sessions }}L</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span>{{ player.break_even_sessions }}E</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);"
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);"
|
||||||
@@ -197,7 +207,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="section-marker">
|
<div class="section-marker">
|
||||||
<span class="section-marker__dot section-marker__dot--rolled"></span>
|
<span class="section-marker__dot section-marker__dot--rolled"></span>
|
||||||
Poker basis
|
Poker basis + settlement
|
||||||
</div>
|
</div>
|
||||||
<h2 class="panel__title">Investment and settlement</h2>
|
<h2 class="panel__title">Investment and settlement</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,38 +226,54 @@
|
|||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled in</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled in</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_in_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_in_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if player.total_payout_carry_in_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Payout carry-in</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_payout_carry_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total invested</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total invested</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_invested_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_invested_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash-outs</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross cashout result</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_cash_out_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_cash_out_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross player claim</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_gross_payout_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled out</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled out</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_out_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_out_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Paid out</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash paid out</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_paid_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_paid_out_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if player.total_front_collected_cents > 0 %}
|
{% if player.total_debt_repayment_cents > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Debt repaid</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash debt repaid</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_front_collected_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_debt_repayment_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if player.current_player_owes_cents > 0 %}
|
{% if player.current_due_to_player_cents > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Still owes</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">House owes player</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--owes);">{{ player.current_player_owes_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.current_due_to_player_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if player.total_front_writeoff_cents > 0 %}
|
{% if player.current_due_to_house_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Owes house</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--owes);">{{ player.current_due_to_house_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.total_writeoff_cents > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Written off</td>
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Written off</td>
|
||||||
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.total_front_writeoff_cents | money }}</td>
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.total_writeoff_cents | money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ session_label(session) }} · boker.club{% endblock %}
|
{% block title %}{{ session_label(session) }} · myboker.org{% endblock %}
|
||||||
{% block page_class %}page--session{% endblock %}
|
{% block page_class %}page--session{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@@ -28,20 +28,20 @@
|
|||||||
<div class="recon-grid" style="margin-bottom:20px;">
|
<div class="recon-grid" style="margin-bottom:20px;">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="recon-card-head">
|
<div class="recon-card-head">
|
||||||
<span class="kicker kicker--dot kicker--dot-accent">Cash flow</span>
|
<span class="kicker kicker--dot kicker--dot-accent">Banker cash flow</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="recon-metric-grid">
|
<div class="recon-metric-grid">
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Cash in</span>
|
<span class="kicker" style="font-size:10px;">Actual cash in</span>
|
||||||
<div class="recon-val recon-val--num">{{ session.total_cash_in_cents | money }}</div>
|
<div class="recon-val recon-val--num">{{ session.total_real_cash_in_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Gross payout</span>
|
<span class="kicker" style="font-size:10px;">Gross player claim</span>
|
||||||
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
|
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Cash paid out</span>
|
<span class="kicker" style="font-size:10px;">Cash paid out</span>
|
||||||
<div class="recon-val recon-val--pos">{{ session.total_paid_cents | money }}</div>
|
<div class="recon-val recon-val--pos">{{ session.total_real_cash_out_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Rolled out</span>
|
<span class="kicker" style="font-size:10px;">Rolled out</span>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="recon-metric-grid">
|
<div class="recon-metric-grid">
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">House owes</span>
|
<span class="kicker" style="font-size:10px;">House owes players</span>
|
||||||
<div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}">
|
<div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}">
|
||||||
{{ session.total_current_due_to_player_cents | money }}
|
{{ session.total_current_due_to_player_cents | money }}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,12 +67,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Debt repaid</span>
|
<span class="kicker" style="font-size:10px;">Cash debt repaid</span>
|
||||||
<div class="recon-val" style="color:var(--pos)">{{ session.total_front_collected_cents | money }}</div>
|
<div class="recon-val" style="color:var(--pos)">{{ session.total_debt_repayment_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recon-cell">
|
<div class="recon-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Written off</span>
|
<span class="kicker" style="font-size:10px;">Written off</span>
|
||||||
<div class="recon-val" style="color:var(--neg)">{{ session.total_front_writeoff_cents | money }}</div>
|
<div class="recon-val" style="color:var(--neg)">{{ session.total_writeoff_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,14 +96,15 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="col-player">Player</th>
|
<th class="col-player">Player</th>
|
||||||
<th>Invested</th>
|
<th>Invested</th>
|
||||||
<th>Cash-out</th>
|
<th>Gross cashout</th>
|
||||||
<th>Net</th>
|
<th>Net</th>
|
||||||
<th>Cash paid</th>
|
<th>Paid out</th>
|
||||||
<th>Settlement</th>
|
<th>Open settlement</th>
|
||||||
<th>Status</th>
|
<th>Settlement status</th>
|
||||||
<th class="col-acct">Fronted</th>
|
<th class="col-acct">Front</th>
|
||||||
<th class="col-acct">Rolled in</th>
|
<th class="col-acct">Rolled in</th>
|
||||||
<th class="col-acct">Gross payout</th>
|
<th class="col-acct">Payout carry-in</th>
|
||||||
|
<th class="col-acct">Gross player claim</th>
|
||||||
<th class="col-acct">Rolled out</th>
|
<th class="col-acct">Rolled out</th>
|
||||||
<th class="col-acct">Notes</th>
|
<th class="col-acct">Notes</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -119,10 +120,12 @@
|
|||||||
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
|
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
|
||||||
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
|
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
|
||||||
</td>
|
</td>
|
||||||
<td style="color:var(--pos)">{{ entry.paid_cents | money }}</td>
|
<td style="color:var(--pos)">{{ entry.paid_out_cents | money }}</td>
|
||||||
<td class="{{ 'num-neg' if entry.current_due_to_player_cents > 0 else '' }}">
|
<td class="{{ 'num-neg' if entry.current_due_to_player_cents > 0 else '' }}">
|
||||||
{% if entry.current_due_to_house_cents > 0 %}
|
{% if entry.current_due_to_house_cents > 0 %}
|
||||||
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
|
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
|
||||||
|
{% elif entry.current_due_to_player_cents > 0 %}
|
||||||
|
<span style="color:var(--neg)">due {{ entry.current_due_to_player_cents | money }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ entry.current_due_to_player_cents | money }}
|
{{ entry.current_due_to_player_cents | money }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -132,8 +135,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td>
|
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td>
|
||||||
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td>
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</td>
|
||||||
<td class="col-acct">
|
<td class="col-acct">
|
||||||
{% if entry.player_owes_gross_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %}
|
{% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</td>
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</td>
|
||||||
<td class="col-acct" style="text-align:left;font-family:var(--font-ui);">
|
<td class="col-acct" style="text-align:left;font-family:var(--font-ui);">
|
||||||
@@ -151,7 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom row: net bars + snapshot + audit feed -->
|
<!-- Bottom row: net bars + snapshot + audit feed -->
|
||||||
<div class="split" style="align-items:stretch;">
|
<div class="split">
|
||||||
<div class="panel col-main">
|
<div class="panel col-main">
|
||||||
<div class="panel__head">
|
<div class="panel__head">
|
||||||
<span class="kicker" style="margin:0;">Breakdown</span>
|
<span class="kicker" style="margin:0;">Breakdown</span>
|
||||||
@@ -159,17 +163,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
|
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel col-side" style="display:flex;flex-direction:column;">
|
<div class="panel col-side">
|
||||||
<div class="panel__head">
|
<div class="panel__head">
|
||||||
<span class="kicker" style="margin:0;">Quick read</span>
|
<span class="kicker" style="margin:0;">Quick read</span>
|
||||||
<h2 class="panel__title">Session snapshot</h2>
|
<h2 class="panel__title">Session snapshot</h2>
|
||||||
</div>
|
</div>
|
||||||
{% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %}
|
{% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %}
|
||||||
{% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %}
|
{% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %}
|
||||||
<div class="snapshot-grid" style="flex:1;">
|
<div class="snapshot-grid">
|
||||||
<div class="snapshot-cell">
|
<div class="snapshot-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Biggest winner</span>
|
<span class="kicker" style="font-size:10px;">Biggest winner</span>
|
||||||
<div class="snapshot-val" style="color:var(--pos)">
|
<div class="snapshot-val" style="color:var(--pos)">
|
||||||
@@ -185,7 +189,7 @@
|
|||||||
{% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %}
|
{% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="snapshot-cell">
|
<div class="snapshot-cell">
|
||||||
<span class="kicker" style="font-size:10px;">Total cash-out</span>
|
<span class="kicker" style="font-size:10px;">Gross cashout result</span>
|
||||||
<div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
|
<div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="snapshot-cell">
|
<div class="snapshot-cell">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Sessions · boker.club{% endblock %}
|
{% block title %}Sessions · myboker.org{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Players</th>
|
<th>Players</th>
|
||||||
<th>Invested</th>
|
<th>Invested</th>
|
||||||
<th>Cash out</th>
|
<th>Gross cashout</th>
|
||||||
<th>Table net</th>
|
<th>Table net</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+177
-7
@@ -1,6 +1,15 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from stats import build_session_summaries
|
import app as app_module
|
||||||
|
from stats import (
|
||||||
|
build_leaderboard,
|
||||||
|
build_session_summaries,
|
||||||
|
player_session_series,
|
||||||
|
session_sort_key,
|
||||||
|
)
|
||||||
|
from storage import load_events, write_events
|
||||||
|
|
||||||
|
|
||||||
def cents(amount: float) -> int:
|
def cents(amount: float) -> int:
|
||||||
@@ -40,7 +49,7 @@ class AccountingTests(unittest.TestCase):
|
|||||||
[
|
[
|
||||||
event("s1", "2026-01-01", "A", "buyin", 10, 1),
|
event("s1", "2026-01-01", "A", "buyin", 10, 1),
|
||||||
event("s1", "2026-01-01", "A", "cashout", 20, 2),
|
event("s1", "2026-01-01", "A", "cashout", 20, 2),
|
||||||
event("s1", "2026-01-01", "A", "paid", 20, 3),
|
event("s1", "2026-01-01", "A", "paid_out", 20, 3),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +65,7 @@ class AccountingTests(unittest.TestCase):
|
|||||||
[
|
[
|
||||||
event("s1", "2026-01-01", "A", "front", 5, 1),
|
event("s1", "2026-01-01", "A", "front", 5, 1),
|
||||||
event("s1", "2026-01-01", "A", "cashout", 8, 2),
|
event("s1", "2026-01-01", "A", "cashout", 8, 2),
|
||||||
event("s1", "2026-01-01", "A", "paid", 3, 3),
|
event("s1", "2026-01-01", "A", "paid_out", 3, 3),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ class AccountingTests(unittest.TestCase):
|
|||||||
self.assertEqual(entry.gross_payout_cents, cents(19.50))
|
self.assertEqual(entry.gross_payout_cents, cents(19.50))
|
||||||
self.assertEqual(entry.current_due_to_player_cents, cents(9.50))
|
self.assertEqual(entry.current_due_to_player_cents, cents(9.50))
|
||||||
self.assertEqual(session.total_paid_out_cents, 0)
|
self.assertEqual(session.total_paid_out_cents, 0)
|
||||||
|
self.assertEqual(entry.payout_status, "partial")
|
||||||
|
|
||||||
def test_rollover_in_is_invested_without_new_cash_in(self):
|
def test_rollover_in_is_invested_without_new_cash_in(self):
|
||||||
rows = [
|
rows = [
|
||||||
@@ -98,20 +108,180 @@ class AccountingTests(unittest.TestCase):
|
|||||||
self.assertEqual(dest_entry.invested_cents, cents(10))
|
self.assertEqual(dest_entry.invested_cents, cents(10))
|
||||||
self.assertEqual(dest.total_cash_in_cents, 0)
|
self.assertEqual(dest.total_cash_in_cents, 0)
|
||||||
|
|
||||||
|
def test_payout_carry_in_does_not_count_as_investment_or_cash(self):
|
||||||
|
rows = [
|
||||||
|
event("s1", "2026-01-01", "A", "buyin", 10, 1),
|
||||||
|
event("s1", "2026-01-01", "A", "payout_carry_in", 0.60, 2),
|
||||||
|
event("s1", "2026-01-01", "A", "cashout", 6.55, 3),
|
||||||
|
event("s1", "2026-01-01", "A", "paid_out", 6.55, 4),
|
||||||
|
]
|
||||||
|
session, entry = self.entry_for(rows)
|
||||||
|
player = build_leaderboard([session])[0]
|
||||||
|
|
||||||
|
self.assertEqual(entry.payout_carry_in_cents, cents(0.60))
|
||||||
|
self.assertEqual(entry.invested_cents, cents(10))
|
||||||
|
self.assertEqual(entry.net_cents, cents(-3.45))
|
||||||
|
self.assertEqual(entry.current_due_to_player_cents, cents(0.60))
|
||||||
|
self.assertEqual(session.total_cash_in_cents, cents(10))
|
||||||
|
self.assertEqual(player.total_payout_carry_in_cents, cents(0.60))
|
||||||
|
|
||||||
def test_debt_repayment_and_writeoff_resolve_front_without_inflating_cash(self):
|
def test_debt_repayment_and_writeoff_resolve_front_without_inflating_cash(self):
|
||||||
session, entry = self.entry_for(
|
session, entry = self.entry_for(
|
||||||
[
|
[
|
||||||
event("s1", "2026-01-01", "A", "front", 10, 1),
|
event("s1", "2026-01-01", "A", "front", 10, 1),
|
||||||
event("s1", "2026-01-01", "A", "cashout", 0, 2),
|
event("s1", "2026-01-01", "A", "cashout", 0, 2),
|
||||||
event("s1", "2026-01-01", "A", "front_collected", 6, 3),
|
event("s1", "2026-01-01", "A", "debt_repayment", 6, 3),
|
||||||
event("s1", "2026-01-01", "A", "front_writeoff", 4, 4),
|
event("s1", "2026-01-01", "A", "writeoff", 4, 4),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(entry.player_owes_gross_cents, cents(10))
|
self.assertEqual(entry.gross_due_to_house_cents, cents(10))
|
||||||
self.assertEqual(entry.current_due_to_house_cents, 0)
|
self.assertEqual(entry.current_due_to_house_cents, 0)
|
||||||
self.assertEqual(session.total_cash_in_cents, cents(6))
|
self.assertEqual(session.total_cash_in_cents, cents(6))
|
||||||
self.assertEqual(session.total_front_writeoff_cents, cents(4))
|
self.assertEqual(session.total_writeoff_cents, cents(4))
|
||||||
|
|
||||||
|
def test_banker_book_position_separates_cash_and_open_items(self):
|
||||||
|
rows = [
|
||||||
|
event("s1", "2026-01-01", "A", "buyin", 10, 1),
|
||||||
|
event("s1", "2026-01-01", "A", "cashout", 25, 2),
|
||||||
|
event("s1", "2026-01-01", "A", "paid_out", 5, 3),
|
||||||
|
event("s1", "2026-01-01", "A", "rollover_out", 10, 4),
|
||||||
|
event("s1", "2026-01-01", "B", "front", 20, 5),
|
||||||
|
event("s1", "2026-01-01", "B", "cashout", 0, 6),
|
||||||
|
event("s1", "2026-01-01", "B", "debt_repayment", 8, 7),
|
||||||
|
event("s1", "2026-01-01", "B", "writeoff", 2, 8),
|
||||||
|
]
|
||||||
|
sessions = build_session_summaries(rows)
|
||||||
|
session = next(s for s in sessions if s.session_id == "s1")
|
||||||
|
|
||||||
|
self.assertEqual(session.total_invested_cents, cents(30))
|
||||||
|
self.assertEqual(session.total_cash_out_cents, cents(25))
|
||||||
|
self.assertEqual(session.total_real_cash_in_cents, cents(18))
|
||||||
|
self.assertEqual(session.total_real_cash_out_cents, cents(5))
|
||||||
|
self.assertEqual(session.total_current_due_to_player_cents, cents(10))
|
||||||
|
self.assertEqual(session.total_current_due_to_house_cents, cents(10))
|
||||||
|
self.assertEqual(session.total_net_book_position_cents, cents(13))
|
||||||
|
|
||||||
|
def test_player_stats_track_house_owes_player_separately_from_cash_paid(self):
|
||||||
|
rows = [
|
||||||
|
event("s1", "2026-01-01", "A", "buyin", 10, 1),
|
||||||
|
event("s1", "2026-01-01", "A", "cashout", 25, 2),
|
||||||
|
event("s1", "2026-01-01", "A", "paid_out", 5, 3),
|
||||||
|
]
|
||||||
|
player = build_leaderboard(build_session_summaries(rows))[0]
|
||||||
|
|
||||||
|
self.assertEqual(player.total_cash_out_cents, cents(25))
|
||||||
|
self.assertEqual(player.total_gross_payout_cents, cents(25))
|
||||||
|
self.assertEqual(player.total_paid_out_cents, cents(5))
|
||||||
|
self.assertEqual(player.current_due_to_player_cents, cents(20))
|
||||||
|
self.assertEqual(player.total_real_cash_out_cents, cents(5))
|
||||||
|
|
||||||
|
def test_rollover_out_can_settle_without_being_marked_paid(self):
|
||||||
|
rows = [
|
||||||
|
event("s1", "2026-01-01", "A", "buyin", 10, 1),
|
||||||
|
event("s1", "2026-01-01", "A", "cashout", 20, 2),
|
||||||
|
event("s1", "2026-01-01", "A", "rollover_out", 20, 3),
|
||||||
|
]
|
||||||
|
entry = build_session_summaries(rows)[0].entries[0]
|
||||||
|
|
||||||
|
self.assertEqual(entry.current_due_to_player_cents, 0)
|
||||||
|
self.assertEqual(entry.paid_out_cents, 0)
|
||||||
|
self.assertEqual(entry.payout_status, "settled")
|
||||||
|
|
||||||
|
def test_admin_can_apply_pending_payout_carry_in_to_open_session(self):
|
||||||
|
rows = [
|
||||||
|
event("s1", "2026-01-01", "", "session_open", 0, 1),
|
||||||
|
event("s1", "2026-01-01", "A", "buyin", 10, 2),
|
||||||
|
event("s1", "2026-01-01", "A", "cashout", 20, 3),
|
||||||
|
event("s1", "2026-01-01", "A", "rollover_out", 5, 4),
|
||||||
|
event("s1", "2026-01-01", "", "session_close", 0, 5),
|
||||||
|
event("s2", "2026-01-02", "", "session_open", 0, 6),
|
||||||
|
event("s2", "2026-01-02", "A", "buyin", 10, 7),
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
data_path = Path(tmpdir) / "entries.csv"
|
||||||
|
write_events(data_path, rows)
|
||||||
|
original_path = app_module.DATA_PATH
|
||||||
|
app_module.DATA_PATH = data_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
sessions = build_session_summaries(load_events(data_path))
|
||||||
|
self.assertEqual(
|
||||||
|
app_module.pending_payout_carry_items(sessions),
|
||||||
|
[{"player_name": "A", "amount_cents": cents(5)}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with app_module.app.test_client() as client:
|
||||||
|
with client.session_transaction() as flask_session:
|
||||||
|
flask_session["is_admin"] = True
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/apply-payout-carry-in",
|
||||||
|
data={"session_id": "s2", "player_name": "A"},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app_module.DATA_PATH = original_path
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
updated_sessions = build_session_summaries(load_events(data_path))
|
||||||
|
target = next(s for s in updated_sessions if s.session_id == "s2")
|
||||||
|
target_entry = target.entries[0]
|
||||||
|
self.assertEqual(target_entry.payout_carry_in_cents, cents(5))
|
||||||
|
self.assertEqual(target_entry.invested_cents, cents(10))
|
||||||
|
self.assertEqual(target_entry.current_due_to_player_cents, cents(5))
|
||||||
|
|
||||||
|
def test_same_day_sessions_sort_by_session_sequence(self):
|
||||||
|
rows = [
|
||||||
|
event("2026-03-21-02", "2026-03-21", "A", "buyin", 10, 1),
|
||||||
|
event("2026-03-21-02", "2026-03-21", "A", "cashout", 11, 2),
|
||||||
|
event("2026-03-21-01", "2026-03-21", "A", "buyin", 10, 3),
|
||||||
|
event("2026-03-21-01", "2026-03-21", "A", "cashout", 9, 4),
|
||||||
|
]
|
||||||
|
sessions = build_session_summaries(rows)
|
||||||
|
ordered = sorted(sessions, key=session_sort_key)
|
||||||
|
|
||||||
|
self.assertEqual([s.session_id for s in ordered], ["2026-03-21-01", "2026-03-21-02"])
|
||||||
|
self.assertEqual(
|
||||||
|
player_session_series(sessions, "A")["labels"],
|
||||||
|
["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_prunes_only_empty_marker_sessions(self):
|
||||||
|
rows = [
|
||||||
|
event("empty-1", "2026-01-01", "", "session_open", 0, 1),
|
||||||
|
event("empty-1", "2026-01-01", "", "session_close", 0, 2),
|
||||||
|
event("played-1", "2026-01-02", "", "session_open", 0, 3),
|
||||||
|
event("played-1", "2026-01-02", "A", "buyin", 10, 4),
|
||||||
|
event("played-1", "2026-01-02", "A", "cashout", 15, 5),
|
||||||
|
event("played-1", "2026-01-02", "", "session_close", 0, 6),
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
data_path = Path(tmpdir) / "entries.csv"
|
||||||
|
write_events(data_path, rows)
|
||||||
|
original_path = app_module.DATA_PATH
|
||||||
|
app_module.DATA_PATH = data_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
with app_module.app.test_client() as client:
|
||||||
|
with client.session_transaction() as flask_session:
|
||||||
|
flask_session["is_admin"] = True
|
||||||
|
|
||||||
|
response = client.post("/admin/prune-empty-sessions")
|
||||||
|
finally:
|
||||||
|
app_module.DATA_PATH = original_path
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
remaining = load_events(data_path)
|
||||||
|
self.assertEqual(
|
||||||
|
{row["session_id"] for row in remaining},
|
||||||
|
{"played-1"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[row["event_type"] for row in remaining],
|
||||||
|
["session_open", "buyin", "cashout", "session_close"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user