329 lines
17 KiB
HTML
329 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Admin · boker.club{% 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. All history is 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 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 out + receivables - payables</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Two-column workspace -->
|
|
<div class="admin-workspace">
|
|
|
|
<!-- LEFT: append form + recent sessions -->
|
|
<div class="col-main">
|
|
|
|
<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>
|
|
<input class="control" name="note" placeholder="e.g. fronted buy-in, settle next week">
|
|
</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>
|
|
</div>
|
|
</form>
|
|
|
|
</div><!-- /.col-main -->
|
|
|
|
<!-- RIGHT: session controls + CSV + raw events feed -->
|
|
<div class="col-side">
|
|
|
|
<div class="panel panel--pad">
|
|
<h2 class="panel__title panel__title--mb">Session controls</h2>
|
|
|
|
{% 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">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>
|
|
{% 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>
|
|
</div>
|
|
|
|
<div class="panel panel--pad">
|
|
<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><!-- /.col-side -->
|
|
|
|
</div><!-- /.admin-workspace -->
|
|
|
|
<div class="admin-bottom-grid">
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<span class="kicker" style="margin:0;">Recent 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 out</th>
|
|
<th>House owes</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('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_cash_in_cents | money }}</td>
|
|
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_paid_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>
|
|
|
|
<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();
|
|
});
|
|
</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 %}
|