update admin layout

This commit is contained in:
SowinskiBraeden committed 2026-03-21 01:30:42 -07:00
1 parent 613ffb394c
commit 6c508f4ef3
3 files changed
+247 -86

No files matched your search

+95 -2
View File
@@ -1,10 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import csv
import os import os
import shutil
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from flask import Flask, flash, redirect, render_template, request, session, url_for from flask import (
Flask,
flash,
redirect,
render_template,
request,
send_file,
session,
url_for,
)
from stats import ( from stats import (
apply_rank_changes, apply_rank_changes,
@@ -236,6 +248,87 @@ def admin_session_state() -> str:
return redirect(url_for("admin_dashboard")) return redirect(url_for("admin_dashboard"))
@app.get("/admin/export")
def admin_export_csv():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin_login"))
export_name = (
f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
)
return send_file(
DATA_PATH,
as_attachment=True,
download_name=export_name,
mimetype="text/csv",
)
@app.post("/admin/import")
def admin_import_csv():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin_login"))
uploaded_file = request.files.get("csv_file")
if uploaded_file is None or uploaded_file.filename == "":
flash("Choose a CSV file to import.", "error")
return redirect(url_for("admin_dashboard"))
if not uploaded_file.filename.lower().endswith(".csv"):
flash("Only CSV files are allowed.", "error")
return redirect(url_for("admin_dashboard"))
try:
uploaded_text = uploaded_file.stream.read().decode("utf-8-sig")
except UnicodeDecodeError:
flash("CSV file must be valid UTF-8 text.", "error")
return redirect(url_for("admin_dashboard"))
uploaded_lines = uploaded_text.splitlines()
if not uploaded_lines:
flash("CSV file is empty.", "error")
return redirect(url_for("admin_dashboard"))
try:
current_header = next(
csv.reader(open(DATA_PATH, "r", encoding="utf-8", newline=""))
)
except Exception:
flash("Could not read the current audit CSV header.", "error")
return redirect(url_for("admin_dashboard"))
try:
uploaded_header = next(csv.reader(uploaded_lines))
except Exception:
flash("Could not read the uploaded CSV header.", "error")
return redirect(url_for("admin_dashboard"))
if uploaded_header != current_header:
flash("CSV header does not match the current audit format.", "error")
return redirect(url_for("admin_dashboard"))
data_dir = os.path.dirname(DATA_PATH)
backup_name = (
f"entries_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
)
backup_path = os.path.join(data_dir, backup_name)
try:
shutil.copy2(DATA_PATH, backup_path)
with open(DATA_PATH, "w", encoding="utf-8", newline="") as handle:
handle.write(uploaded_text)
flash(f"CSV imported successfully. Backup created: {backup_name}", "success")
except Exception:
flash("Import failed. Existing CSV was not updated.", "error")
return redirect(url_for("admin_dashboard"))
@app.route("/admin", methods=["GET", "POST"]) @app.route("/admin", methods=["GET", "POST"])
def admin_dashboard() -> str: def admin_dashboard() -> str:
if not is_admin(): if not is_admin():
@@ -286,7 +379,7 @@ def admin_dashboard() -> str:
sessions = build_session_summaries(events) sessions = build_session_summaries(events)
recent_sessions = sessions[:6] recent_sessions = sessions[:6]
open_sessions = [session for session in sessions if session.status == "open"] open_sessions = [session for session in sessions if session.status == "open"]
recent_events = list(reversed(events[-20:])) recent_events = list(reversed(events[-8:]))
return render_template( return render_template(
"admin_dashboard.html", "admin_dashboard.html",
+32
View File
@@ -1000,6 +1000,38 @@ td a:hover {
border-color: rgba(255, 166, 77, 0.35); border-color: rgba(255, 166, 77, 0.35);
} }
.admin-tools-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.import-form input[type="file"] {
width: 100%;
margin-top: 0.5rem;
margin-bottom: 1rem;
color: var(--text-muted);
}
.import-form input[type="file"]::file-selector-button {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: var(--text-strong);
border-radius: 10px;
padding: 0.55rem 0.8rem;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
margin-right: 0.75rem;
cursor: pointer;
}
.import-form input[type="file"]::file-selector-button:hover {
background: rgba(255, 166, 77, 0.12);
border-color: rgba(255, 166, 77, 0.28);
}
@media (max-width: 980px) { @media (max-width: 980px) {
.site-shell { .site-shell {
width: min(100% - 24px, 100%); width: min(100% - 24px, 100%);
+120 -84
View File
@@ -52,101 +52,137 @@
<button class="primary-button" type="submit">Add event</button> <button class="primary-button" type="submit">Add event</button>
</form> </form>
<form class="panel form-card" method="post" action="{{ url_for('admin_session_state') }}"> <section class="grid">
<p class="eyebrow">Session state</p> <form class="panel form-card" method="post" action="{{ url_for('admin_session_state') }}">
<h2>Open or close a session</h2> <p class="eyebrow">Session state</p>
<h2>Open or close a session</h2>
<label> <label>
<span>Session date</span> <span>Session date</span>
<input type="date" name="session_date" required> <input type="date" name="session_date" required>
</label> </label>
<div class="button-row"> <div class="button-row">
<button class="primary-button" type="submit" name="state" value="open">Open session</button> <button class="primary-button" type="submit" name="state" value="open">Open session</button>
<button class="secondary-button" type="submit" name="state" value="closed">Close session</button> <button class="secondary-button" type="submit" name="state" value="closed">Close session</button>
</div> </div>
</form> </form>
<div class="stack-gap"> <section class="panel form-card">
<section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<p class="eyebrow">Recent sessions</p> <p class="eyebrow">Tools</p>
<h2>Current totals</h2> <h2>Audit CSV</h2>
</div> </div>
</div> </div>
<div class="table-wrap small-table">
<table>
<thead>
<tr>
<th>Date</th>
<th>Players</th>
<th>Buy-ins</th>
<th>Cash-outs</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
<tr>
<td>
<a href="{{ url_for('session_detail', session_date=session.session_date) }}">
{{ session.session_date | pretty_date }}
</a>
</td>
<td>{{ session.entries|length }}</td>
<td>{{ session.total_buy_in_cents | money }}</td>
<td>{{ session.total_cash_out_cents | money }}</td>
<td>
<span class="status-badge status-{{ session.status }}">
{{ session.status }}
</span>
</td>
<td>
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
<input type="hidden" name="session_date" value="{{ session.session_date }}">
{% if session.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="4">No sessions yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel"> <div class="admin-tools-grid">
<div class="panel-header"> <div class="helper-box">
<div> <p class="muted-text">
<p class="eyebrow">Recent raw events</p> Export the current audit trail or import a replacement CSV. Import creates a timestamped backup first.
<h2>Ledger feed</h2> </p>
</div> <div class="button-row">
</div> <a class="primary-button" href="{{ url_for('admin_export_csv') }}">Export CSV</a>
<div class="event-feed condensed">
{% for event in recent_events %}
<article class="event-card">
<div class="event-card-top">
<strong>{{ event.player_name }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</span>
</div> </div>
<p class="event-amount">{{ event.amount_cents | money }}</p> </div>
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %} <form method="post" action="{{ url_for('admin_import_csv') }}" enctype="multipart/form-data" class="helper-box import-form">
</article> <label for="csv_file">
{% else %} <span>Import replacement CSV</span>
<p class="muted-text">No recent events.</p> </label>
{% endfor %} <input id="csv_file" name="csv_file" type="file" accept=".csv,text/csv" required>
<div class="button-row">
<button
class="secondary-button"
type="submit"
onclick="return confirm('Replace the current audit CSV? A backup will be created first.');"
>
Import CSV
</button>
</div>
</form>
</div> </div>
</section> </section>
</section>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Recent sessions</p>
<h2>Current totals</h2>
</div>
</div>
<div class="table-wrap small-table">
<table>
<thead>
<tr>
<th>Date</th>
<th>Players</th>
<th>Buy-ins</th>
<th>Cash-outs</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
<tr>
<td>
<a href="{{ url_for('session_detail', session_date=session.session_date) }}">
{{ session.session_date | pretty_date }}
</a>
</td>
<td>{{ session.entries|length }}</td>
<td>{{ session.total_buy_in_cents | money }}</td>
<td>{{ session.total_cash_out_cents | money }}</td>
<td>
<span class="status-badge status-{{ session.status }}">
{{ session.status }}
</span>
</td>
<td>
<form method="post" action="{{ url_for('admin_session_state') }}" class="inline-action-form">
<input type="hidden" name="session_date" value="{{ session.session_date }}">
{% if session.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="4">No sessions yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Recent raw events</p>
<h2>Ledger feed</h2>
</div>
</div>
<div class="event-feed condensed">
{% for event in recent_events %}
<article class="event-card">
<div class="event-card-top">
<strong>{{ event.player_name }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type }}</span>
</div>
<p class="event-amount">{{ event.amount_cents | money }}</p>
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %}
</article>
{% else %}
<p class="muted-text">No recent events.</p>
{% endfor %}
</div> </div>
</section> </section>
{% endblock %} {% endblock %}