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:
HotSwapp
2025-10-07 17:50:03 -05:00
parent 684b947651
commit aeb0be6982
7 changed files with 508 additions and 1 deletions

View File

@@ -164,3 +164,186 @@ def build_payments_detailed_pdf(payments: List[Payment]) -> bytes:
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)