2311 lines
102 KiB
Python
2311 lines
102 KiB
Python
# app.py
|
||
from __future__ import annotations
|
||
import os, secrets, string, json as _json, csv
|
||
from datetime import datetime, timedelta
|
||
from io import StringIO
|
||
from typing import Optional
|
||
|
||
from flask import (
|
||
Flask, render_template_string, request, redirect, url_for, flash, jsonify,
|
||
make_response, abort
|
||
)
|
||
from flask_sqlalchemy import SQLAlchemy
|
||
from flask_login import (
|
||
LoginManager, UserMixin, login_user, current_user, login_required, logout_user
|
||
)
|
||
from werkzeug.security import generate_password_hash, check_password_hash
|
||
|
||
# CSRF + Rate Limiting
|
||
from flask_wtf import CSRFProtect
|
||
from flask_limiter import Limiter
|
||
from flask_limiter.util import get_remote_address
|
||
|
||
# =============================================================================
|
||
# Config
|
||
# =============================================================================
|
||
|
||
def getenv(name: str, default: Optional[str] = None) -> Optional[str]:
|
||
return os.environ.get(name, default)
|
||
|
||
DATABASE_URL = getenv("DATABASE_URL", "sqlite:///rbac.db")
|
||
SECRET_KEY = getenv("APP_SECRET_KEY", "dev_change_me")
|
||
APP_BRAND = getenv("APP_BRAND", "Streetside Canyon")
|
||
|
||
|
||
# First-run bootstrap admin (used only if there are no users in DB)
|
||
BOOTSTRAP_ADMIN_USER = getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||
BOOTSTRAP_ADMIN_PASS = getenv("DEFAULT_ADMIN_PASSWORD", "change_me_now")
|
||
|
||
# =============================================================================
|
||
# App + DB + Login
|
||
# =============================================================================
|
||
app = Flask(__name__)
|
||
app.config.update(
|
||
SECRET_KEY=SECRET_KEY,
|
||
SQLALCHEMY_DATABASE_URI=DATABASE_URL,
|
||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||
SESSION_COOKIE_HTTPONLY=True,
|
||
SESSION_COOKIE_SAMESITE="Lax",
|
||
SESSION_COOKIE_SECURE=True, # assumes HTTPS (you run behind Nginx)
|
||
REMEMBER_COOKIE_SECURE=True,
|
||
REMEMBER_COOKIE_HTTPONLY=True,
|
||
PERMANENT_SESSION_LIFETIME=60 * 60 * 24 * 14, # 14 days in seconds
|
||
)
|
||
|
||
# --- Freeze-safe config ---
|
||
app.config.update(
|
||
FREEZER_FLAT_URLS=True,
|
||
FREEZER_REMOVE_EXTRA_FILES=True,
|
||
FREEZER_IGNORE_MIMETYPE_WARNINGS=True
|
||
)
|
||
|
||
|
||
db = SQLAlchemy(app)
|
||
|
||
login_manager = LoginManager(app)
|
||
login_manager.login_view = "login"
|
||
login_manager.session_protection = "strong"
|
||
|
||
# CSRF + Rate limit
|
||
csrf = CSRFProtect(app)
|
||
limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["200/day", "50/hour"])
|
||
|
||
# =============================================================================
|
||
# Security headers
|
||
# =============================================================================
|
||
@app.after_request
|
||
def add_security_headers(resp):
|
||
csp = (
|
||
"default-src 'self'; "
|
||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||
"img-src 'self' data:; font-src 'self' data:; "
|
||
"base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
|
||
)
|
||
resp.headers.setdefault("Content-Security-Policy", csp)
|
||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||
return resp
|
||
|
||
# =============================================================================
|
||
# Models
|
||
# =============================================================================
|
||
class User(UserMixin, db.Model):
|
||
__tablename__ = "users"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||
password_hash = db.Column(db.String(255), nullable=False)
|
||
|
||
# 'admin' or 'member'
|
||
role = db.Column(db.String(16), nullable=False, default="member", index=True)
|
||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
# Force password change on first login (or after admin reset)
|
||
must_change_password = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||
|
||
def get_id(self):
|
||
return str(self.id)
|
||
|
||
def set_password(self, raw: str) -> None:
|
||
self.password_hash = generate_password_hash(raw)
|
||
|
||
def check_password(self, raw: str) -> bool:
|
||
return check_password_hash(self.password_hash, raw)
|
||
|
||
def is_admin(self) -> bool:
|
||
return self.role == "admin"
|
||
|
||
|
||
class AvailabilityWeekly(db.Model):
|
||
__tablename__ = "availability_weekly"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False, index=True)
|
||
# JSON shape: {"mon":{"avail":true,"start":"08:00","end":"17:00"}, ...}
|
||
data_json = db.Column(db.Text, nullable=False, default="{}")
|
||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
||
|
||
user = db.relationship("User", backref=db.backref("availability_weekly", uselist=False))
|
||
|
||
class TimeOffRequest(db.Model):
|
||
__tablename__ = "timeoff_requests"
|
||
__table_args__ = (
|
||
db.Index("ix_timeoff_user_status", "user_id", "status"),
|
||
db.Index("ix_timeoff_created_at", "created_at"),
|
||
db.Index("ix_timeoff_date", "date"),
|
||
)
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||
date = db.Column(db.String(10), nullable=False) # "YYYY-MM-DD"
|
||
note = db.Column(db.String(240))
|
||
status = db.Column(db.String(16), nullable=False, default="pending", index=True) # pending|approved|denied|cancelled
|
||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||
decided_at = db.Column(db.DateTime)
|
||
|
||
user = db.relationship("User", backref=db.backref("timeoff_requests", lazy="dynamic"))
|
||
|
||
class Wishlist(db.Model):
|
||
__tablename__ = "wishlists"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False, index=True)
|
||
wishlist = db.Column(db.Text, nullable=False)
|
||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
||
|
||
user = db.relationship("User", backref=db.backref("wishlist", uselist=False))
|
||
|
||
class SecretSantaEntry(db.Model):
|
||
__tablename__ = "secret_santa_entries"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False, index=True)
|
||
|
||
# Explicit questions
|
||
full_name = db.Column(db.String(120), nullable=False) # “Name (First and Last)”
|
||
age = db.Column(db.Integer) # Age
|
||
birthday = db.Column(db.String(10)) # YYYY-MM-DD (stored as text for SQLite simplicity)
|
||
hobbies = db.Column(db.Text) # List of hobbies
|
||
gift_card = db.Column(db.String(120)) # Favorite gift card
|
||
fav_movie = db.Column(db.String(120)) # Favorite type of movie
|
||
jewelry = db.Column(db.Boolean, nullable=False, default=False) # Yes/No
|
||
|
||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
||
|
||
user = db.relationship("User", backref=db.backref("secret_santa", uselist=False))
|
||
|
||
|
||
class InfoContact(db.Model):
|
||
__tablename__ = "info_contacts"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String(120), nullable=False, index=True)
|
||
role = db.Column(db.String(120))
|
||
phone = db.Column(db.String(32))
|
||
priority = db.Column(db.Integer, default=5, index=True) # 1 = top
|
||
team = db.Column(db.String(64), default="Streetside") # e.g., "Streetside"
|
||
is_active = db.Column(db.Boolean, default=True, index=True)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||
|
||
class DeptExtension(db.Model):
|
||
__tablename__ = "dept_extensions"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
ext = db.Column(db.String(16), nullable=False, index=True)
|
||
dept = db.Column(db.String(120), nullable=False, index=True)
|
||
is_active = db.Column(db.Boolean, default=True, index=True)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||
|
||
class SupportItem(db.Model):
|
||
"""
|
||
Generic support card: Guest Services, eCommerce Team, IT, etc.
|
||
"""
|
||
__tablename__ = "support_items"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
category = db.Column(db.String(64), nullable=False) # "Guest Services", "IT", "Ecommerce"
|
||
email = db.Column(db.String(160))
|
||
phone = db.Column(db.String(64))
|
||
note = db.Column(db.Text) # free-text note / disclaimer
|
||
issues_json = db.Column(db.Text, default="[]") # JSON list of bullet items
|
||
audience = db.Column(db.String(16), default="all") # "all" | "admin"
|
||
is_active = db.Column(db.Boolean, default=True)
|
||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
|
||
def issues(self):
|
||
try:
|
||
return _json.loads(self.issues_json or "[]")
|
||
except Exception:
|
||
return []
|
||
|
||
class LocalSecret(db.Model):
|
||
"""
|
||
Admin-only quick refs (register login, local device passwords, etc.)
|
||
"""
|
||
__tablename__ = "local_secrets"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
label = db.Column(db.String(160), nullable=False) # e.g. "Register Login"
|
||
value = db.Column(db.String(400), nullable=False) # e.g. "#291 / 0000"
|
||
notes = db.Column(db.Text)
|
||
is_active = db.Column(db.Boolean, default=True)
|
||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
|
||
|
||
|
||
@login_manager.user_loader
|
||
def load_user(user_id: str) -> Optional[User]:
|
||
try:
|
||
return db.session.get(User, int(user_id)) # type: ignore[arg-type]
|
||
except Exception:
|
||
return None
|
||
|
||
# =============================================================================
|
||
# Bootstrap (first run) — Flask 3 safe
|
||
# =============================================================================
|
||
def ensure_bootstrap_admin():
|
||
db.create_all()
|
||
if not User.query.first():
|
||
admin = User(
|
||
username=(BOOTSTRAP_ADMIN_USER or "admin").strip(),
|
||
role="admin",
|
||
is_active=True,
|
||
must_change_password=True,
|
||
)
|
||
admin.set_password((BOOTSTRAP_ADMIN_PASS or "change_me_now").strip())
|
||
db.session.add(admin)
|
||
db.session.commit()
|
||
app.logger.warning(
|
||
"Bootstrapped admin user '%s' (must change password on first login).",
|
||
admin.username,
|
||
)
|
||
|
||
with app.app_context():
|
||
ensure_bootstrap_admin()
|
||
db.create_all() # make sure new tables exist
|
||
|
||
def seed_store_info():
|
||
"""
|
||
One-time seed using the info Ben provided.
|
||
Safe to call repeatedly: it inserts only when each table is empty.
|
||
"""
|
||
any_inserted = False
|
||
|
||
# -----------------------------
|
||
# Contacts (priority: 1 = top)
|
||
# -----------------------------
|
||
if InfoContact.query.count() == 0:
|
||
contacts = [
|
||
{"name": "Ben", "role": None, "phone": "(806) 395-6770", "priority": 1},
|
||
{"name": "Terri A", "role": None, "phone": "(702) 776-1791", "priority": 2},
|
||
{"name": "Shireen", "role": None, "phone": "(806) 231-4024", "priority": 3},
|
||
{"name": "Charles", "role": None, "phone": "(806) 476-8101", "priority": 4},
|
||
{"name": "Matt", "role": None, "phone": "(806) 443-2069", "priority": 5},
|
||
{"name": "Navayah", "role": None, "phone": "(806) 729-2385", "priority": 6},
|
||
{"name": "Jaydaci", "role": None, "phone": "(806) 433-1715", "priority": 7},
|
||
{"name": "Diego", "role": None, "phone": "(620) 621-4402", "priority": 8},
|
||
{"name": "Kaley", "role": None, "phone": "(806) 292-9060", "priority": 9},
|
||
{"name": "Maika", "role": None, "phone": "(806) 557-8154", "priority": 10},
|
||
{"name": "Jacob", "role": None, "phone": "(806) 476-9724", "priority": 11},
|
||
{"name": "Tammy", "role": None, "phone": "(806) 523-5935", "priority": 12},
|
||
{"name": "Kathleen", "role": None, "phone": "(806) 420-9218", "priority": 13},
|
||
]
|
||
db.session.bulk_save_objects([InfoContact(**c) for c in contacts])
|
||
any_inserted = True
|
||
|
||
# --------------------------------
|
||
# Department extensions directory
|
||
# --------------------------------
|
||
if DeptExtension.query.count() == 0:
|
||
exts = [
|
||
("532000", "Service Counter"),
|
||
("532005", "Produce"),
|
||
("532006", "Market"),
|
||
("532106", "Market Office"),
|
||
("532007", "Bakery 1"),
|
||
("532107", "Bakery 2"),
|
||
("532012", "Food Service"),
|
||
("532013", "Pharmacy 1"),
|
||
("532113", "Pharmacy 2"),
|
||
("532213", "Pharmacy 3"),
|
||
("532313", "Pharmacy 4"),
|
||
("532413", "Pharmacy Office"),
|
||
("532014", "Dairy"),
|
||
("532018", "Fuel (UE)"),
|
||
("532373", "DSD"),
|
||
("532200", "Store Office"),
|
||
("532500", "Cash Office"),
|
||
("532600", "Price Coordinator"),
|
||
("532899", "Guest (Lobby)"),
|
||
("532950", "Streetside"),
|
||
]
|
||
db.session.bulk_save_objects([DeptExtension(ext=e, dept=d) for e, d in exts])
|
||
any_inserted = True
|
||
|
||
# -----------------------------
|
||
# Support / escalation cards
|
||
# -----------------------------
|
||
if SupportItem.query.count() == 0:
|
||
# Guest Services (primary escalation path)
|
||
guest_services_issues = [
|
||
"Billing Issues",
|
||
"Delivery Creations",
|
||
"Missing Loyalty Number",
|
||
"Waiting for the POS Result",
|
||
"Flybuy Activation",
|
||
"Order Status Updates",
|
||
"Refund Status",
|
||
"App/Web Issues",
|
||
"Promo Questions",
|
||
"Delivery Questions",
|
||
"Adding Rewards",
|
||
"Constant Export Failing",
|
||
"Winshop Not Working",
|
||
"Storefront issues (e.g., wrong prices/descriptions, discontinued items)"
|
||
]
|
||
db.session.add(SupportItem(
|
||
category="Guest Services",
|
||
email="guestservices@unitedtexas.com",
|
||
phone="855-762-7880",
|
||
note="Guest Services should be your first step. The address streetsidehelp@unitedtexas.com is no longer active.",
|
||
issues_json=_json.dumps(guest_services_issues),
|
||
audience="all",
|
||
is_active=True,
|
||
))
|
||
|
||
# Ecommerce Team
|
||
ecommerce_resp = [
|
||
"Equipment needs: Scales, Walkies, Thermo Guns, etc.",
|
||
"Marketing materials needs",
|
||
"Slot closures — contact SD and RVP first"
|
||
]
|
||
db.session.add(SupportItem(
|
||
category="Ecommerce Team",
|
||
email="team_ecommerce@unitedtexas.com",
|
||
phone=None,
|
||
note=None,
|
||
issues_json=_json.dumps(ecommerce_resp),
|
||
audience="all",
|
||
is_active=True,
|
||
))
|
||
|
||
# IT Support
|
||
it_issues = [
|
||
"Issues with POS",
|
||
"Issues with handhelds or label printer",
|
||
"Needing handhelds",
|
||
"Troubles logging into the computer or email"
|
||
]
|
||
db.session.add(SupportItem(
|
||
category="IT Support",
|
||
email="support@unitedtexas.com",
|
||
phone="806-791-8181 Option 1",
|
||
note=None,
|
||
issues_json=_json.dumps(it_issues),
|
||
audience="all",
|
||
is_active=True,
|
||
))
|
||
|
||
any_inserted = True
|
||
|
||
# -----------------------------
|
||
# Admin-only quick notes
|
||
# -----------------------------
|
||
if LocalSecret.query.count() == 0:
|
||
secrets = [
|
||
{
|
||
"label": "Register Login",
|
||
"value": "#291 / 0000",
|
||
"notes": "Sign off register at night — DO NOT SHUT OFF.",
|
||
},
|
||
{
|
||
"label": "Computer Password",
|
||
"value": "532532",
|
||
"notes": "Closing: turn off heater/fan if on.",
|
||
},
|
||
{
|
||
"label": "Phone Password",
|
||
"value": "1111",
|
||
"notes": None,
|
||
},
|
||
{
|
||
"label": "Bottle Deposit Returns (Volleman Glass empty)",
|
||
"value": "13573",
|
||
"notes": None,
|
||
},
|
||
]
|
||
db.session.bulk_save_objects([LocalSecret(**s) for s in secrets])
|
||
any_inserted = True
|
||
|
||
if any_inserted:
|
||
db.session.commit()
|
||
app.logger.warning("Seeded Store Info (contacts/extensions/support/secrets).")
|
||
else:
|
||
app.logger.info("Store Info seed skipped: tables already contain data.")
|
||
|
||
with app.app_context():
|
||
ensure_bootstrap_admin()
|
||
db.create_all()
|
||
seed_store_info()
|
||
|
||
|
||
# =============================================================================
|
||
# Role guard + utilities
|
||
# =============================================================================
|
||
from functools import wraps
|
||
|
||
def admin_required(f):
|
||
@wraps(f)
|
||
@login_required
|
||
def wrapper(*args, **kwargs):
|
||
if not current_user.is_admin():
|
||
flash("Admins only.", "error")
|
||
return redirect(url_for("dashboard"))
|
||
return f(*args, **kwargs)
|
||
return wrapper
|
||
|
||
def gen_temp_password(length: int = 14) -> str:
|
||
alphabet = string.ascii_letters + string.digits
|
||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||
|
||
DAY_NAMES = [("mon","Mon"),("tue","Tue"),("wed","Wed"),("thu","Thu"),("fri","Fri"),("sat","Sat"),("sun","Sun")]
|
||
|
||
def _default_week():
|
||
return {k: {"avail": False, "start": "08:00", "end": "17:00"} for k,_ in DAY_NAMES}
|
||
|
||
def _parse_week_from_form(form):
|
||
out = {}
|
||
for key,_label in DAY_NAMES:
|
||
avail = (form.get(f"{key}_avail") == "on")
|
||
start = (form.get(f"{key}_start") or "08:00").strip()[:5]
|
||
end = (form.get(f"{key}_end") or "17:00").strip()[:5]
|
||
out[key] = {"avail": avail, "start": start, "end": end}
|
||
return out
|
||
|
||
def _validate_iso_date(d: str) -> bool:
|
||
try:
|
||
datetime.strptime(d, "%Y-%m-%d")
|
||
return True
|
||
except ValueError:
|
||
return False
|
||
|
||
# =============================================================================
|
||
# Base layout + render helper
|
||
# =============================================================================
|
||
TPL_BASE = """
|
||
<!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>
|
||
"""
|
||
|
||
|
||
|
||
TPL_LOGIN_BODY = """
|
||
<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>
|
||
"""
|
||
|
||
# Dashboard with punchy, clickable tiles
|
||
TPL_DASHBOARD_BODY = """
|
||
<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>
|
||
"""
|
||
|
||
|
||
TPL_INFO_PAGE = """
|
||
<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>
|
||
"""
|
||
|
||
TPL_ADMIN_INFO = """
|
||
<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>
|
||
"""
|
||
|
||
TPL_CHANGE_PASSWORD_BODY = """
|
||
<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>
|
||
"""
|
||
|
||
# Member: Request Off page (simple, professional)
|
||
TPL_REQUEST_OFF_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">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>
|
||
"""
|
||
|
||
|
||
# Admin: Users
|
||
TPL_ADMIN_USERS_BODY = """
|
||
<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>
|
||
"""
|
||
|
||
# Member: availability only
|
||
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>
|
||
"""
|
||
|
||
# Admin: Team Availability grid (includes ✎ edit link)
|
||
TPL_ADMIN_AVAIL_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">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>
|
||
"""
|
||
|
||
# Admin: Time-off requests moderation
|
||
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>
|
||
"""
|
||
|
||
# Admin: Wishlists table
|
||
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>
|
||
"""
|
||
|
||
TPL_SECRET_SANTA_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">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>
|
||
"""
|
||
|
||
# Admin: Secret Santa cards
|
||
TPL_ADMIN_SS_BODY = """
|
||
<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>
|
||
"""
|
||
|
||
# Admin: Availability editor (NEW)
|
||
TPL_ADMIN_AVAIL_EDIT_BODY = """
|
||
<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>
|
||
"""
|
||
|
||
def render_page(body_tpl: str, **ctx):
|
||
"""Render a body template into the base layout."""
|
||
body_html = render_template_string(body_tpl, **ctx)
|
||
title = ctx.get("title") or APP_BRAND
|
||
return render_template_string(TPL_BASE, title=title, content=body_html, app_brand=APP_BRAND)
|
||
|
||
# =============================================================================
|
||
# 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_page(TPL_LOGIN_BODY, 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_page(TPL_DASHBOARD_BODY, 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_page(TPL_CHANGE_PASSWORD_BODY, 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_page(TPL_SECRET_SANTA_BODY, 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_page(TPL_ADMIN_SS_BODY, 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_page(TPL_REQUEST_OFF_BODY, 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_page(TPL_AVAIL_BODY, 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_page(TPL_ADMIN_AVAIL_BODY, 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_page(TPL_ADMIN_AVAIL_EDIT_BODY,
|
||
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)
|