diff --git a/README.md b/README.md index d09f1f4..f2638ed 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The idea is pretty simple: public pages for stats and session history, plus a sm - admin login for recording events - open / closed session tracking - payout tracking with `paid` events -- front debt write-offs with `front_writeoff` events +- front debt collections and write-offs with `front_collected` / `front_writeoff` events - session and player charts - CSV import / export from the admin page - append-only `entries.csv` ledger instead of overwriting old rows @@ -38,6 +38,7 @@ Current event types: - `buyin` - `front` +- `front_collected` - `front_writeoff` - `cashout` - `paid` @@ -52,6 +53,7 @@ A few examples: - another `buyin` for a rebuy - another `cashout` if chip counts are corrected later - a `paid` event when someone is actually settled up +- a `front_collected` event when a front is repaid outside poker - a `front_writeoff` event when a front will not be collected - a `note` event for bookkeeping context - `session_open` / `session_close` to mark whether a game night is still live diff --git a/app.py b/app.py index b429f71..f77d83d 100644 --- a/app.py +++ b/app.py @@ -438,6 +438,74 @@ def admin_write_off_front() -> str: return redirect(url_for("admin_dashboard")) +@app.post("/admin/collect-front") +def admin_collect_front() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + debt_key = request.form.get("debt_key", "").strip() + note = request.form.get("note", "").strip() + amount_raw = request.form.get("amount", "0").strip() + + try: + amount_cents = int(round(float(amount_raw) * 100)) + except ValueError: + flash("Amount must be a number.", "error") + return redirect(url_for("admin_dashboard")) + + if amount_cents <= 0: + flash("Collected amount must be greater than zero.", "error") + return redirect(url_for("admin_dashboard")) + + try: + session_id, player_name = debt_key.split("||", 1) + except ValueError: + flash("Select a valid player debt.", "error") + return redirect(url_for("admin_dashboard")) + + events = load_events(DATA_PATH) + sessions = build_session_summaries(events) + target = next( + (session for session in sessions if session.session_id == session_id), + None, + ) + + if target is None: + flash("Session not found.", "error") + return redirect(url_for("admin_dashboard")) + + entry = next( + (entry for entry in target.entries if entry.player_name == player_name), + None, + ) + + if entry is None or entry.player_owes_cents <= 0: + flash("That player does not have an outstanding front debt.", "error") + return redirect(url_for("admin_dashboard")) + + if amount_cents > entry.player_owes_cents: + flash( + f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", + "error", + ) + return redirect(url_for("admin_dashboard")) + + append_event( + DATA_PATH, + session_id=target.session_id, + session_date=target.session_date, + player_name=entry.player_name, + event_type="front_collected", + amount_cents=amount_cents, + note=note or "Front debt collected.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash("Front debt collected.", "success") + return redirect(url_for("admin_dashboard")) + + @app.post("/admin/open-session") def admin_open_session() -> str: if not is_admin(): @@ -537,6 +605,9 @@ def admin_dashboard(): "players_owe_cents": sum( session.total_player_owes_cents for session in sessions ), + "collected_cents": sum( + session.total_front_collected_cents for session in sessions + ), "written_off_cents": sum( session.total_front_writeoff_cents for session in sessions ), diff --git a/static/css/style.css b/static/css/style.css index 3873a15..3890f2e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -294,6 +294,14 @@ h2 { line-height: 1.15; } +h3 { + position: relative; + z-index: 1; + margin: 0; + font-size: 1.05rem; + line-height: 1.2; +} + .muted-text { margin: 0; color: var(--text-muted); @@ -383,6 +391,10 @@ h2 { min-height: 112px; } +.compact-form textarea { + min-height: 92px; +} + .form-card input:focus, .form-card select:focus, .form-card textarea:focus { @@ -394,6 +406,14 @@ h2 { align-self: start; } +.admin-layout { + grid-template-columns: minmax(420px, 1.15fr) minmax(360px, 0.85fr); +} + +.admin-layout > .grid { + gap: 18px; +} + .panel-header { position: relative; z-index: 1; @@ -763,11 +783,14 @@ td a:hover { } .leaderboard-layout, -.session-layout, -.admin-layout { +.session-layout { align-items: stretch; } +.admin-layout { + align-items: start; +} + .results-panel-full { display: block; } @@ -958,6 +981,12 @@ td a:hover { border: 1px solid rgba(251, 146, 60, 0.22); } +.pill-front_collected { + color: #22c55e; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.22); +} + .pill-front_writeoff { color: #f87171; background: rgba(248, 113, 113, 0.12); @@ -995,6 +1024,12 @@ td a:hover { border: 1px solid rgba(248, 113, 113, 0.24); } +.status-collected { + color: #22c55e; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.24); +} + .inline-action-form { margin: 0; } @@ -1038,6 +1073,39 @@ td a:hover { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.debt-panel { + display: grid; + gap: 18px; +} + +.debt-summary { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.debt-summary span { + display: inline-flex; + min-height: 34px; + align-items: center; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 12px; + color: var(--text-muted); + font-size: 0.86rem; + font-weight: 700; +} + +.debt-action-grid { + position: relative; + z-index: 1; + display: grid; + gap: 18px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .import-form input[type="file"] { width: 100%; margin-top: 0.5rem; @@ -1088,20 +1156,34 @@ td a:hover { .admin-cash-grid { display: grid; gap: 1rem; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(150px, 1fr)); margin-bottom: 1rem; } -@media (max-width: 1200px) { +@media (max-width: 1180px) { .admin-cash-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 1180px) { + .profile-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 760px) { .admin-cash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .debt-action-grid { + grid-template-columns: 1fr; + } + + .debt-summary { + justify-content: flex-start; + } } @media (max-width: 520px) { @@ -1135,6 +1217,7 @@ td a:hover { .two-col, .three-col, + .four-col, .leaderboard-layout, .session-layout, .admin-layout { diff --git a/stats.py b/stats.py index 29082a4..8ae8c63 100644 --- a/stats.py +++ b/stats.py @@ -31,6 +31,7 @@ class SessionEntry: player_name: str buy_in_cents: int = 0 front_cents: int = 0 + front_collected_cents: int = 0 front_writeoff_cents: int = 0 cash_out_cents: int = 0 paid_cents: int = 0 @@ -68,7 +69,7 @@ class SessionEntry: @property def player_owes_cents(self) -> int: - return max(self.raw_player_owes_cents - self.front_writeoff_applied_cents, 0) + return max(self.raw_player_owes_cents - self.front_resolved_cents, 0) @property def raw_player_owes_cents(self) -> int: @@ -84,7 +85,18 @@ class SessionEntry: @property def front_writeoff_applied_cents(self) -> int: - return min(self.front_writeoff_cents, self.raw_player_owes_cents) + return min( + self.front_writeoff_cents, + max(self.raw_player_owes_cents - self.front_collected_applied_cents, 0), + ) + + @property + def front_collected_applied_cents(self) -> int: + return min(self.front_collected_cents, self.raw_player_owes_cents) + + @property + def front_resolved_cents(self) -> int: + return self.front_collected_applied_cents + self.front_writeoff_applied_cents @property def payout_status(self) -> str: @@ -92,6 +104,8 @@ class SessionEntry: return "owes" if self.front_writeoff_applied_cents > 0: return "written_off" + if self.front_collected_applied_cents > 0: + return "collected" if self.gross_payout_cents <= 0: return "none" if self.current_due_cents <= 0: @@ -169,6 +183,10 @@ class SessionSummary: def total_front_writeoff_cents(self) -> int: return sum(entry.front_writeoff_applied_cents for entry in self.entries) + @property + def total_front_collected_cents(self) -> int: + return sum(entry.front_collected_applied_cents for entry in self.entries) + @property def total_cash_in_cents(self) -> int: return sum(entry.buy_in_cents for entry in self.entries) @@ -196,6 +214,7 @@ class PlayerStats: biggest_loss_cents: int total_buy_in_cents: int total_front_cents: int + total_front_collected_cents: int total_front_writeoff_cents: int current_player_owes_cents: int total_rollover_in_cents: int @@ -347,6 +366,8 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.buy_in_cents += event["amount_cents"] elif event_type == "front": entry.front_cents += event["amount_cents"] + elif event_type == "front_collected": + entry.front_collected_cents += event["amount_cents"] elif event_type == "front_writeoff": entry.front_writeoff_cents += event["amount_cents"] elif event_type == "rollover_in": @@ -481,6 +502,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_buy_in = sum(entry.buy_in_cents for entry in entries) total_front = sum(entry.front_cents for entry in entries) + total_front_collected = sum( + entry.front_collected_applied_cents for entry in entries + ) total_front_writeoff = sum( entry.front_writeoff_applied_cents for entry in entries ) @@ -507,6 +531,7 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: biggest_loss_cents=biggest_loss, total_buy_in_cents=total_buy_in, total_front_cents=total_front, + total_front_collected_cents=total_front_collected, total_front_writeoff_cents=total_front_writeoff, current_player_owes_cents=current_player_owes, total_rollover_in_cents=total_rollover_in, diff --git a/storage.py b/storage.py index e250670..e891c27 100644 --- a/storage.py +++ b/storage.py @@ -35,6 +35,7 @@ class EventRow(TypedDict): VALID_EVENT_TYPES = { "buyin", "front", + "front_collected", "front_writeoff", "cashout", "paid", diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index fca5e65..f4decf8 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -113,9 +113,58 @@ -
- + + {% else %} +No outstanding front debt.
+ {% endif %}| Total Net | {{ player.total_net_cents | money }} |
|---|---|
| Win % | {{ "%.1f"|format(player.win_pct) }}% |
| ROI | {{ "%.1f"|format(player.roi_pct) }}% |
| Sessions | {{ player.sessions_played }} |
| Paid Buy-ins | {{ player.total_buy_in_cents | money }} |
| Fronted | {{ player.total_front_cents | money }} |
| Debt Collected | {{ player.total_front_collected_cents | money }} |
| Current Debt | {{ player.current_player_owes_cents | money }} |
| Written Off | {{ player.total_front_writeoff_cents | money }} |
| Rolled In | {{ player.total_rollover_in_cents | money }} |
| Total Invested | {{ player.total_invested_cents | money }} |
| Cash-outs | {{ player.total_cash_out_cents | money }} |
| Rolled Out | {{ player.total_rollover_out_cents | money }} |
| Winning Sessions | {{ player.winning_sessions }} |
| Losing Sessions | {{ player.losing_sessions }} | Gross due | Paid | Rolled out | +Collected | Written off | Still owed | Status | @@ -128,13 +129,16 @@{{ entry.paid_cents | money }} | {{ entry.rollover_out_cents | money }} | +{{ entry.front_collected_applied_cents | money }} | {{ entry.front_writeoff_applied_cents | money }} | -+ | {% if entry.player_owes_cents > 0 %} Owes {{ entry.player_owes_cents | money }} {% elif entry.front_writeoff_applied_cents > 0 %} Written off {{ entry.front_writeoff_applied_cents | money }} + {% elif entry.front_collected_applied_cents > 0 %} + Collected {{ entry.front_collected_applied_cents | money }} {% else %} {{ entry.current_due_cents | money }} {% endif %} |