Compare commits

1 Commits

Author SHA1 Message Date
Ben Mosley
716e0c1fac Bo Nix better win the damn super bowl dude he gives me anxiety every time I watch him 2025-11-30 22:36:19 -06:00
16 changed files with 1802 additions and 0 deletions

666
app.py Normal file
View File

@@ -0,0 +1,666 @@
from __future__ import annotations
import os
from datetime import datetime
from flask import Flask, render_template, url_for, jsonify, Response, redirect
from requests import requests
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY")
def render_page(body_tpl: str, **ctx):
title = ctx.get("title") or APP_BRAND
return render_template ("base.html")
# =============================================================================
# Routes: Auth + Dashboard
# =============================================================================
@app.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("dashboard"))
return redirect(url_for("login"))
@limiter.limit("5/minute")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
user = User.query.filter_by(username=username).first()
if not user or not user.is_active or not user.check_password(password):
flash("Invalid credentials.", "error")
return redirect(url_for("login"))
login_user(user, remember=True)
if user.must_change_password:
flash("Please set a new password to continue.", "ok")
return redirect(url_for("change_password"))
return redirect(url_for("dashboard"))
return render_template ("tpl_login_body.html", title="Login")
@app.post("/logout")
@login_required
def logout():
logout_user()
flash("Signed out.", "ok")
return redirect(url_for("login"))
@app.route("/dashboard")
@login_required
def dashboard():
return render_template ("tpl_dashboard_body.html", title="Dashboard", user=current_user)
@app.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password():
if request.method == "POST":
curr = request.form.get("current_password") or ""
new1 = request.form.get("new_password") or ""
new2 = request.form.get("confirm_password") or ""
if not current_user.check_password(curr):
flash("Current password is incorrect.", "error")
return redirect(url_for("change_password"))
if len(new1) < 10:
flash("Choose a stronger password (min 10 chars).", "error")
return redirect(url_for("change_password"))
if new1 != new2:
flash("Passwords do not match.", "error")
return redirect(url_for("change_password"))
current_user.set_password(new1)
current_user.must_change_password = False
db.session.commit()
flash("Password updated.", "ok")
return redirect(url_for("dashboard"))
return render_template ("tpl_change_password.html", title="Change Password", user=current_user)
# =============================================================================
# Secret Santa
# =============================================================================
@app.route("/secret-santa", methods=["GET", "POST"])
@login_required
def secret_santa():
entry = SecretSantaEntry.query.filter_by(user_id=current_user.id).first()
if request.method == "POST":
full_name = (request.form.get("full_name") or "").strip()
if not full_name:
flash("Name is required.", "error")
return redirect(url_for("secret_santa"))
age_raw = request.form.get("age")
birthday = (request.form.get("birthday") or "").strip()
hobbies = (request.form.get("hobbies") or "").strip()
gift_card = (request.form.get("gift_card") or "").strip()
fav_movie = (request.form.get("fav_movie") or "").strip()
jewelry = request.form.get("jewelry") == "yes"
try:
age = int(age_raw) if (age_raw or "").strip() else None
except ValueError:
age = None
if entry:
entry.full_name = full_name
entry.age = age
entry.birthday = birthday or None
entry.hobbies = hobbies
entry.gift_card = gift_card
entry.fav_movie = fav_movie
entry.jewelry = jewelry
else:
entry = SecretSantaEntry(
user_id=current_user.id,
full_name=full_name, age=age, birthday=birthday or None,
hobbies=hobbies, gift_card=gift_card, fav_movie=fav_movie,
jewelry=jewelry,
)
db.session.add(entry)
db.session.commit()
flash("Secret Santa saved.", "ok")
return redirect(url_for("secret_santa"))
form = {
"full_name": entry.full_name if entry else current_user.username,
"age": entry.age if entry else "",
"birthday": entry.birthday if entry else "",
"hobbies": entry.hobbies if entry else "",
"gift_card": entry.gift_card if entry else "",
"fav_movie": entry.fav_movie if entry else "",
"jewelry": bool(entry.jewelry) if (entry and entry.jewelry is not None) else False,
}
return render_template ("tpl_secret_santa.html", title="Secret Santa", form=form)
@app.get("/admin/secret-santa")
@admin_required
def admin_secret_santa():
rows = (SecretSantaEntry.query
.join(User, SecretSantaEntry.user_id == User.id)
.order_by(User.username.asc())
.all())
return render_template ("admin_secret_santa.html", title="Secret Santa", rows=rows)
# =============================================================================
# Request Off
# =============================================================================
@app.get("/request-off")
@login_required
def request_off():
my_reqs = (TimeOffRequest.query
.filter_by(user_id=current_user.id)
.order_by(TimeOffRequest.created_at.desc())
.all())
return render_template ("tpl_request_off_body.html", title="Request Off", my_reqs=my_reqs)
@app.post("/requests/new")
@login_required
def requests_new():
date = (request.form.get("date") or "").strip()
note = (request.form.get("note") or "").strip()[:240]
if not date or not _validate_iso_date(date):
flash("Please choose a valid date (YYYY-MM-DD).", "error")
return redirect(url_for("request_off"))
r = TimeOffRequest(user_id=current_user.id, date=date, note=note, status="pending")
db.session.add(r); db.session.commit()
flash("Request submitted.", "ok")
return redirect(url_for("request_off"))
@app.post("/requests/<int:req_id>/cancel")
@login_required
def requests_cancel(req_id: int):
r = db.session.get(TimeOffRequest, req_id)
if not r or r.user_id != current_user.id:
flash("Not found.", "error"); return redirect(url_for("request_off"))
if r.status != "pending":
flash("Only pending requests can be cancelled.", "error"); return redirect(url_for("request_off"))
r.status = "cancelled"; r.decided_at = datetime.utcnow(); db.session.commit()
flash("Request cancelled.", "ok"); return redirect(url_for("request_off"))
# =============================================================================
# Availability (member self-service)
# =============================================================================
@app.route("/availability", methods=["GET", "POST"])
@login_required
def availability():
aw = AvailabilityWeekly.query.filter_by(user_id=current_user.id).first()
if request.method == "POST":
week = _parse_week_from_form(request.form)
if not aw:
aw = AvailabilityWeekly(user_id=current_user.id, data_json=_json.dumps(week))
db.session.add(aw)
else:
aw.data_json = _json.dumps(week)
db.session.commit()
flash("Availability saved.", "ok")
return redirect(url_for("availability"))
week = _default_week()
if aw and aw.data_json:
try:
data = _json.loads(aw.data_json) or {}
for k,_ in DAY_NAMES:
if k in data:
week[k] = {
"avail": bool(data[k].get("avail")),
"start": (data[k].get("start") or "08:00")[:5],
"end": (data[k].get("end") or "17:00")[:5],
}
except Exception as e:
app.logger.warning("Failed to parse availability JSON for user %s: %s", current_user.id, e)
return render_template ("tpl_avail.html", title="Your Availability",
day_names=DAY_NAMES, week=week)
# =============================================================================
# Admin: Availability grid + EDITOR (NEW)
# =============================================================================
@app.get("/admin/availability")
@admin_required
def admin_availability():
rows = []
users = User.query.filter_by(is_active=True).order_by(User.username.asc()).all()
for u in users:
aw = AvailabilityWeekly.query.filter_by(user_id=u.id).first()
wk = _default_week()
if aw and aw.data_json:
try:
data = _json.loads(aw.data_json) or {}
for k,_ in DAY_NAMES:
if k in data:
wk[k] = {
"avail": bool(data[k].get("avail")),
"start": (data[k].get("start") or "08:00")[:5],
"end": (data[k].get("end") or "17:00")[:5],
}
except Exception as e:
app.logger.warning("Failed to parse availability JSON for user %s: %s", u.id, e)
rows.append({"user_id": u.id, "username": u.username, "week": wk})
return render_template ("admin_avail.html", title="Team Availability", rows=rows, day_names=DAY_NAMES)
@app.route("/admin/availability/edit", methods=["GET", "POST"])
@admin_required
def admin_availability_edit():
# Save for a specific user
if request.method == "POST":
try:
target_id = int(request.form.get("user_id") or "0")
except ValueError:
flash("Invalid user.", "error")
return redirect(url_for("admin_availability_edit"))
target = db.session.get(User, target_id)
if not target:
flash("User not found.", "error")
return redirect(url_for("admin_availability_edit"))
week = _parse_week_from_form(request.form)
aw = AvailabilityWeekly.query.filter_by(user_id=target.id).first()
if not aw:
aw = AvailabilityWeekly(user_id=target.id, data_json=_json.dumps(week))
db.session.add(aw)
else:
aw.data_json = _json.dumps(week)
db.session.commit()
flash(f"Availability saved for {target.username}.", "ok")
return redirect(url_for("admin_availability_edit", user_id=target.id))
# GET: pick a user then load their week
users = User.query.filter_by(is_active=True).order_by(User.username.asc()).all()
user_id = request.args.get("user_id", type=int)
user = db.session.get(User, user_id) if user_id else None
week = _default_week()
if user:
aw = AvailabilityWeekly.query.filter_by(user_id=user.id).first()
if aw and aw.data_json:
try:
data = _json.loads(aw.data_json) or {}
for k,_ in DAY_NAMES:
if k in data:
week[k] = {
"avail": bool(data[k].get("avail")),
"start": (data[k].get("start") or "08:00")[:5],
"end": (data[k].get("end") or "17:00")[:5],
}
except Exception as e:
app.logger.warning("Admin edit: bad JSON for user %s: %s", user.id, e)
return render_template ("admin_availability.html",
title="Edit Availability",
users=users, user=user, day_names=DAY_NAMES, week=week)
# =============================================================================
# Admin: Requests + Wishlists
# =============================================================================
@app.get("/admin/requests")
@admin_required
def admin_requests():
rows = (TimeOffRequest.query
.join(User, TimeOffRequest.user_id == User.id)
.order_by(TimeOffRequest.status.asc(), TimeOffRequest.created_at.desc())
.all())
return render_page(TPL_ADMIN_REQS_BODY, title="Time-off Requests", rows=rows)
@app.post("/admin/requests/<int:req_id>/<action>")
@admin_required
def admin_requests_action(req_id: int, action: str):
r = db.session.get(TimeOffRequest, req_id)
if not r:
flash("Request not found.", "error"); return redirect(url_for("admin_requests"))
if r.status != "pending":
flash("Request is already decided.", "error"); return redirect(url_for("admin_requests"))
if action not in ("approve","deny"):
flash("Invalid action.", "error"); return redirect(url_for("admin_requests"))
r.status = "approved" if action == "approve" else "denied"
r.decided_at = datetime.utcnow()
db.session.commit()
flash(f"Request {action}d.", "ok"); return redirect(url_for("admin_requests"))
@app.get("/admin/wishlists")
@admin_required
def admin_wishlists():
rows = (Wishlist.query
.join(User, Wishlist.user_id == User.id)
.order_by(User.username.asc())
.all())
return render_page(TPL_ADMIN_WISHLISTS_BODY, title="Wishlists", rows=rows)
# =============================================================================
# Exports
# =============================================================================
@app.get("/admin/secret-santa/export.csv")
@admin_required
def admin_secret_santa_export():
rows = (SecretSantaEntry.query.join(User).order_by(User.username.asc()).all())
sio = StringIO()
w = csv.writer(sio)
w.writerow(["username","full_name","age","birthday","hobbies","gift_card","fav_movie","jewelry","updated_at"])
for e in rows:
w.writerow([
e.user.username, e.full_name, e.age or "", e.birthday or "", e.hobbies or "",
e.gift_card or "", e.fav_movie or "", "Yes" if e.jewelry else "No",
e.updated_at.isoformat() if e.updated_at else ""
])
resp = make_response(sio.getvalue())
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
resp.headers["Content-Disposition"] = "attachment; filename=secret_santa.csv"
return resp
@app.get("/admin/requests/export.csv")
@admin_required
def admin_requests_export():
rows = TimeOffRequest.query.join(User).order_by(TimeOffRequest.created_at.desc()).all()
sio = StringIO()
w = csv.writer(sio)
w.writerow(["username","date","status","note","created_at","decided_at"])
for r in rows:
w.writerow([
r.user.username, r.date, r.status, r.note or "",
r.created_at.isoformat(),
r.decided_at.isoformat() if r.decided_at else ""
])
resp = make_response(sio.getvalue())
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
resp.headers["Content-Disposition"] = "attachment; filename=time_off_requests.csv"
return resp
# =============================================================================
# Admin: Users CRUD
# =============================================================================
@app.get("/admin/users")
@admin_required
def admin_users():
users = User.query.order_by(User.created_at.desc()).all()
return render_page(TPL_ADMIN_USERS_BODY, title="Users", users=users)
@app.post("/admin/users/create")
@admin_required
def admin_users_create():
username = (request.form.get("username") or "").strip()
role = (request.form.get("role") or "member").strip().lower()
role = "admin" if role == "admin" else "member"
if not username:
flash("Username is required.", "error")
return redirect(url_for("admin_users"))
if User.query.filter_by(username=username).first():
flash("Username already exists.", "error")
return redirect(url_for("admin_users"))
temp_pw = gen_temp_password()
u = User(username=username, role=role, is_active=True, must_change_password=True)
u.set_password(temp_pw)
db.session.add(u)
db.session.commit()
flash(f"User '{username}' created with temporary password: {temp_pw}", "ok")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/reset")
@admin_required
def admin_users_reset(user_id: int):
u = db.session.get(User, user_id)
if not u:
flash("User not found.", "error")
return redirect(url_for("admin_users"))
temp_pw = gen_temp_password()
u.set_password(temp_pw)
u.must_change_password = True
db.session.commit()
flash(f"Password reset for '{u.username}'. New temp: {temp_pw}", "ok")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/role")
@admin_required
def admin_users_role(user_id: int):
u = db.session.get(User, user_id)
if not u:
flash("User not found.", "error")
return redirect(url_for("admin_users"))
new_role = (request.form.get("role") or "member").lower()
u.role = "admin" if new_role == "admin" else "member"
db.session.commit()
flash(f"Role updated for '{u.username}'{u.role}", "ok")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/toggle")
@admin_required
def admin_users_toggle(user_id: int):
u = db.session.get(User, user_id)
if not u:
flash("User not found.", "error")
return redirect(url_for("admin_users"))
# Prevent locking yourself out of the last admin
if u.id == current_user.id and u.role == "admin":
admins = User.query.filter_by(role="admin", is_active=True).count()
if admins <= 1:
flash("You are the last active admin; cannot deactivate.", "error")
return redirect(url_for("admin_users"))
u.is_active = not u.is_active
db.session.commit()
flash(f"User '{u.username}' active={u.is_active}", "ok")
return redirect(url_for("admin_users"))
# =============================================================================
# JSON API + Health
# =============================================================================
@app.get("/api/me")
@login_required
def api_me():
u: User = current_user # type: ignore
return jsonify(
id=u.id, username=u.username, role=u.role,
must_change_password=u.must_change_password, is_active=u.is_active
)
# ---------------------------
# Info: user view
# ---------------------------
@app.get("/info")
@login_required
def info_page():
contacts = (InfoContact.query
.filter_by(is_active=True)
.order_by(InfoContact.priority.asc(), InfoContact.name.asc())
.all())
exts = (DeptExtension.query
.filter_by(is_active=True)
.order_by(DeptExtension.ext.asc())
.all())
supports = (SupportItem.query
.filter_by(is_active=True)
.filter((SupportItem.audience == "all") | (SupportItem.audience == ("admin" if current_user.is_admin() else "zzz")))
.order_by(SupportItem.category.asc())
.all())
admin_secrets = []
if current_user.is_admin():
admin_secrets = (LocalSecret.query
.filter_by(is_active=True)
.order_by(LocalSecret.updated_at.desc())
.all())
return render_page(TPL_INFO_PAGE, title="Store Info",
contacts=contacts, exts=exts, supports=supports, admin_secrets=admin_secrets)
# ---------------------------
# Info: admin console
# ---------------------------
@app.get("/admin/info")
@admin_required
def admin_info_console():
contacts = InfoContact.query.order_by(InfoContact.priority.asc(), InfoContact.name.asc()).all()
exts = DeptExtension.query.order_by(DeptExtension.ext.asc()).all()
supports = SupportItem.query.order_by(SupportItem.category.asc()).all()
secrets = LocalSecret.query.order_by(LocalSecret.updated_at.desc()).all()
return render_page(TPL_ADMIN_INFO, title="Info Admin",
contacts=contacts, exts=exts, supports=supports, secrets=secrets)
# ---- Contacts CRUD
@app.post("/admin/info/contacts/create")
@admin_required
def admin_info_contacts_create():
name = (request.form.get("name") or "").strip()
role = (request.form.get("role") or "").strip()
phone = (request.form.get("phone") or "").strip()
priority = request.form.get("priority", type=int) or 5
if not name:
flash("Name is required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(InfoContact(name=name, role=role, phone=phone, priority=max(1, min(priority, 9))))
db.session.commit()
flash("Contact added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/contacts/<int:cid>/toggle")
@admin_required
def admin_info_contacts_toggle(cid:int):
c = db.session.get(InfoContact, cid)
if not c: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
c.is_active = not c.is_active; db.session.commit()
flash("Contact updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/contacts/<int:cid>/delete")
@admin_required
def admin_info_contacts_delete(cid:int):
c = db.session.get(InfoContact, cid)
if not c: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(c); db.session.commit()
flash("Contact deleted.", "ok"); return redirect(url_for("admin_info_console"))
# ---- Dept Extensions CRUD
@app.post("/admin/info/exts/create")
@admin_required
def admin_info_exts_create():
ext = (request.form.get("ext") or "").strip()
dept = (request.form.get("dept") or "").strip()
if not ext or not dept:
flash("Extension and department are required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(DeptExtension(ext=ext, dept=dept))
db.session.commit()
flash("Extension added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/exts/<int:did>/toggle")
@admin_required
def admin_info_exts_toggle(did:int):
d = db.session.get(DeptExtension, did)
if not d: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
d.is_active = not d.is_active; db.session.commit()
flash("Extension updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/exts/<int:did>/delete")
@admin_required
def admin_info_exts_delete(did:int):
d = db.session.get(DeptExtension, did)
if not d: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(d); db.session.commit()
flash("Extension deleted.", "ok"); return redirect(url_for("admin_info_console"))
# ---- Support Items CRUD
@app.post("/admin/info/support/create")
@admin_required
def admin_info_support_create():
cat = (request.form.get("category") or "").strip()
email = (request.form.get("email") or "").strip()
phone = (request.form.get("phone") or "").strip()
note = (request.form.get("note") or "").strip()
admin_only = request.form.get("admin_only") == "on"
issues_text = (request.form.get("issues") or "").strip()
issues = [ln.strip() for ln in issues_text.splitlines() if ln.strip()]
if not cat:
flash("Category is required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(SupportItem(category=cat, email=email, phone=phone, note=note,
issues_json=_json.dumps(issues), audience=("admin" if admin_only else "all")))
db.session.commit()
flash("Support item added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/support/<int:sid>/toggle")
@admin_required
def admin_info_support_toggle(sid:int):
s = db.session.get(SupportItem, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
s.is_active = not s.is_active; db.session.commit()
flash("Support item updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/support/<int:sid>/delete")
@admin_required
def admin_info_support_delete(sid:int):
s = db.session.get(SupportItem, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(s); db.session.commit()
flash("Support item deleted.", "ok"); return redirect(url_for("admin_info_console"))
# ---- Secrets CRUD (admin-only)
@app.post("/admin/info/secret/create")
@admin_required
def admin_info_secret_create():
label = (request.form.get("label") or "").strip()
value = (request.form.get("value") or "").strip()
notes = (request.form.get("notes") or "").strip()
if not label or not value:
flash("Label and value are required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(LocalSecret(label=label, value=value, notes=notes))
db.session.commit()
flash("Secret added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/secret/<int:sid>/toggle")
@admin_required
def admin_info_secret_toggle(sid:int):
s = db.session.get(LocalSecret, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
s.is_active = not s.is_active; db.session.commit()
flash("Secret updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/secret/<int:sid>/delete")
@admin_required
def admin_info_secret_delete(sid:int):
s = db.session.get(LocalSecret, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(s); db.session.commit()
flash("Secret deleted.", "ok"); return redirect(url_for("admin_info_console"))
@app.get("/healthz")
def healthz():
return {"ok": True}, 200
# =============================================================================
# Error handlers
# =============================================================================
@app.errorhandler(403)
def error_403(e):
return render_page("<section><h1 class='text-xl font-bold'>Forbidden</h1><p class='text-slate-300 mt-2'>You don't have access to this resource.</p></section>"), 403
@app.errorhandler(404)
def error_404(e):
return render_page("<section><h1 class='text-xl font-bold'>Not Found</h1><p class='text-slate-300 mt-2'>We couldn't find what you were looking for.</p></section>"), 404
@app.errorhandler(500)
def error_500(e):
return render_page("<section><h1 class='text-xl font-bold'>Server Error</h1><p class='text-slate-300 mt-2'>Something went wrong. Please try again.</p></section>"), 500
# =============================================================================
# Main
# =============================================================================
if __name__ == "__main__":
app.run(debug=True, port=5000)

View File

@@ -0,0 +1,39 @@
<section class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold mb-3">Team Availability (Weekly)</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_availability') }}">Refresh</a>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 text-left">User</th>
{% for _k,lab in day_names %}<th class="py-2 text-left">{{ lab }}</th>{% endfor %}
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for row in rows %}
<tr>
<td class="py-2 font-semibold">
<div class="flex items-center gap-2">
<span>{{ row.username }}</span>
<a href="{{ url_for('admin_availability_edit', user_id=row.user_id) }}"
class="text-xs underline text-slate-400 hover:text-slate-200">✎ Edit</a>
</div>
</td>
{% for k,_lab in day_names %}
{% set d = row.week.get(k) %}
<td class="py-2">
{% if d and d.avail %}
<span class="inline-flex items-center gap-1 rounded-md border border-emerald-700 bg-emerald-500/15 px-2 py-0.5 text-emerald-200">
{{ d.start }}{{ d.end }}
</span>
{% else %}
<span class="text-slate-500"></span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</section>

View File

@@ -0,0 +1,75 @@
<section class="grid gap-6">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold">Edit User Availability</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_availability') }}">Back to Team Grid</a>
</div>
<!-- User picker -->
<form method="get" class="flex items-center gap-2 max-w-xl">
<label class="text-sm text-slate-300">User</label>
<select name="user_id" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 w-full">
<option value="">— Select a user —</option>
{% for u in users %}
<option value="{{ u.id }}" {{ 'selected' if (user and user.id==u.id) else '' }}>
{{ u.username }}
</option>
{% endfor %}
</select>
<button class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500">Load</button>
</form>
{% if user %}
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold mb-3">Weekly Pattern for: <span class="font-mono">{{ user.username }}</span></h2>
<form method="post" class="overflow-x-auto">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ user.id }}">
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-4 text-left font-medium">Day</th>
<th class="py-2 px-2 text-left font-medium">Available</th>
<th class="py-2 px-2 text-left font-medium">Start</th>
<th class="py-2 px-2 text-left font-medium">End</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for key,label in day_names %}
<tr>
<td class="py-2 pr-4"><strong>{{ label }}</strong></td>
<td class="py-2 px-2"><input type="checkbox" name="{{ key }}_avail" {{ 'checked' if week[key].avail else '' }}></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_start" value="{{ week[key].start }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_end" value="{{ week[key].end }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-4 flex flex-wrap gap-2">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Save for {{ user.username }}</button>
<button type="button" onclick="fillAll('06:00','19:00')" class="px-4 py-2 rounded-lg border border-slate-700 hover:border-slate-500">Quick-fill 06:0019:00</button>
<button type="button" onclick="setAllOff()" class="px-4 py-2 rounded-lg border border-slate-700 hover:border-slate-500">Set All Off</button>
</div>
</form>
</div>
<script>
function fillAll(s,e){
const days = ['mon','tue','wed','thu','fri','sat','sun'];
days.forEach(d=>{
document.querySelector(`input[name="${d}_avail"]`).checked = true;
document.querySelector(`input[name="${d}_start"]`).value = s;
document.querySelector(`input[name="${d}_end"]`).value = e;
});
}
function setAllOff(){
const days = ['mon','tue','wed','thu','fri','sat','sun'];
days.forEach(d=>{
document.querySelector(`input[name="${d}_avail"]`).checked = false;
document.querySelector(`input[name="${d}_start"]`).value = '08:00';
document.querySelector(`input[name="${d}_end"]`).value = '17:00';
});
}
</script>
{% endif %}
</section>

148
templates/admin_info.html Normal file
View File

@@ -0,0 +1,148 @@
<section class="grid gap-8">
<header class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Info Admin Console</h1>
<a href="{{ url_for('info_page') }}" class="text-sm underline">Back to Info</a>
</header>
<!-- Contacts -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Contacts</h2>
<form method="post" action="{{ url_for('admin_info_contacts_create') }}" class="mt-3 grid gap-2 sm:grid-cols-[1fr,1fr,160px,100px,100px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="name" placeholder="Name" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" required>
<input name="role" placeholder="Role" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="phone" placeholder="Phone" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="priority" type="number" min="1" max="9" value="5" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button>
</form>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-slate-300"><tr><th class="py-2 text-left px-2">Name</th><th class="py-2 text-left px-2">Role</th><th class="py-2 text-left px-2">Phone</th><th class="py-2 text-left px-2">Priority</th><th class="py-2 text-left px-2">Active</th><th class="py-2 text-left px-2">Action</th></tr></thead>
<tbody class="divide-y divide-slate-800">
{% for c in contacts %}
<tr>
<td class="py-2 px-2">{{ c.name }}</td>
<td class="py-2 px-2">{{ c.role or '' }}</td>
<td class="py-2 px-2">{{ c.phone or '' }}</td>
<td class="py-2 px-2">{{ c.priority }}</td>
<td class="py-2 px-2">{{ 'yes' if c.is_active else 'no' }}</td>
<td class="py-2 px-2">
<form method="post" action="{{ url_for('admin_info_contacts_toggle', cid=c.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if c.is_active else 'Activate' }}</button>
</form>
<form method="post" action="{{ url_for('admin_info_contacts_delete', cid=c.id) }}" class="inline" onsubmit="return confirm('Delete contact?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Department Extensions -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Department Extensions</h2>
<form method="post" action="{{ url_for('admin_info_exts_create') }}" class="mt-3 grid gap-2 sm:grid-cols-[180px,1fr,120px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="ext" placeholder="532000" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" required>
<input name="dept" placeholder="Service Counter" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" required>
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button>
</form>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-slate-300"><tr><th class="py-2 text-left px-2">Ext</th><th class="py-2 text-left px-2">Department</th><th class="py-2 text-left px-2">Active</th><th class="py-2 text-left px-2">Action</th></tr></thead>
<tbody class="divide-y divide-slate-800">
{% for d in exts %}
<tr>
<td class="py-2 px-2 font-mono">{{ d.ext }}</td>
<td class="py-2 px-2">{{ d.dept }}</td>
<td class="py-2 px-2">{{ 'yes' if d.is_active else 'no' }}</td>
<td class="py-2 px-2">
<form method="post" action="{{ url_for('admin_info_exts_toggle', did=d.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if d.is_active else 'Activate' }}</button>
</form>
<form method="post" action="{{ url_for('admin_info_exts_delete', did=d.id) }}" class="inline" onsubmit="return confirm('Delete extension?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Support Items -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Support & Escalation</h2>
<form method="post" action="{{ url_for('admin_info_support_create') }}" class="mt-3 grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid sm:grid-cols-3 gap-2">
<input name="category" placeholder="Guest Services" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="email" placeholder="guestservices@…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="phone" placeholder="806-791-8181 Option 1" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</div>
<textarea name="issues" rows="3" placeholder="One issue per line…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700"></textarea>
<input name="note" placeholder="Notes / disclaimers (optional)" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<div class="flex items-center gap-2">
<label class="text-sm inline-flex items-center gap-2">
<input type="checkbox" name="admin_only" class="rounded border-slate-700 bg-slate-950">
<span>Admin only</span>
</label>
<button class="ml-auto px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button>
</div>
</form>
<div class="mt-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));">
{% for s in supports %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ s.category }}</h3>
<span class="text-xs text-slate-400">{{ s.audience }}</span>
</div>
{% if s.email %}<p class="text-sm text-slate-300 mt-1">{{ s.email }}</p>{% endif %}
{% if s.phone %}<p class="text-sm text-slate-300">{{ s.phone }}</p>{% endif %}
{% if s.note %}<p class="text-xs text-slate-400 mt-2">{{ s.note }}</p>{% endif %}
{% if s.issues() %}
<ul class="mt-3 text-sm list-disc pl-5 space-y-1">{% for it in s.issues() %}<li>{{ it }}</li>{% endfor %}</ul>
{% endif %}
<div class="mt-3 flex gap-2">
<form method="post" action="{{ url_for('admin_info_support_toggle', sid=s.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if s.is_active else 'Activate' }}</button></form>
<form method="post" action="{{ url_for('admin_info_support_delete', sid=s.id) }}" onsubmit="return confirm('Delete support item?');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button></form>
</div>
</article>
{% endfor %}
</div>
</div>
<!-- Admin-Only Quick Notes -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Admin Quick Notes (Secrets)</h2>
<form method="post" action="{{ url_for('admin_info_secret_create') }}" class="mt-3 grid gap-2 sm:grid-cols-[1fr,1fr]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="label" placeholder="Register Login" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="value" placeholder="#291 / 0000" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<textarea name="notes" rows="2" placeholder="Notes (optional)" class="sm:col-span-2 px-3 py-2 rounded-lg bg-slate-950 border border-slate-700"></textarea>
<div class="sm:col-span-2"><button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button></div>
</form>
<div class="mt-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));">
{% for s in secrets %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h3 class="font-semibold">{{ s.label }}</h3>
<p class="mt-1 font-mono text-sm">{{ s.value }}</p>
{% if s.notes %}<p class="text-xs text-slate-400 mt-2 whitespace-pre-wrap">{{ s.notes }}</p>{% endif %}
<div class="mt-3 flex gap-2">
<form method="post" action="{{ url_for('admin_info_secret_toggle', sid=s.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if s.is_active else 'Activate' }}</button></form>
<form method="post" action="{{ url_for('admin_info_secret_delete', sid=s.id) }}" onsubmit="return confirm('Delete note?');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button></form>
</div>
</article>
{% endfor %}
</div>
</div>
</section>

View File

@@ -0,0 +1,32 @@
TPL_ADMIN_REQS_BODY = """
<section class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold mb-3">Time-off Requests</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_requests_export') }}">Export CSV</a>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr><th class="py-2 text-left">User</th><th class="py-2 text-left">Date</th><th class="py-2 text-left">Status</th><th class="py-2 text-left">Note</th><th class="py-2 text-left">Requested</th><th class="py-2 text-left">Action</th></tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for r in rows %}
<tr>
<td class="py-2">{{ r.user.username }}</td>
<td class="py-2">{{ r.date }}</td>
<td class="py-2 capitalize">{{ r.status }}</td>
<td class="py-2">{{ r.note or '' }}</td>
<td class="py-2">{{ r.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="py-2">
{% if r.status == 'pending' %}
<div class="flex gap-2">
<form method="post" action="{{ url_for('admin_requests_action', req_id=r.id, action='approve') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700" type="submit">Approve</button></form>
<form method="post" action="{{ url_for('admin_requests_action', req_id=r.id, action='deny') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" type="submit">Deny</button></form>
</div>
{% else %}<span class="text-slate-400"></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
"""

View File

@@ -0,0 +1,80 @@
<section class="grid gap-6">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold tracking-tight">Secret Santa</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_secret_santa_export') }}">Export CSV</a>
</div>
<div>
<input id="ssFilter"
placeholder="Filter by username or name…"
class="w-full sm:w-[28rem] px-4 py-2.5 rounded-xl bg-slate-950 border border-slate-700"
oninput="filterSS(this.value)">
</div>
<div id="ssGrid" class="grid gap-6"
style="grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));">
{% for e in rows %}
<article
class="ss-card group rounded-3xl border border-slate-800 bg-slate-900/60 p-6 hover:border-brand-600/50 hover:shadow-xl hover:shadow-brand-600/10 transition"
data-keywords="{{ (e.user.username ~ ' ' ~ (e.full_name or '') ) | lower }}"
>
<header class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<h3 class="text-lg font-semibold leading-6">{{ e.full_name or '—' }}</h3>
<p class="text-xs text-slate-400 break-all leading-5">{{ e.user.username }}</p>
</div>
<span
class="shrink-0 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs
{{ 'border-emerald-700 bg-emerald-500/15 text-emerald-200' if e.jewelry else 'border-slate-700 bg-slate-800/60 text-slate-300' }}">
{{ 'Jewelry: Yes' if e.jewelry else 'Jewelry: No' }}
</span>
</header>
<div class="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 text-sm leading-6">
<div>
<p class="text-slate-400 text-xs">Age</p>
<p class="font-medium">{{ e.age or '—' }}</p>
</div>
<div>
<p class="text-slate-400 text-xs">Birthday</p>
<p class="font-medium">{{ e.birthday or '—' }}</p>
</div>
<div class="col-span-2">
<p class="text-slate-400 text-xs">Favorite Gift Card</p>
<p class="font-medium">{{ e.gift_card or '—' }}</p>
</div>
<div class="col-span-2">
<p class="text-slate-400 text-xs">Favorite Type of Movie</p>
<p class="font-medium">{{ e.fav_movie or '—' }}</p>
</div>
<div class="col-span-2">
<p class="text-slate-400 text-xs">Hobbies</p>
<p class="whitespace-pre-wrap">{{ e.hobbies or '—' }}</p>
</div>
</div>
<footer class="mt-6 pt-4 border-t border-slate-800 flex items-center justify-between text-xs text-slate-400 leading-6">
<span>Updated {{ e.updated_at.strftime('%Y-%m-%d %H:%M') if e.updated_at else '—' }}</span>
<span class="opacity-70">ID #{{ e.id }}</span>
</footer>
</article>
{% endfor %}
</div>
{% if not rows %}
<p class="text-slate-400 text-sm">No Secret Santa entries yet.</p>
{% endif %}
</section>
<script>
function filterSS(q){
q = (q || '').toLowerCase();
document.querySelectorAll('.ss-card').forEach(card=>{
const keys = card.getAttribute('data-keywords') || '';
card.style.display = keys.includes(q) ? '' : 'none';
});
}
</script>

100
templates/admin_users.html Normal file
View File

@@ -0,0 +1,100 @@
<section class="grid gap-6">
<!-- Create User -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-5">
<h1 class="text-xl font-bold">Users</h1>
<p class="text-slate-400 text-sm mt-1">Temp password will be generated and shown as a flash message.</p>
<form method="post" action="{{ url_for('admin_users_create') }}"
class="mt-4 grid gap-3 sm:grid-cols-[1fr,200px,140px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="username" placeholder="Username (e.g. email)"
required
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-600" />
<select name="role"
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">
Create
</button>
</form>
</div>
<!-- Users table -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-5 overflow-x-auto">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold">All Users</h2>
<input id="userFilter" placeholder="Filter by username…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" oninput="filterUsers(this.value)" />
</div>
<table class="min-w-full text-sm mt-3">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-3 text-left font-medium">ID</th>
<th class="py-2 pr-3 text-left font-medium">Username</th>
<th class="py-2 pr-3 text-left font-medium">Role</th>
<th class="py-2 pr-3 text-left font-medium">Active</th>
<th class="py-2 pr-3 text-left font-medium">Must Change PW</th>
<th class="py-2 pr-3 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for u in users %}
<tr id="u{{ u.id }}">
<td class="py-2 pr-3 text-slate-300">{{ u.id }}</td>
<td class="py-2 pr-3 font-medium">{{ u.username }}</td>
<!-- Role updater -->
<td class="py-2 pr-3">
<form method="post" action="{{ url_for('admin_users_role', user_id=u.id) }}" class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="role"
class="px-2 py-1 rounded bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="member" {{ 'selected' if u.role=='member' else '' }}>member</option>
<option value="admin" {{ 'selected' if u.role=='admin' else '' }}>admin</option>
</select>
<button class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" type="submit">
Update
</button>
</form>
</td>
<!-- Active toggle -->
<td class="py-2 pr-3">
<form method="post" action="{{ url_for('admin_users_toggle', user_id=u.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
class="px-3 py-1.5 rounded-lg {{ 'bg-emerald-600 hover:bg-emerald-700' if u.is_active else 'bg-slate-700 hover:bg-slate-600' }}"
type="submit">
{{ 'Active' if u.is_active else 'Inactive' }}
</button>
</form>
</td>
<td class="py-2 pr-3">{{ 'yes' if u.must_change_password else 'no' }}</td>
<!-- Reset -->
<td class="py-2 pr-3">
<form method="post" action="{{ url_for('admin_users_reset', user_id=u.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">
Reset Password
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
function filterUsers(q) {
q = (q || '').toLowerCase();
document.querySelectorAll('tbody tr').forEach(tr => {
const name = tr.querySelector('td:nth-child(2)')?.textContent.toLowerCase() || '';
tr.style.display = name.includes(q) ? '' : 'none';
});
}
</script>
</div>
</section>

View File

@@ -0,0 +1,22 @@
TPL_ADMIN_WISHLISTS_BODY = """
<section class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold mb-3">Secret Santa / Wishlists</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_secret_santa_export') }}">Export Secret Santa CSV</a>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr><th class="py-2 text-left">User</th><th class="py-2 text-left">Last Updated</th><th class="py-2 text-left">Wishlist</th></tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for w in rows %}
<tr>
<td class="py-2">{{ w.user.username }}</td>
<td class="py-2">{{ w.updated_at.strftime('%Y-%m-%d %H:%M') if w.updated_at else '' }}</td>
<td class="py-2 whitespace-pre-wrap">{{ w.wishlist }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
"""

211
templates/base.html Normal file
View File

@@ -0,0 +1,211 @@
<!doctype html>
<html lang="en" class="h-full bg-slate-950">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{{ title or app_brand }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
600: '#2563eb',
700: '#1d4ed8'
}
}
}
}
}
</script>
<style>
/* Every box prints on its own page */
@media print {
.print-block {
page-break-before: always !important;
break-before: page !important;
}
/* remove first blank page */
.print-block:first-child {
page-break-before: avoid !important;
break-before: avoid !important;
}
}
/* Base print reset */
@media print {
html, body {
background: #ffffff !important;
color: #000000 !important;
}
/* Hide app chrome */
header,
nav,
footer,
.fixed,
#mobileNav,
#navToggle {
display: none !important;
visibility: hidden !important;
}
/* Remove Tailwind background classes */
.bg-slate-950,
.bg-slate-900\/60,
.bg-slate-950\/60 {
background: #ffffff !important;
}
.text-slate-100,
.text-slate-200,
.text-slate-300,
.text-slate-400 {
color: #000000 !important;
}
/* Force links to print black */
a {
color: #000000 !important;
text-decoration: underline !important;
}
/* Expand content full width */
main {
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
}
/* Page breaks for nice sections */
.print-section {
page-break-before: always;
}
/* Avoid truncation */
.no-break {
page-break-inside: avoid;
}
}
</style>
</head>
<body class="h-full text-slate-100 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
<div class="min-h-screen">
<!-- Topbar -->
<header class="sticky top-0 z-50 backdrop-blur bg-slate-950/70 border-b border-slate-800">
<div class="max-w-6xl mx-auto px-4">
<div class="h-14 flex items-center justify-between">
<!-- Brand -->
<a href="{{ url_for('dashboard') }}" class="flex items-center gap-2">
<div class="h-7 w-7 rounded bg-brand-600/20 border border-brand-600/30 grid place-items-center font-bold">SC</div>
<span class="font-semibold tracking-wide">{{ app_brand }}</span>
</a>
{% if current_user.is_authenticated %}
<!-- Desktop nav -->
<nav class="hidden md:flex items-center gap-2 text-sm">
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('dashboard') }}">Home</a>
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('availability') }}">Your Availability</a>
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('request_off') }}">Request Off</a>
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('secret_santa') }}">Secret Santa</a>
{% if current_user.role == 'admin' %}
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('admin_users') }}">Admin</a>
{% endif %}
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-3 py-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700" type="submit">Logout</button>
</form>
</nav>
<!-- Mobile menu -->
<button id="navToggle" class="md:hidden inline-flex items-center justify-center h-9 w-9 rounded-lg border border-slate-700 hover:border-slate-500" aria-label="Open menu" aria-expanded="false" aria-controls="mobileNav">
<svg id="navIconOpen" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
<svg id="navIconClose" class="h-5 w-5 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
{% endif %}
</div>
</div>
<!-- Mobile nav panel -->
<div id="mobileNav" class="md:hidden max-w-6xl mx-auto px-4 pb-3 hidden">
<div class="grid gap-2 text-sm">
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('dashboard') }}">Home</a>
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('availability') }}">Availability</a>
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('request_off') }}">Request Off</a>
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('secret_santa') }}">Secret Santa</a>
{% if current_user.role == 'admin' %}
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('admin_users') }}">Admin</a>
{% endif %}
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-3 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700" type="submit">Logout</button>
</form>
</div>
</div>
</header>
<!-- Main content -->
<main class="max-w-6xl mx-auto px-4 pt-6 pb-[6.5rem]"> <!-- ⬅️ room for bottom nav + iOS inset -->
{% with msgs = get_flashed_messages(with_categories=true) %}
{% if msgs %}
<div class="space-y-2 mb-4">
{% for cat,msg in msgs %}
<div class="rounded-lg px-3 py-2 text-sm border {{ 'bg-emerald-500/15 border-emerald-700 text-emerald-200' if cat=='ok' else 'bg-red-500/15 border-red-700 text-red-200' }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{{ content|safe }}
</main>
<!-- Bottom nav -->
{% if current_user.is_authenticated %}
<nav class="fixed bottom-0 inset-x-0 z-50 bg-[#111827] border-t border-gray-800 md:hidden pb-[env(safe-area-inset-bottom)]">
<div class="flex justify-around text-sm text-gray-400">
<a href="{{ url_for('dashboard') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'dashboard' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M3 12l2-2 7-7 7 7 2 2"/></svg>
Home
</a>
<a href="{{ url_for('availability') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'availability' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M8 7V3m8 4V3M3 11h18"/></svg>
Availability
</a>
<a href="{{ url_for('request_off') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'request' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M9 5v6m4 0h6"/></svg>
Requests
</a>
<a href="{{ url_for('secret_santa') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'secret' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5z"/></svg>
Gifts
</a>
</div>
</nav>
{% endif %}
<footer class="text-xs text-center text-slate-500 py-6">© {{ app_brand }}</footer>
</div>
<!-- Nav toggle -->
<script>
(function () {
const btn = document.getElementById('navToggle');
const menu = document.getElementById('mobileNav');
const openI = document.getElementById('navIconOpen');
const closeI = document.getElementById('navIconClose');
if (!btn || !menu) return;
btn.addEventListener('click', () => {
const isHidden = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
btn.setAttribute('aria-expanded', String(isHidden));
openI.classList.toggle('hidden', !isHidden);
closeI.classList.toggle('hidden', isHidden);
});
})();
</script>
</body>
</html>

34
templates/tpl_avail.html Normal file
View File

@@ -0,0 +1,34 @@
TPL_AVAIL_BODY = """
<section class="grid gap-6">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h1 class="text-xl font-bold mb-3">Your Weekly Availability</h1>
<form method="post" class="overflow-x-auto">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-4 text-left font-medium">Day</th>
<th class="py-2 px-2 text-left font-medium">Available</th>
<th class="py-2 px-2 text-left font-medium">Start</th>
<th class="py-2 px-2 text-left font-medium">End</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for key,label in day_names %}
<tr>
<td class="py-2 pr-4"><strong>{{ label }}</strong></td>
<td class="py-2 px-2"><input type="checkbox" name="{{ key }}_avail" {{ 'checked' if week[key].avail else '' }}></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_start" value="{{ week[key].start }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_end" value="{{ week[key].end }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Save Availability</button>
<span class="text-xs text-slate-400">Tip: Use the links in the header to request a specific day off or fill out Secret Santa.</span>
</div>
</form>
</div>
</section>
"""

View File

@@ -0,0 +1,13 @@
<h1 class="text-xl font-bold mb-3">Change Password</h1>
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 max-w-md">
<form method="post" class="grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="current_password" type="password" placeholder="Current password" required autocomplete="current-password" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="new_password" type="password" placeholder="New password (min 10 chars)" required autocomplete="new-password" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="confirm_password" type="password" placeholder="Confirm new password" required autocomplete="new-password" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Update</button>
</form>
{% if user.must_change_password %}
<p class="text-xs text-slate-400 mt-2">You must set a new password before continuing.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,130 @@
<section class="grid gap-4">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-bold">Welcome, {{ user.username }}</h1>
<p class="text-slate-300">Role: <span class="font-semibold uppercase">{{ user.role }}</span></p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-brand-600 opacity-80" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Member tiles -->
<a href="{{ url_for('info_page') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-600 focus:ring-offset-2 focus:ring-offset-slate-950 col-span-full">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Department / Store Info</h3>
<p class="mt-1 text-sm text-slate-400">Phones, extensions, support contacts, and notes.</p>
</div>
</div>
</a>
<a href="{{ url_for('availability') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-600 focus:ring-offset-2 focus:ring-offset-slate-950">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10m-7 4h4m-9 5h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v11a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Your Availability</h3>
<p class="mt-1 text-sm text-slate-400">Set your weekly pattern.</p>
</div>
</div>
</a>
<a href="{{ url_for('request_off') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 17l4 4 4-4m0-5l-4-4-4 4" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Request Off</h3>
<p class="mt-1 text-sm text-slate-400">Ask for specific days off.</p>
</div>
</div>
</a>
<a href="{{ url_for('secret_santa') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.5 0-3 1-3 3 0 1.1.9 2 2 2h2v1a2 2 0 01-2 2H8m4-8c1.5 0 3 1 3 3 0 1.1-.9 2-2 2h-2v1a2 2 0 002 2h4" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Secret Santa</h3>
<p class="mt-1 text-sm text-slate-400">Fill out your gift preferences.</p>
</div>
</div>
</a>
{% if user.role == 'admin' %}
<!-- Admin tiles -->
<a href="{{ url_for('admin_availability') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Team Availability</h3>
<p class="mt-1 text-sm text-slate-400">View weekly patterns by user.</p>
</div>
</div>
</a>
<a href="{{ url_for('admin_requests') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Time-off Requests</h3>
<p class="mt-1 text-sm text-slate-400">Approve or deny requests.</p>
</div>
</div>
</a>
<a href="{{ url_for('admin_secret_santa') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 22s8-4 8-10V5a8 8 0 10-16 0v7c0 6 8 10 8 10z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Secret Santa (Admin)</h3>
<p class="mt-1 text-sm text-slate-400">Browse entries and export data.</p>
</div>
</div>
</a>
<a href="{{ url_for('admin_users') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.121 17.804A4 4 0 017 17h10a4 4 0 011.879.804M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">User Management</h3>
<p class="mt-1 text-sm text-slate-400">Create accounts and set roles.</p>
</div>
</div>
</a>
{% endif %}
</div>
</section>

View File

@@ -0,0 +1,93 @@
<section class="grid gap-6">
<header class="flex items-center justify-between flex-wrap gap-3 print:hidden">
<h1 class="text-2xl font-bold">Department / Store Info</h1>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('admin_info_console') }}" class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500 text-sm">Admin Console</a>
{% endif %}
<button onclick="window.print()" class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500 text-sm">
Print / PDF
</button>
</header>
<!-- Quick Contacts -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 print-block">
<div class="flex items-center justify-between p-4 border-b border-slate-800">
<h2 class="text-lg font-semibold">Quick Contacts</h2>
<input id="q" placeholder="Filter by name…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-sm" oninput="filterContacts(this.value)">
</div>
<div id="contactsGrid" class="p-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
{% for c in contacts %}
<article class="contact-card rounded-xl border border-slate-800 bg-slate-950/60 p-3" data-key="{{ (c.name ~ ' ' ~ (c.role or '') ~ ' ' ~ (c.phone or '')) | lower }}">
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ c.name }}</h3>
{% if c.priority == 1 %}<span class="text-xs px-2 py-0.5 rounded-full border border-amber-600 text-amber-200">Priority</span>{% endif %}
</div>
{% if c.role %}<p class="text-xs text-slate-400">{{ c.role }}</p>{% endif %}
{% if c.phone %}<p class="mt-1 text-sm font-mono">{{ c.phone }}</p>{% endif %}
</article>
{% endfor %}
</div>
</div>
<!-- Department Extensions -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 overflow-x-auto print-block">
<div class="p-4 border-b border-slate-800">
<h2 class="text-lg font-semibold">Department Extensions</h2>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300"><tr><th class="py-2 px-3 text-left font-medium">Extension</th><th class="py-2 px-3 text-left font-medium">Department</th></tr></thead>
<tbody class="divide-y divide-slate-800">
{% for d in exts %}
<tr><td class="py-2 px-3 font-mono">{{ d.ext }}</td><td class="py-2 px-3">{{ d.dept }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Support Items -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 print-block">
<div class="p-4 border-b border-slate-800"><h2 class="text-lg font-semibold">Support & Escalation</h2></div>
<div class="p-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
{% for s in supports %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h3 class="font-semibold">{{ s.category }}</h3>
{% if s.email %}<p class="text-sm text-slate-300 mt-1"><span class="text-slate-400">Email:</span> {{ s.email }}</p>{% endif %}
{% if s.phone %}<p class="text-sm text-slate-300"><span class="text-slate-400">Phone:</span> {{ s.phone }}</p>{% endif %}
{% if s.note %}<p class="text-xs text-slate-400 mt-2">{{ s.note }}</p>{% endif %}
{% if s.issues() %}
<ul class="mt-3 text-sm list-disc pl-5 space-y-1">
{% for it in s.issues() %}<li>{{ it }}</li>{% endfor %}
</ul>
{% endif %}
</article>
{% endfor %}
</div>
</div>
{% if admin_secrets %}
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 print-block">
<div class="p-4 border-b border-slate-800 flex items-center justify-between ">
<h2 class="text-lg font-semibold">Admin Quick Notes</h2>
<span class="text-xs text-slate-400">Visible to admins only</span>
</div>
<div class="p-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
{% for s in admin_secrets %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h3 class="font-semibold">{{ s.label }}</h3>
<p class="mt-1 font-mono text-sm">{{ s.value }}</p>
{% if s.notes %}<p class="text-xs text-slate-400 mt-2 whitespace-pre-wrap">{{ s.notes }}</p>{% endif %}
</article>
{% endfor %}
</div>
</div>
{% endif %}
</section>
<script>
function filterContacts(q){
q = (q||'').toLowerCase();
document.querySelectorAll('.contact-card').forEach(c=>{
c.style.display = (c.getAttribute('data-key')||'').includes(q) ? '' : 'none';
});
}
</script>

View File

@@ -0,0 +1,18 @@
<section class="max-w-md mx-auto">
<h1 class="text-2xl font-bold mb-3">Sign in</h1>
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<form method="post" class="grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Username</span>
<input name="username" autocomplete="username" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Password</span>
<input name="password" type="password" autocomplete="current-password" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<button class="mt-2 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Login</button>
<p class="text-xs text-slate-400">First run? Use the bootstrap admin, then change it.</p>
</form>
</div>
</section>

View File

@@ -0,0 +1,93 @@
<section class="grid gap-6">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h1 class="text-xl font-bold mb-3">Request Time Off</h1>
<p class="text-sm text-slate-400 mb-4">
Use this page to request specific days off. Your leader will approve or deny
the request in the admin view.
</p>
<!-- New request form -->
<form method="post" action="{{ url_for('requests_new') }}" class="grid gap-3 max-w-md">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Date</span>
<input type="date"
name="date"
required
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" />
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Note (optional)</span>
<textarea name="note"
rows="3"
maxlength="240"
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700"
placeholder="Reason, details, or anything the scheduler should know."></textarea>
</label>
<button class="mt-1 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold"
type="submit">
Submit Request
</button>
</form>
</div>
<!-- Existing requests -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<h2 class="text-lg font-semibold mb-3">Your Requests</h2>
{% if my_reqs %}
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-3 text-left font-medium">Date</th>
<th class="py-2 pr-3 text-left font-medium">Status</th>
<th class="py-2 pr-3 text-left font-medium">Note</th>
<th class="py-2 pr-3 text-left font-medium">Requested</th>
<th class="py-2 pr-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for r in my_reqs %}
<tr>
<td class="py-2 pr-3">{{ r.date }}</td>
<td class="py-2 pr-3">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs capitalize
{% if r.status == 'pending' %}
bg-amber-500/15 text-amber-200 border border-amber-600
{% elif r.status == 'approved' %}
bg-emerald-500/15 text-emerald-200 border border-emerald-600
{% elif r.status == 'denied' %}
bg-red-500/15 text-red-200 border border-red-600
{% else %}
bg-slate-700/40 text-slate-200 border border-slate-600
{% endif %}">
{{ r.status }}
</span>
</td>
<td class="py-2 pr-3 whitespace-pre-wrap">{{ r.note or '' }}</td>
<td class="py-2 pr-3">
{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '' }}
</td>
<td class="py-2 pr-3">
{% if r.status == 'pending' %}
<form method="post"
action="{{ url_for('requests_cancel', req_id=r.id) }}"
onsubmit="return confirm('Cancel this request?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500 text-xs">
Cancel
</button>
</form>
{% else %}
<span class="text-xs text-slate-500">No action</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-sm text-slate-400">You don't have any requests yet.</p>
{% endif %}
</div>
</section>

View File

@@ -0,0 +1,48 @@
<section class="grid gap-6">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h1 class="text-xl font-bold mb-3">Secret Santa</h1>
<form method="post" class="grid gap-3 max-w-2xl">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Name (First and Last)</span>
<input name="full_name" value="{{ form.full_name or '' }}" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Age</span>
<input type="number" min="0" name="age" value="{{ form.age or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Birthday</span>
<input type="date" name="birthday" value="{{ form.birthday or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
</div>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">List of Hobbies</span>
<textarea name="hobbies" rows="3" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" placeholder="e.g., fishing, cooking, gaming">{{ form.hobbies or '' }}</textarea>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Favorite Gift card</span>
<input name="gift_card" value="{{ form.gift_card or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Favorite Type of movie</span>
<input name="fav_movie" value="{{ form.fav_movie or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
</div>
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" name="jewelry" value="yes" {% if form.jewelry %}checked{% endif %} class="rounded border-slate-700 bg-slate-950">
<span class="text-slate-300">Jewelry (Yes/No)</span>
</label>
<div class="pt-2">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Save</button>
</div>
</form>
</div>
</section>