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.
This commit is contained in:
Binary file not shown.
Binary file not shown.
224
app/main.py
224
app/main.py
@@ -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
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|||||||
183
app/reporting.py
183
app/reporting.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
74
app/templates/report_phone_book_address.html
Normal file
74
app/templates/report_phone_book_address.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user