Compare commits

..

7 Commits

Author SHA1 Message Date
HotSwapp
9497d69c76 Navbar: remove brand logo image; keep text brand only 2025-10-07 22:03:20 -05:00
HotSwapp
2a7d91da54 Auth UI: reliably hide navbar on login via body_class; add .auth-logo sized ~button width; restart 2025-10-07 21:59:40 -05:00
HotSwapp
bb68c489ee Auth UI: hide navbar on login via base navbar block; keep footer; remove circular logo styling; widen auth wrapper; restart container 2025-10-07 21:50:18 -05:00
HotSwapp
180314d43d UI: Simplify login page styling, remove purple gradient background, stop global .container overrides; add scoped .auth-wrapper; neutralize buttons/cards; rebuild verified via smoke test 2025-10-07 21:37:10 -05:00
HotSwapp
7fe57ccb6d Improve login screen design and functionality
- Increased logo size from 60x60 to 120x120px with proper styling
- Enhanced card layout with better padding and rounded corners
- Added modern gradient background and improved visual hierarchy
- Improved form styling with larger inputs and better spacing
- Enhanced password visibility toggle with better UX
- Improved error message styling with icons and rounded corners
- Added responsive design improvements for better mobile experience
- Updated color scheme with modern gradients and improved contrast
2025-10-07 21:33:12 -05:00
HotSwapp
aeb0be6982 feat(reports): add Envelope, Phone Book (address+phone) and Rolodex Info reports
- PDF builders in app/reporting.py (envelope, phone+address, rolodex info)
- Endpoints in app/main.py with auth, filtering, logging, Content-Disposition
- New HTML template report_phone_book_address.html
- Rolodex bulk actions updated with buttons/links
- JS helper to submit selections to alternate endpoints

Tested via docker compose build/up and health check.
2025-10-07 17:50:03 -05:00
HotSwapp
684b947651 docs: add next-section prompt for Reports (Envelope, Phone Book variants, Rolodex Info); confirm TODO next step pending run/test 2025-10-07 17:40:02 -05:00
11 changed files with 701 additions and 45 deletions

Binary file not shown.

View File

@@ -31,7 +31,13 @@ from structlog import contextvars as structlog_contextvars
from .database import create_tables, get_db, get_database_url from .database import create_tables, get_db, get_database_url
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
from .auth import authenticate_user, get_current_user_from_session from .auth import authenticate_user, get_current_user_from_session
from .reporting import build_phone_book_pdf, build_payments_detailed_pdf from .reporting import (
build_phone_book_pdf,
build_payments_detailed_pdf,
build_envelope_pdf,
build_phone_book_address_pdf,
build_rolodex_info_pdf,
)
from .logging_config import setup_logging from .logging_config import setup_logging
from .schemas import ( from .schemas import (
ClientOut, ClientOut,
@@ -2420,6 +2426,222 @@ async def payments_detailed_report(
}, },
) )
# ------------------------------
# Reports: Phone Book (Address + Phone)
# ------------------------------
@app.post("/reports/phone-book-address")
async def phone_book_address_post(request: Request):
"""Accept selected client IDs from forms and redirect to GET for rendering."""
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
client_ids = form.getlist("client_ids")
if not client_ids:
return RedirectResponse(url="/rolodex", status_code=302)
ids_param = "&".join([f"client_ids={cid}" for cid in client_ids])
return RedirectResponse(url=f"/reports/phone-book-address?{ids_param}", status_code=302)
@app.get("/reports/phone-book-address")
async def phone_book_address_report(
request: Request,
client_ids: List[int] | None = Query(None),
q: str | None = Query(None, description="Filter by name/company"),
phone: str | None = Query(None, description="Phone contains"),
format: str | None = Query(None, description="csv or pdf for export"),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
query = db.query(Client).options(joinedload(Client.phones))
if client_ids:
query = query.filter(Client.id.in_(client_ids))
else:
if q:
like = f"%{q}%"
query = query.filter(
or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like))
)
if phone:
query = query.filter(Client.phones.any(Phone.phone_number.ilike(f"%{phone}%")))
clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all()
if format == "csv":
# Build CSV output
output = StringIO()
writer = csv.writer(output)
writer.writerow(["Last", "First", "Company", "Address", "City", "State", "ZIP", "Phone Type", "Phone Number"])
for c in clients:
if c.phones:
for p in c.phones:
writer.writerow([
c.last_name or "",
c.first_name or "",
c.company or "",
c.address or "",
c.city or "",
c.state or "",
c.zip_code or "",
p.phone_type or "",
p.phone_number or "",
])
else:
writer.writerow([
c.last_name or "",
c.first_name or "",
c.company or "",
c.address or "",
c.city or "",
c.state or "",
c.zip_code or "",
"",
"",
])
csv_bytes = output.getvalue().encode("utf-8")
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=phone_book_address.csv"},
)
if format == "pdf":
pdf_bytes = build_phone_book_address_pdf(clients)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=phone_book_address.pdf"},
)
logger.info("phone_book_address_render", count=len(clients))
return templates.TemplateResponse(
"report_phone_book_address.html",
{
"request": request,
"user": user,
"clients": clients,
"q": q,
"phone": phone,
"client_ids": client_ids or [],
},
)
# ------------------------------
# Reports: Envelope (PDF)
# ------------------------------
@app.post("/reports/envelope")
async def envelope_report_post(request: Request):
"""Accept selected client IDs and redirect to GET for PDF download."""
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
client_ids = form.getlist("client_ids")
if not client_ids:
return RedirectResponse(url="/rolodex", status_code=302)
ids_param = "&".join([f"client_ids={cid}" for cid in client_ids])
return RedirectResponse(url=f"/reports/envelope?{ids_param}&format=pdf", status_code=302)
@app.get("/reports/envelope")
async def envelope_report(
request: Request,
client_ids: List[int] | None = Query(None),
q: str | None = Query(None, description="Filter by name/company"),
phone: str | None = Query(None, description="Phone contains (optional)"),
format: str | None = Query("pdf", description="pdf output only"),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
query = db.query(Client)
if client_ids:
query = query.filter(Client.id.in_(client_ids))
else:
if q:
like = f"%{q}%"
query = query.filter(
or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like))
)
if phone:
# include clients that have a matching phone
query = query.join(Phone, isouter=True).filter(or_(Phone.phone_number.ilike(f"%{phone}%"), Phone.id == None)).distinct() # noqa: E711
clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all()
# Always produce PDF
pdf_bytes = build_envelope_pdf(clients)
logger.info("envelope_pdf", count=len(clients))
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=envelopes.pdf"},
)
# ------------------------------
# Reports: Rolodex Info (PDF)
# ------------------------------
@app.post("/reports/rolodex-info")
async def rolodex_info_post(request: Request):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
form = await request.form()
client_ids = form.getlist("client_ids")
if not client_ids:
return RedirectResponse(url="/rolodex", status_code=302)
ids_param = "&".join([f"client_ids={cid}" for cid in client_ids])
return RedirectResponse(url=f"/reports/rolodex-info?{ids_param}&format=pdf", status_code=302)
@app.get("/reports/rolodex-info")
async def rolodex_info_report(
request: Request,
client_ids: List[int] | None = Query(None),
q: str | None = Query(None, description="Filter by name/company"),
format: str | None = Query("pdf", description="pdf output only"),
db: Session = Depends(get_db),
):
user = get_current_user_from_session(request.session)
if not user:
return RedirectResponse(url="/login", status_code=302)
query = db.query(Client).options(joinedload(Client.phones))
if client_ids:
query = query.filter(Client.id.in_(client_ids))
elif q:
like = f"%{q}%"
query = query.filter(
or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like))
)
clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all()
pdf_bytes = build_rolodex_info_pdf(clients)
logger.info("rolodex_info_pdf", count=len(clients))
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=rolodex_info.pdf"},
)
# ------------------------------ # ------------------------------
# JSON API: list/filter endpoints # JSON API: list/filter endpoints
# ------------------------------ # ------------------------------

View File

@@ -164,3 +164,186 @@ def build_payments_detailed_pdf(payments: List[Payment]) -> bytes:
return _output_pdf_bytes(pdf) return _output_pdf_bytes(pdf)
# ------------------------------
# Additional PDF Builders
# ------------------------------
def _format_client_name(client: Client) -> str:
last = client.last_name or ""
first = client.first_name or ""
name = f"{last}, {first}".strip(", ")
return name or (client.company or "")
def _format_city_state_zip(client: Client) -> str:
parts: list[str] = []
if client.city:
parts.append(client.city)
state_zip = " ".join([p for p in [(client.state or ""), (client.zip_code or "")] if p])
if state_zip:
if parts:
parts[-1] = f"{parts[-1]},"
parts.append(state_zip)
return " ".join(parts)
def build_envelope_pdf(clients: List[Client]) -> bytes:
"""Build an Envelope PDF with mailing blocks per client.
Layout uses a simple grid to place multiple #10 envelope-style address
blocks per Letter page. Each block includes:
- Name (Last, First)
- Company (if present)
- Address line
- City, ST ZIP
"""
logger.info("pdf_envelope_start", count=len(clients))
pdf = SimplePDF(title="Envelope Blocks")
pdf.add_page()
# Grid parameters
usable_width = pdf.w - pdf.l_margin - pdf.r_margin
usable_height = pdf.h - pdf.t_margin - pdf.b_margin
cols = 2
col_w = usable_width / cols
row_h = 45 # mm per block
rows = max(1, int(usable_height // row_h))
pdf.set_font("helvetica", size=11)
col = 0
row = 0
for idx, c in enumerate(clients):
if row >= rows:
# next page
pdf.add_page()
col = 0
row = 0
x = pdf.l_margin + (col * col_w) + 6 # slight inner padding
y = pdf.t_margin + (row * row_h) + 8
# Draw block contents
pdf.set_xy(x, y)
name_line = _format_client_name(c)
if name_line:
pdf.cell(col_w - 12, 6, name_line, ln=1)
if c.company:
pdf.set_x(x)
pdf.cell(col_w - 12, 6, c.company[:48], ln=1)
if c.address:
pdf.set_x(x)
pdf.cell(col_w - 12, 6, c.address[:48], ln=1)
city_state_zip = _format_city_state_zip(c)
if city_state_zip:
pdf.set_x(x)
pdf.cell(col_w - 12, 6, city_state_zip[:48], ln=1)
# Advance grid position
col += 1
if col >= cols:
col = 0
row += 1
logger.info("pdf_envelope_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)
def build_phone_book_address_pdf(clients: List[Client]) -> bytes:
"""Build a Phone Book (Address + Phone) PDF.
Columns: Name, Company, Address, City, State, ZIP, Phone
Multiple phone numbers yield multiple rows per client.
"""
logger.info("pdf_phone_book_addr_start", count=len(clients))
pdf = SimplePDF(title="Phone Book — Address + Phone")
pdf.add_page()
headers = ["Name", "Company", "Address", "City", "State", "ZIP", "Phone"]
widths = [40, 40, 55, 28, 12, 18, 30]
pdf.set_font("helvetica", "B", 9)
for h, w in zip(headers, widths):
pdf.cell(w, 7, h, border=1)
pdf.ln(7)
pdf.set_font("helvetica", size=9)
for c in clients:
name = _format_client_name(c)
phones = getattr(c, "phones", None) or [] # type: ignore[attr-defined]
if phones:
for p in phones:
pdf.cell(widths[0], 6, (name or "")[:24], border=1)
pdf.cell(widths[1], 6, (c.company or "")[:24], border=1)
pdf.cell(widths[2], 6, (c.address or "")[:32], border=1)
pdf.cell(widths[3], 6, (c.city or "")[:14], border=1)
pdf.cell(widths[4], 6, (c.state or "")[:4], border=1)
pdf.cell(widths[5], 6, (c.zip_code or "")[:10], border=1)
pdf.cell(widths[6], 6, (getattr(p, "phone_number", "") or "")[:18], border=1)
pdf.ln(6)
else:
pdf.cell(widths[0], 6, (name or "")[:24], border=1)
pdf.cell(widths[1], 6, (c.company or "")[:24], border=1)
pdf.cell(widths[2], 6, (c.address or "")[:32], border=1)
pdf.cell(widths[3], 6, (c.city or "")[:14], border=1)
pdf.cell(widths[4], 6, (c.state or "")[:4], border=1)
pdf.cell(widths[5], 6, (c.zip_code or "")[:10], border=1)
pdf.cell(widths[6], 6, "", border=1)
pdf.ln(6)
logger.info("pdf_phone_book_addr_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)
def build_rolodex_info_pdf(clients: List[Client]) -> bytes:
"""Build a Rolodex Info PDF with stacked info blocks per client."""
logger.info("pdf_rolodex_info_start", count=len(clients))
pdf = SimplePDF(title="Rolodex Info")
pdf.add_page()
pdf.set_font("helvetica", size=11)
for idx, c in enumerate(clients):
# Section header
pdf.set_font("helvetica", "B", 12)
pdf.cell(0, 7, _format_client_name(c) or "(No Name)", ln=1)
pdf.set_font("helvetica", size=10)
# Company
if c.company:
pdf.cell(0, 6, f"Company: {c.company}", ln=1)
# Address lines
if c.address:
pdf.cell(0, 6, f"Address: {c.address}", ln=1)
city_state_zip = _format_city_state_zip(c)
if city_state_zip:
pdf.cell(0, 6, f"City/State/ZIP: {city_state_zip}", ln=1)
# Legacy Id
if c.rolodex_id:
pdf.cell(0, 6, f"Legacy ID: {c.rolodex_id}", ln=1)
# Phones
phones = getattr(c, "phones", None) or [] # type: ignore[attr-defined]
if phones:
for p in phones:
ptype = (getattr(p, "phone_type", "") or "").strip()
pnum = (getattr(p, "phone_number", "") or "").strip()
label = f"{ptype}: {pnum}" if ptype else pnum
pdf.cell(0, 6, f"Phone: {label}", ln=1)
# Divider
pdf.ln(2)
pdf.set_draw_color(200)
x1 = pdf.l_margin
x2 = pdf.w - pdf.r_margin
y = pdf.get_y()
pdf.line(x1, y, x2, y)
pdf.ln(3)
logger.info("pdf_rolodex_info_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)

View File

@@ -16,12 +16,11 @@
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body class="{% block body_class %}{% endblock %}">
<!-- Navigation --> {% block navbar %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" width="30" height="30" class="d-inline-block align-text-top me-2">
Delphi Database Delphi Database
</a> </a>
@@ -69,6 +68,7 @@
</div> </div>
</div> </div>
</nav> </nav>
{% endblock %}
<!-- Main Content --> <!-- Main Content -->
<main class="container-fluid mt-4"> <main class="container-fluid mt-4">

View File

@@ -2,69 +2,84 @@
{% block title %}Login - Delphi Database{% endblock %} {% block title %}Login - Delphi Database{% endblock %}
{% block navbar %}{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container auth-wrapper">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-6 col-lg-4"> <div class="col-md-8 col-lg-5">
<div class="card shadow-sm"> <div class="card shadow-lg" style="border: none; border-radius: 15px;">
<div class="card-body"> <div class="card-body p-5">
<div class="text-center mb-4"> <div class="text-center mb-4">
<img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo" class="mb-3" style="width: 60px; height: 60px;"> <div class="auth-logo mx-auto mb-4">
<h2 class="card-title">Welcome Back</h2> <img src="{{ url_for('static', path='/logo/delphi-logo.webp') }}" alt="Delphi Logo">
</div>
<h2 class="card-title mb-2">Welcome Back</h2>
<p class="text-muted">Sign in to access Delphi Database</p> <p class="text-muted">Sign in to access Delphi Database</p>
</div> </div>
{% if error %} {% if error %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger d-flex align-items-center" role="alert" style="border-radius: 8px;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }} <i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>{{ error }}</div>
</div> </div>
{% endif %} {% endif %}
<form method="post" action="/login"> <form method="post" action="/login">
<div class="mb-3"> <div class="mb-4">
<label for="username" class="form-label">Username</label> <label for="username" class="form-label fw-semibold">Username</label>
<div class="input-group"> <div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-person"></i></span> <span class="input-group-text bg-light border-end-0">
<input type="text" class="form-control" id="username" name="username" required <i class="bi bi-person text-muted"></i>
placeholder="Enter your username" autocomplete="username"> </span>
<input type="text" class="form-control border-start-0 bg-light"
id="username" name="username" required
placeholder="Enter your username" autocomplete="username"
style="border-left: none;">
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-4">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label fw-semibold">Password</label>
<div class="input-group"> <div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-key"></i></span> <span class="input-group-text bg-light border-end-0">
<input type="password" class="form-control" id="password" name="password" required <i class="bi bi-key text-muted"></i>
placeholder="Enter your password" autocomplete="current-password"> </span>
<input type="password" class="form-control border-start-0 bg-light"
id="password" name="password" required
placeholder="Enter your password" autocomplete="current-password"
style="border-left: none;">
</div> </div>
</div> </div>
<div class="mb-3 form-check"> <div class="mb-4 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe"> <input type="checkbox" class="form-check-input" id="rememberMe">
<label class="form-check-label" for="rememberMe"> <label class="form-check-label text-muted" for="rememberMe">
Remember me Remember me
</label> </label>
</div> </div>
<div class="d-grid"> <div class="d-grid mb-3">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary btn-lg fw-semibold">
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In <i class="bi bi-box-arrow-in-right me-2"></i>Sign In
</button> </button>
</div> </div>
</form> </form>
<div class="text-center mt-4"> <div class="text-center mt-4 p-3" style="background-color: #f8f9fa; border-radius: 8px;">
<small class="text-muted"> <small class="text-muted">
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
Default credentials: admin / admin123 <strong>Default credentials:</strong> admin / admin123
</small> </small>
</div> </div>
</div> </div>
</div> </div>
<div class="text-center mt-3"> <div class="text-center mt-4">
<small class="text-muted"> <small class="text-muted">
Don't have an account? Contact your administrator. Don't have an account? <a href="mailto:admin@delphi.com" class="text-decoration-none">Contact your administrator</a>.
</small> </small>
</div> </div>
</div> </div>
@@ -79,32 +94,41 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus(); document.getElementById('username').focus();
}); });
// Show/hide password toggle (optional enhancement) // Show/hide password toggle functionality
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const passwordField = document.getElementById('password'); const passwordField = document.getElementById('password');
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
toggleBtn.type = 'button'; toggleBtn.type = 'button';
toggleBtn.className = 'btn btn-outline-secondary'; toggleBtn.className = 'btn btn-light border-start-0';
toggleBtn.innerHTML = '<i class="bi bi-eye"></i>'; toggleBtn.innerHTML = '<i class="bi bi-eye text-muted"></i>';
toggleBtn.style.border = 'none'; toggleBtn.style.border = '2px solid #e9ecef';
toggleBtn.style.background = 'transparent'; toggleBtn.style.borderLeft = 'none';
toggleBtn.style.background = '#f8f9fa';
toggleBtn.style.borderRadius = '0 8px 8px 0';
// Add toggle functionality // Add toggle functionality with better UX
toggleBtn.addEventListener('click', function() { toggleBtn.addEventListener('click', function() {
if (passwordField.type === 'password') { if (passwordField.type === 'password') {
passwordField.type = 'text'; passwordField.type = 'text';
this.innerHTML = '<i class="bi bi-eye-slash"></i>'; this.innerHTML = '<i class="bi bi-eye-slash text-muted"></i>';
this.classList.remove('btn-light');
this.classList.add('btn-outline-primary');
} else { } else {
passwordField.type = 'password'; passwordField.type = 'password';
this.innerHTML = '<i class="bi bi-eye"></i>'; this.innerHTML = '<i class="bi bi-eye text-muted"></i>';
this.classList.remove('btn-outline-primary');
this.classList.add('btn-light');
} }
}); });
// Insert toggle button into password input group // Insert toggle button into password input group
const passwordInputGroup = passwordField.closest('.input-group'); const passwordInputGroup = passwordField.closest('.input-group');
if (passwordInputGroup) { if (passwordInputGroup) {
const span = passwordInputGroup.querySelector('.input-group-text'); passwordInputGroup.appendChild(toggleBtn);
passwordInputGroup.insertBefore(toggleBtn, span.nextSibling);
// Adjust the input field border radius
passwordField.style.borderRadius = '8px 0 0 8px';
passwordField.style.borderRight = 'none';
} }
}); });
</script> </script>

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Phone Book (Address + Phone) · Delphi Database{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 d-flex align-items-center">
<a class="btn btn-sm btn-outline-secondary me-2" href="/rolodex">
<i class="bi bi-arrow-left"></i> Back
</a>
<h2 class="mb-0">Phone Book (Address + Phone)</h2>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book-address?format=csv{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}">
<i class="bi bi-filetype-csv me-1"></i>Download CSV
</a>
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book-address?format=pdf{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}{% if phone %}&phone={{ phone | urlencode }}{% endif %}">
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
</a>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 220px;">Name</th>
<th>Company</th>
<th>Address</th>
<th style="width: 160px;">City</th>
<th style="width: 90px;">State</th>
<th style="width: 110px;">ZIP</th>
<th style="width: 200px;">Phone</th>
</tr>
</thead>
<tbody>
{% if clients and clients|length > 0 %}
{% for c in clients %}
{% if c.phones and c.phones|length > 0 %}
{% for p in c.phones %}
<tr>
<td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<td>{{ c.company or '' }}</td>
<td>{{ c.address or '' }}</td>
<td>{{ c.city or '' }}</td>
<td>{{ c.state or '' }}</td>
<td>{{ c.zip_code or '' }}</td>
<td>{{ (p.phone_type ~ ': ' if p.phone_type) ~ (p.phone_number or '') }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td><span class="fw-semibold">{{ c.last_name or '' }}, {{ c.first_name or '' }}</span></td>
<td>{{ c.company or '' }}</td>
<td>{{ c.address or '' }}</td>
<td>{{ c.city or '' }}</td>
<td>{{ c.state or '' }}</td>
<td>{{ c.zip_code or '' }}</td>
<td class="text-muted"></td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No data.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -92,6 +92,15 @@
<a class="btn btn-outline-secondary" href="/reports/phone-book?format=csv{% if q %}&q={{ q | urlencode }}{% endif %}"> <a class="btn btn-outline-secondary" href="/reports/phone-book?format=csv{% if q %}&q={{ q | urlencode }}{% endif %}">
<i class="bi bi-filetype-csv me-1"></i>Phone Book CSV (Current Filter) <i class="bi bi-filetype-csv me-1"></i>Phone Book CSV (Current Filter)
</a> </a>
<a class="btn btn-outline-secondary js-submit-to" data-action="/reports/phone-book-address" href="#">
<i class="bi bi-journal-text me-1"></i>Phone+Address (Selected)
</a>
<a class="btn btn-outline-secondary js-submit-to" data-action="/reports/envelope" href="#">
<i class="bi bi-envelope me-1"></i>Envelope (Selected)
</a>
<a class="btn btn-outline-secondary js-submit-to" data-action="/reports/rolodex-info" href="#">
<i class="bi bi-card-text me-1"></i>Rolodex Info (Selected)
</a>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
</div> </div>

View File

@@ -0,0 +1,53 @@
## Next Section Prompt — Reports: Envelope, Phone Book (variants), Rolodex Info
Implement the next set of reports building on existing report infrastructure.
Context:
- Phone Book HTML/CSV/PDF is already implemented at `/reports/phone-book` using `build_phone_book_pdf` and `report_phone_book.html`.
- Bulk selection from `rolodex.html` posts to `/reports/phone-book`.
Goals:
1) Envelope report (PDF)
- Endpoint: `GET /reports/envelope?client_ids=...&q=...&format=pdf`
- Input: either selected `client_ids` (from rolodex bulk) or current filter `q`.
- Output: PDF of #10 envelope mailing blocks, one per client, paginated. Address format:
- Line 1: `First Last` (or `Company` if present and person name is blank)
- Line 2: `Company` (optional if name already used)
- Line 3: `Address`
- Line 4: `City, ST ZIP`
- Implementation: `build_envelope_pdf(clients)` in `app/reporting.py` using fpdf2 with sensible margins and font sizes.
2) Phone Book — Address & Phone variant (HTML/CSV/PDF)
- Extend existing `GET /reports/phone-book` to support `variant=address_phone`.
- Columns: Name, Company, Address, City, State, Zip, Phone Type, Phone Number.
- CSV: include all columns above.
- PDF: tabular layout similar to current phone book, trimmed to fit page width.
3) Rolodex Info report (PDF)
- Endpoint: `GET /reports/rolodex-info?client_ids=...&q=...&format=pdf`
- Output: Per-client summary blocks showing key details:
- Name (Last, First), Company
- Address, City/State/Zip
- All phone numbers (type + number)
- Implementation: `build_rolodex_info_pdf(clients)` in `app/reporting.py`, multi-column or stacked blocks with clear headings and light separators.
UI wiring:
- In `rolodex.html` bulk actions, add buttons/links for:
- Envelope (Selected) → POST to `/reports/envelope` or GET with `client_ids`
- Phone Book CSV/PDF (Current Filter) with `variant=address_phone`
- Rolodex Info (Selected)
Acceptance Criteria:
- All endpoints require auth; respect `client_ids` or `q` filtering similar to phone book.
- CSV downloads set `Content-Disposition` with meaningful filenames.
- PDFs render at least one page with legible typography and pagination.
- Logging: add structured logs for render/export start/end with counts.
Non-goals (for this pass):
- Advanced formatting (duplex, custom fonts), i18n, or bulk email generation.
Test notes:
- Use existing seed/import flows to populate clients and phones.
- Validate downloads via browser and ensure no server errors in logs.

View File

@@ -1,8 +1,79 @@
/* Custom CSS for Delphi Database */ /* Custom CSS for Delphi Database */
/* Additional styles can be added here */ /* Base: keep it neutral and let pages decide backgrounds */
body { body {
background-color: #f8f9fa; background-color: #f5f7fb; /* light neutral */
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Hide navbar for login page */
.login-page .navbar { display: none; }
/* Avoid overriding Bootstrap's global .container layout.
Provide a scoped helper for auth pages instead. */
.auth-wrapper {
min-height: calc(100vh - 120px); /* account for navbar+footer */
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
width: 100%; /* allow Bootstrap grid to expand normally */
}
/* Ensure inner row spans available width when parent is flex */
.auth-wrapper > .row {
width: 100%;
}
/* Enhanced card styling for login */
.card {
background: #ffffff;
}
/* Logo styling */
.card .card-body .text-center img {
border-radius: 0; /* show the raw logo */
background: transparent;
padding: 0;
box-shadow: none;
}
/* Auth logo scales close to button width */
.auth-logo { max-width: 320px; }
.auth-logo img { width: 100%; height: auto; object-fit: contain; }
/* Input group styling */
.input-group-lg .form-control {
border-radius: 8px !important;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.input-group-lg .form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
background: white;
}
.input-group-text {
border-radius: 8px 0 0 8px !important;
border: 2px solid #e9ecef;
border-right: none;
background: #f8f9fa;
}
/* Button styling */
.btn-primary {
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
} }
/* Custom navbar styles */ /* Custom navbar styles */
@@ -13,7 +84,8 @@ body {
/* Custom card styles */ /* Custom card styles */
.card { .card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border: 1px solid rgba(0, 0, 0, 0.125); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
} }
/* Custom button styles */ /* Custom button styles */

View File

@@ -81,6 +81,25 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Submit selection to alternate endpoints using data-action
document.querySelectorAll('.js-submit-to').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var container = link.closest('.table-responsive') || document;
var form = container.querySelector('form.js-answer-table');
if (!form) form = document.querySelector('form.js-answer-table');
if (!form) return;
var original = form.getAttribute('action');
var action = link.getAttribute('data-action');
if (action) form.setAttribute('action', action);
try {
form.submit();
} finally {
if (original) form.setAttribute('action', original);
}
});
});
// Field help: show contextual help from data-help on focus // Field help: show contextual help from data-help on focus
function attachFieldHelp(container) { function attachFieldHelp(container) {
if (!container) return; if (!container) return;