fix: landscape PDF reports, full text, wider columns, include CSV+PDF in monthly ZIP

This commit is contained in:
Claus Lohmar 2026-07-14 10:06:31 +00:00
parent d65c0cd5fa
commit 64844baa83
2 changed files with 89 additions and 91 deletions

View file

@ -653,15 +653,15 @@ func generateCSV(eventName string, expenses []database.Expense, userName, userDe
// If the expenses use a different currency than the base currency, both
// original and converted amounts are included.
func generatePDF(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.AddPage()
// Title: "Expense Report: <event name>"
pdf.SetFont("Helvetica", "B", 16)
// Title.
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Expense Report: "+eventName)
pdf.Ln(8)
// User info block.
// User info.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
@ -681,69 +681,68 @@ func generatePDF(eventName string, expenses []database.Expense, userName, userDe
}
}
// Table header row with item number.
pdf.SetFont("Helvetica", "B", 10)
// Landscape A4: 297mm wide, 10mm margins → 277mm usable.
pdf.SetFont("Helvetica", "B", 8)
var headers []string
var colWidths []float64
if hasConversion {
headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30}
headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category", "Description"}
colWidths = []float64{7, 24, 44, 20, 14, 20, 14, 40, 94}
} else {
headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"}
colWidths = []float64{10, 28, 48, 22, 18, 40}
headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category", "Description"}
colWidths = []float64{8, 28, 58, 24, 18, 48, 93}
}
for i, h := range headers {
pdf.Cell(colWidths[i], 8, h)
pdf.Cell(colWidths[i], 7, h)
}
pdf.Ln(8)
pdf.Ln(7)
// Table data rows with item numbers and automatic page breaks.
pdf.SetFont("Helvetica", "", 9)
marginBottom := 20.0 // mm margin from bottom before page break
// Table data rows.
pdf.SetFont("Helvetica", "", 8)
marginBottom := 18.0
var totalOrig, totalConv float64
for i, exp := range expenses {
// Check if we need a page break (A4 = 297mm height).
if pdf.GetY() > 297-marginBottom {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
// Re-draw header row on new page.
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 9)
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
itemNum := i + 1
if hasConversion {
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 8, exp.Date)
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 8, exp.Currency)
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
pdf.Cell(colWidths[6], 8, exp.BaseCurrency)
pdf.Cell(colWidths[7], 8, truncateString(exp.Category, 12))
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", exp.ConvertedAmount))
pdf.Cell(colWidths[6], 6, exp.BaseCurrency)
pdf.Cell(colWidths[7], 6, exp.Category)
pdf.Cell(colWidths[8], 6, exp.Description)
totalOrig += exp.Amount
totalConv += exp.ConvertedAmount
} else {
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 8, exp.Date)
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20))
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 8, exp.Currency)
pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20))
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, exp.Category)
pdf.Cell(colWidths[6], 6, exp.Description)
totalOrig += exp.Amount
}
pdf.Ln(8)
pdf.Ln(6)
}
// Totals row with a separator line.
pdf.SetDrawColor(71, 85, 105) // #475569 border colour
pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1)
// Totals row.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
if hasConversion {
// Determine the base/claim currency from the first expense that has one.
baseCur := ""
for _, exp := range expenses {
if exp.BaseCurrency != "" {
@ -759,6 +758,7 @@ func generatePDF(eventName string, expenses []database.Expense, userName, userDe
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", totalConv))
pdf.Cell(colWidths[6], 8, baseCur)
pdf.Cell(colWidths[7], 8, "")
pdf.Cell(colWidths[8], 8, "")
} else {
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
@ -766,9 +766,10 @@ func generatePDF(eventName string, expenses []database.Expense, userName, userDe
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 8, "")
pdf.Cell(colWidths[6], 8, "")
}
// Write the PDF document to a memory buffer.
// Write to buffer.
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)

View file

@ -310,11 +310,6 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
return
}
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
if format != "csv" && format != "pdf" {
format = "pdf"
}
userID := getUserID(r)
if userID == "" {
renderFileError(w, "Session expired. Please log in again.")
@ -365,16 +360,18 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
uDept = repUser.Department
}
// Generate the report.
var reportAtt *email.Attachment
if format == "csv" {
reportAtt, err = generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
} else {
reportAtt, err = generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept)
}
// Generate both CSV and PDF reports.
csvAtt, err := generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate %s: %v",
time.Now().Format(time.RFC3339), format, err)
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate CSV: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate report.")
return
}
pdfAtt, err := generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate PDF: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate report.")
return
}
@ -383,7 +380,8 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
addToZip(pkg, csvAtt.Filename, csvAtt.Content)
addToZip(pkg, pdfAtt.Filename, pdfAtt.Content)
// Add all receipt images from all events.
imgIdx := 0
@ -450,12 +448,10 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
log.Printf("INFO [%s] handlers: GenerateMonthlyReport: package %s created for month %s",
time.Now().Format(time.RFC3339), pkgFilename, monthID)
ext := format
reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(month.Name), ext)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Monthly Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">%s &amp; %d events, %d receipt images packaged.</p>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">CSV + PDF &amp; %d events, %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
@ -470,7 +466,7 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
<div id="send-link-result"></div>
</div>
</div>`,
template.HTMLEscapeString(reportName), len(events), imgIdx,
len(events), imgIdx,
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
}
@ -621,14 +617,14 @@ func generateMonthlyCSV(monthName string, events []database.Event, expenses []da
}, nil
}
// generateMonthlyPDF creates a PDF attachment aggregating expenses across
// all events in a month, with a section per event.
// generateMonthlyPDF creates a landscape PDF attachment aggregating expenses
// across all events in a month, with a section per event. Full text, no truncation.
func generateMonthlyPDF(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 16)
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
pdf.Ln(8)
@ -654,9 +650,10 @@ func generateMonthlyPDF(monthName string, events []database.Event, expenses []da
}
itemNum := 1
colWidths := []float64{8, 22, 38, 18, 14, 24, 30}
headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Desc."}
marginBottom := 20.0
// Landscape A4: 297mm wide, 10mm margins → 277mm usable.
colWidths := []float64{8, 28, 55, 22, 16, 50, 98}
headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Description"}
marginBottom := 18.0
var grandTotal float64
for _, evt := range events {
@ -665,48 +662,48 @@ func generateMonthlyPDF(monthName string, events []database.Event, expenses []da
continue
}
// Event section header with page break check.
if pdf.GetY() > 260 {
// Event section header.
if pdf.GetY() > 180 {
pdf.AddPage()
}
pdf.SetFont("Helvetica", "B", 11)
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, eventNames[evt.ID])
pdf.Ln(10)
pdf.Ln(9)
// Column headers.
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(8)
pdf.Ln(7)
var evtTotal float64
pdf.SetFont("Helvetica", "", 9)
pdf.SetFont("Helvetica", "", 8)
for _, exp := range evtExpenses {
if pdf.GetY() > 297-marginBottom {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 9)
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 9)
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
pdf.Cell(colWidths[0], 7, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 7, exp.Date)
pdf.Cell(colWidths[2], 7, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[3], 7, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 7, exp.Currency)
pdf.Cell(colWidths[5], 7, truncateString(exp.Category, 12))
pdf.Cell(colWidths[6], 7, truncateString(exp.Description, 18))
pdf.Ln(7)
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, exp.Category)
pdf.Cell(colWidths[6], 6, exp.Description)
pdf.Ln(6)
evtTotal += exp.Amount
itemNum++
}
// Event subtotal with separator.
// Event subtotal.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
pdf.Cell(colWidths[0], 8, "")