Compare commits
1 Commits
main
...
bennysvers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716e0c1fac |
666
app.py
Normal file
666
app.py
Normal 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)
|
||||
39
templates/admin_avail.html
Normal file
39
templates/admin_avail.html
Normal 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>
|
||||
75
templates/admin_availability.html
Normal file
75
templates/admin_availability.html
Normal 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:00–19: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
148
templates/admin_info.html
Normal 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>
|
||||
32
templates/admin_reqs_off.html
Normal file
32
templates/admin_reqs_off.html
Normal 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>
|
||||
"""
|
||||
80
templates/admin_secret_santa.html
Normal file
80
templates/admin_secret_santa.html
Normal 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
100
templates/admin_users.html
Normal 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>
|
||||
22
templates/admin_wishlists.html
Normal file
22
templates/admin_wishlists.html
Normal 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
211
templates/base.html
Normal 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
34
templates/tpl_avail.html
Normal 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>
|
||||
"""
|
||||
13
templates/tpl_change_password.html
Normal file
13
templates/tpl_change_password.html
Normal 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>
|
||||
130
templates/tpl_dashboard_body.html
Normal file
130
templates/tpl_dashboard_body.html
Normal 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>
|
||||
93
templates/tpl_info_page.html
Normal file
93
templates/tpl_info_page.html
Normal 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>
|
||||
18
templates/tpl_login_body.html
Normal file
18
templates/tpl_login_body.html
Normal 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>
|
||||
93
templates/tpl_request_off_body.html
Normal file
93
templates/tpl_request_off_body.html
Normal 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>
|
||||
48
templates/tpl_secret_santa.html
Normal file
48
templates/tpl_secret_santa.html
Normal 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>
|
||||
Reference in New Issue
Block a user