437 lines
21 KiB
HTML
437 lines
21 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Admin · myboker.org{% endblock %}
|
|
{% block page_class %}page--admin{% endblock %}
|
|
{% block content %}
|
|
|
|
<div class="hero" style="padding-bottom:12px;">
|
|
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
|
|
<p class="hero__kicker" style="margin:0;">Admin</p>
|
|
{% if open_sessions %}
|
|
<span class="live-badge">{{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live</span>
|
|
{% endif %}
|
|
</div>
|
|
<h1 class="hero__title">Ledger controls</h1>
|
|
<p class="hero__sub">Append events to the audit trail. Player ledger history stays permanent.</p>
|
|
</div>
|
|
|
|
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only
|
|
affect receivables/payables through the session settlement formulas. -->
|
|
<div class="cash-strip">
|
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
|
<span class="kicker">Cash in</span>
|
|
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
|
|
</div>
|
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
|
<span class="kicker">Cash paid out</span>
|
|
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
|
|
</div>
|
|
<div class="stat stat--rail" style="--rail:var(--neg);">
|
|
<span class="kicker">House owes players</span>
|
|
<div class="stat__val {{ 'num-neg' if admin_totals.house_owes_players_cents > 0 else '' }}">{{ admin_totals.house_owes_players_cents | money }}</div>
|
|
</div>
|
|
<div class="stat stat--rail" style="--rail:var(--owes);">
|
|
<span class="kicker">Players owe house</span>
|
|
<div class="stat__val {{ 'num-neg' if admin_totals.players_owe_house_cents > 0 else '' }}">{{ admin_totals.players_owe_house_cents | money }}</div>
|
|
</div>
|
|
<div class="stat stat--rail" style="--rail:{{ 'var(--pos)' if admin_totals.net_book_position_cents == 0 else 'var(--warn)' }};">
|
|
<span class="kicker">Net book position</span>
|
|
<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 }}
|
|
</div>
|
|
<div class="stat__sub">cash in - cash paid out + receivables - payables</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin workspace -->
|
|
<div class="admin-workspace">
|
|
|
|
<form class="panel append" method="post" action="{{ url_for('admin.dashboard') }}">
|
|
<div class="panel__head">
|
|
<h2 class="panel__title">Append ledger event</h2>
|
|
<span class="panel__tag">fast entry</span>
|
|
</div>
|
|
<div class="append__body">
|
|
<div class="append__grid">
|
|
<label class="field">
|
|
<span class="field__label">Session</span>
|
|
<select class="control" name="session_id" required>
|
|
<option value="">Select session</option>
|
|
{% for s in open_sessions %}
|
|
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
|
|
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
|
|
</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>
|
|
<textarea class="control append__textarea" name="note" placeholder="e.g. fronted buy-in, settle next week"></textarea>
|
|
</label>
|
|
<div class="append__footer">
|
|
<button type="submit" class="btn btn--primary">Append event</button>
|
|
<span class="append__preview">Will record as
|
|
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
|
|
</span>
|
|
</div>
|
|
{% if pending_carry_items %}
|
|
<div class="carry-recs">
|
|
<div class="micro">Recommended carry-ins</div>
|
|
{% for item in pending_carry_items %}
|
|
<div class="carry-rec">
|
|
<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>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
|
|
<div class="panel panel--pad session-control-panel">
|
|
<h2 class="panel__title panel__title--mb">Session controls</h2>
|
|
<div class="session-summary-strip">
|
|
<div>
|
|
<span class="micro">Today</span>
|
|
<strong>{{ admin_today }}</strong>
|
|
</div>
|
|
<div>
|
|
<span class="micro">Empty</span>
|
|
<strong>{{ empty_session_count }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
{% if live_session %}
|
|
<div class="livecard">
|
|
<div class="livecard__head">
|
|
<span class="dot dot--live"></span>
|
|
<span class="livecard__name">{{ session_label(live_session) }}</span>
|
|
<span class="livecard__no">{{ live_session_no }}</span>
|
|
</div>
|
|
<div class="livecard__metrics">
|
|
<div><div class="micro">Invested</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') }}"
|
|
{% 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>
|
|
</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 %}
|
|
|
|
<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 -->
|
|
|
|
<!-- 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="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<span class="kicker" style="margin:0;">All sessions</span>
|
|
<h2 class="panel__title">Current totals</h2>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Session</th>
|
|
<th>Players</th>
|
|
<th>Cash in</th>
|
|
<th>Cash paid out</th>
|
|
<th>House owes players</th>
|
|
<th>Players owe house</th>
|
|
<th>Book position</th>
|
|
<th>Status</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for s in recent_sessions %}
|
|
<tr>
|
|
<td><a href="{{ url_for('public.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.total_banker_cash_in_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_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><span class="status-badge status-{{ s.status }}">{{ s.status }}</span></td>
|
|
<td>
|
|
<form method="post" action="{{ url_for('admin.session_state') }}" class="inline-action-form">
|
|
<input type="hidden" name="session_id" value="{{ s.session_id }}">
|
|
{% if s.status == "open" %}
|
|
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
|
|
{% else %}
|
|
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
|
|
{% endif %}
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="9">No sessions yet.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<span class="kicker" style="margin:0;">Recent events</span>
|
|
<h2 class="panel__title">Ledger feed</h2>
|
|
</div>
|
|
<span class="badge badge--closed" style="font-size:9.5px;">append-only</span>
|
|
</div>
|
|
<div class="event-feed feed feed--inline">
|
|
{% for event in recent_events %}
|
|
<div class="event-card event-card--inline">
|
|
<div class="event-card-top">
|
|
<strong>{{ event.player_name or "Session" }}</strong>
|
|
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
|
|
{% if event.note %}<span class="event-note">{{ event.note }}</span>{% endif %}
|
|
</div>
|
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
|
<p class="tiny-text">{{ event.session_date | pretty_date }}</p>
|
|
</div>
|
|
{% else %}
|
|
<p class="muted-text" style="padding:20px;">No recent events.</p>
|
|
{% endfor %}
|
|
</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>
|
|
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
|
const eventTypePreview = document.getElementById('eventTypePreview');
|
|
eventTypeSelect?.addEventListener('change', () => {
|
|
const option = eventTypeSelect.selectedOptions[0];
|
|
if (!option || !eventTypePreview) return;
|
|
eventTypePreview.style.color = option.dataset.color || 'var(--muted)';
|
|
eventTypePreview.textContent = (option.dataset.label || option.value).toUpperCase();
|
|
});
|
|
|
|
const csvDrop = document.getElementById('csvDrop');
|
|
const csvInput = document.getElementById('csvTopImport');
|
|
csvDrop?.addEventListener('dragover', (event) => {
|
|
event.preventDefault();
|
|
csvDrop.classList.add('is-drag');
|
|
});
|
|
csvDrop?.addEventListener('dragleave', () => csvDrop.classList.remove('is-drag'));
|
|
csvDrop?.addEventListener('drop', (event) => {
|
|
event.preventDefault();
|
|
csvDrop.classList.remove('is-drag');
|
|
if (!csvInput || !event.dataTransfer?.files?.length) return;
|
|
csvInput.files = event.dataTransfer.files;
|
|
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>
|
|
|
|
{% endblock %}
|