fix: landscape PDF reports, full text, wider columns, include CSV+PDF in monthly ZIP
This commit is contained in:
parent
d65c0cd5fa
commit
64844baa83
2 changed files with 89 additions and 91 deletions
|
|
@ -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
|
// If the expenses use a different currency than the base currency, both
|
||||||
// original and converted amounts are included.
|
// original and converted amounts are included.
|
||||||
func generatePDF(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
|
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()
|
pdf.AddPage()
|
||||||
|
|
||||||
// Title: "Expense Report: <event name>"
|
// Title.
|
||||||
pdf.SetFont("Helvetica", "B", 16)
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
||||||
pdf.Ln(8)
|
pdf.Ln(8)
|
||||||
|
|
||||||
// User info block.
|
// User info.
|
||||||
if userName != "" {
|
if userName != "" {
|
||||||
pdf.SetFont("Helvetica", "", 9)
|
pdf.SetFont("Helvetica", "", 9)
|
||||||
infoLine := fmt.Sprintf("Prepared by: %s", userName)
|
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.
|
// Landscape A4: 297mm wide, 10mm margins → 277mm usable.
|
||||||
pdf.SetFont("Helvetica", "B", 10)
|
pdf.SetFont("Helvetica", "B", 8)
|
||||||
var headers []string
|
var headers []string
|
||||||
var colWidths []float64
|
var colWidths []float64
|
||||||
if hasConversion {
|
if hasConversion {
|
||||||
headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
|
headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category", "Description"}
|
||||||
colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30}
|
colWidths = []float64{7, 24, 44, 20, 14, 20, 14, 40, 94}
|
||||||
} else {
|
} else {
|
||||||
headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"}
|
headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category", "Description"}
|
||||||
colWidths = []float64{10, 28, 48, 22, 18, 40}
|
colWidths = []float64{8, 28, 58, 24, 18, 48, 93}
|
||||||
}
|
}
|
||||||
for i, h := range headers {
|
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.
|
// Table data rows.
|
||||||
pdf.SetFont("Helvetica", "", 9)
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
marginBottom := 20.0 // mm margin from bottom before page break
|
marginBottom := 18.0
|
||||||
var totalOrig, totalConv float64
|
var totalOrig, totalConv float64
|
||||||
for i, exp := range expenses {
|
for i, exp := range expenses {
|
||||||
// Check if we need a page break (A4 = 297mm height).
|
if pdf.GetY() > 210-marginBottom {
|
||||||
if pdf.GetY() > 297-marginBottom {
|
|
||||||
pdf.AddPage()
|
pdf.AddPage()
|
||||||
// Re-draw header row on new page.
|
pdf.SetFont("Helvetica", "B", 8)
|
||||||
pdf.SetFont("Helvetica", "B", 9)
|
|
||||||
for j, h := range headers {
|
for j, h := range headers {
|
||||||
pdf.Cell(colWidths[j], 8, h)
|
pdf.Cell(colWidths[j], 7, h)
|
||||||
}
|
}
|
||||||
pdf.Ln(8)
|
pdf.Ln(7)
|
||||||
pdf.SetFont("Helvetica", "", 9)
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
}
|
}
|
||||||
itemNum := i + 1
|
itemNum := i + 1
|
||||||
if hasConversion {
|
if hasConversion {
|
||||||
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
|
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
|
||||||
pdf.Cell(colWidths[1], 8, exp.Date)
|
pdf.Cell(colWidths[1], 6, exp.Date)
|
||||||
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18))
|
pdf.Cell(colWidths[2], 6, exp.Merchant)
|
||||||
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
|
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
|
||||||
pdf.Cell(colWidths[4], 8, exp.Currency)
|
pdf.Cell(colWidths[4], 6, exp.Currency)
|
||||||
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
|
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", exp.ConvertedAmount))
|
||||||
pdf.Cell(colWidths[6], 8, exp.BaseCurrency)
|
pdf.Cell(colWidths[6], 6, exp.BaseCurrency)
|
||||||
pdf.Cell(colWidths[7], 8, truncateString(exp.Category, 12))
|
pdf.Cell(colWidths[7], 6, exp.Category)
|
||||||
|
pdf.Cell(colWidths[8], 6, exp.Description)
|
||||||
totalOrig += exp.Amount
|
totalOrig += exp.Amount
|
||||||
totalConv += exp.ConvertedAmount
|
totalConv += exp.ConvertedAmount
|
||||||
} else {
|
} else {
|
||||||
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
|
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
|
||||||
pdf.Cell(colWidths[1], 8, exp.Date)
|
pdf.Cell(colWidths[1], 6, exp.Date)
|
||||||
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20))
|
pdf.Cell(colWidths[2], 6, exp.Merchant)
|
||||||
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
|
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
|
||||||
pdf.Cell(colWidths[4], 8, exp.Currency)
|
pdf.Cell(colWidths[4], 6, exp.Currency)
|
||||||
pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20))
|
pdf.Cell(colWidths[5], 6, exp.Category)
|
||||||
|
pdf.Cell(colWidths[6], 6, exp.Description)
|
||||||
totalOrig += exp.Amount
|
totalOrig += exp.Amount
|
||||||
}
|
}
|
||||||
pdf.Ln(8)
|
pdf.Ln(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Totals row with a separator line.
|
// Totals row.
|
||||||
pdf.SetDrawColor(71, 85, 105) // #475569 border colour
|
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.Ln(3)
|
||||||
pdf.SetFont("Helvetica", "B", 9)
|
pdf.SetFont("Helvetica", "B", 9)
|
||||||
if hasConversion {
|
if hasConversion {
|
||||||
// Determine the base/claim currency from the first expense that has one.
|
|
||||||
baseCur := ""
|
baseCur := ""
|
||||||
for _, exp := range expenses {
|
for _, exp := range expenses {
|
||||||
if exp.BaseCurrency != "" {
|
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[5], 8, fmt.Sprintf("%.2f", totalConv))
|
||||||
pdf.Cell(colWidths[6], 8, baseCur)
|
pdf.Cell(colWidths[6], 8, baseCur)
|
||||||
pdf.Cell(colWidths[7], 8, "")
|
pdf.Cell(colWidths[7], 8, "")
|
||||||
|
pdf.Cell(colWidths[8], 8, "")
|
||||||
} else {
|
} else {
|
||||||
pdf.Cell(colWidths[0], 8, "")
|
pdf.Cell(colWidths[0], 8, "")
|
||||||
pdf.Cell(colWidths[1], 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[3], 8, fmt.Sprintf("%.2f", totalOrig))
|
||||||
pdf.Cell(colWidths[4], 8, "")
|
pdf.Cell(colWidths[4], 8, "")
|
||||||
pdf.Cell(colWidths[5], 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
|
var buf bytes.Buffer
|
||||||
if err := pdf.Output(&buf); err != nil {
|
if err := pdf.Output(&buf); err != nil {
|
||||||
return nil, fmt.Errorf("PDF output: %w", err)
|
return nil, fmt.Errorf("PDF output: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -310,11 +310,6 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
|
|
||||||
if format != "csv" && format != "pdf" {
|
|
||||||
format = "pdf"
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
renderFileError(w, "Session expired. Please log in again.")
|
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
|
uDept = repUser.Department
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the report.
|
// Generate both CSV and PDF reports.
|
||||||
var reportAtt *email.Attachment
|
csvAtt, err := generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
|
||||||
if format == "csv" {
|
|
||||||
reportAtt, err = generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
|
|
||||||
} else {
|
|
||||||
reportAtt, err = generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate %s: %v",
|
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate CSV: %v",
|
||||||
time.Now().Format(time.RFC3339), format, err)
|
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.")
|
renderFileError(w, "Failed to generate report.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +380,8 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
|
||||||
var pkgBuf bytes.Buffer
|
var pkgBuf bytes.Buffer
|
||||||
pkg := zip.NewWriter(&pkgBuf)
|
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.
|
// Add all receipt images from all events.
|
||||||
imgIdx := 0
|
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",
|
log.Printf("INFO [%s] handlers: GenerateMonthlyReport: package %s created for month %s",
|
||||||
time.Now().Format(time.RFC3339), pkgFilename, monthID)
|
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")
|
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;">
|
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>
|
<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 & %d events, %d receipt images packaged.</p>
|
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">CSV + PDF & %d events, %d receipt images packaged.</p>
|
||||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
@ -470,7 +466,7 @@ func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Requ
|
||||||
<div id="send-link-result"></div>
|
<div id="send-link-result"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
template.HTMLEscapeString(reportName), len(events), imgIdx,
|
len(events), imgIdx,
|
||||||
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
|
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
|
||||||
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
|
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
|
||||||
}
|
}
|
||||||
|
|
@ -621,14 +617,14 @@ func generateMonthlyCSV(monthName string, events []database.Event, expenses []da
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateMonthlyPDF creates a PDF attachment aggregating expenses across
|
// generateMonthlyPDF creates a landscape PDF attachment aggregating expenses
|
||||||
// all events in a month, with a section per event.
|
// 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) {
|
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()
|
pdf.AddPage()
|
||||||
|
|
||||||
// Title.
|
// Title.
|
||||||
pdf.SetFont("Helvetica", "B", 16)
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
|
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
|
||||||
pdf.Ln(8)
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
|
@ -654,9 +650,10 @@ func generateMonthlyPDF(monthName string, events []database.Event, expenses []da
|
||||||
}
|
}
|
||||||
|
|
||||||
itemNum := 1
|
itemNum := 1
|
||||||
colWidths := []float64{8, 22, 38, 18, 14, 24, 30}
|
// Landscape A4: 297mm wide, 10mm margins → 277mm usable.
|
||||||
headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Desc."}
|
colWidths := []float64{8, 28, 55, 22, 16, 50, 98}
|
||||||
marginBottom := 20.0
|
headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Description"}
|
||||||
|
marginBottom := 18.0
|
||||||
|
|
||||||
var grandTotal float64
|
var grandTotal float64
|
||||||
for _, evt := range events {
|
for _, evt := range events {
|
||||||
|
|
@ -665,48 +662,48 @@ func generateMonthlyPDF(monthName string, events []database.Event, expenses []da
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event section header with page break check.
|
// Event section header.
|
||||||
if pdf.GetY() > 260 {
|
if pdf.GetY() > 180 {
|
||||||
pdf.AddPage()
|
pdf.AddPage()
|
||||||
}
|
}
|
||||||
pdf.SetFont("Helvetica", "B", 11)
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
pdf.Cell(0, 8, eventNames[evt.ID])
|
pdf.Cell(0, 8, eventNames[evt.ID])
|
||||||
pdf.Ln(10)
|
pdf.Ln(9)
|
||||||
|
|
||||||
// Column headers.
|
// Column headers.
|
||||||
pdf.SetFont("Helvetica", "B", 9)
|
pdf.SetFont("Helvetica", "B", 8)
|
||||||
for j, h := range headers {
|
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
|
var evtTotal float64
|
||||||
pdf.SetFont("Helvetica", "", 9)
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
for _, exp := range evtExpenses {
|
for _, exp := range evtExpenses {
|
||||||
if pdf.GetY() > 297-marginBottom {
|
if pdf.GetY() > 210-marginBottom {
|
||||||
pdf.AddPage()
|
pdf.AddPage()
|
||||||
pdf.SetFont("Helvetica", "B", 9)
|
pdf.SetFont("Helvetica", "B", 8)
|
||||||
for j, h := range headers {
|
for j, h := range headers {
|
||||||
pdf.Cell(colWidths[j], 8, h)
|
pdf.Cell(colWidths[j], 7, h)
|
||||||
}
|
}
|
||||||
pdf.Ln(8)
|
pdf.Ln(7)
|
||||||
pdf.SetFont("Helvetica", "", 9)
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
}
|
}
|
||||||
pdf.Cell(colWidths[0], 7, fmt.Sprintf("%d", itemNum))
|
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
|
||||||
pdf.Cell(colWidths[1], 7, exp.Date)
|
pdf.Cell(colWidths[1], 6, exp.Date)
|
||||||
pdf.Cell(colWidths[2], 7, truncateString(exp.Merchant, 18))
|
pdf.Cell(colWidths[2], 6, exp.Merchant)
|
||||||
pdf.Cell(colWidths[3], 7, fmt.Sprintf("%.2f", exp.Amount))
|
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
|
||||||
pdf.Cell(colWidths[4], 7, exp.Currency)
|
pdf.Cell(colWidths[4], 6, exp.Currency)
|
||||||
pdf.Cell(colWidths[5], 7, truncateString(exp.Category, 12))
|
pdf.Cell(colWidths[5], 6, exp.Category)
|
||||||
pdf.Cell(colWidths[6], 7, truncateString(exp.Description, 18))
|
pdf.Cell(colWidths[6], 6, exp.Description)
|
||||||
pdf.Ln(7)
|
pdf.Ln(6)
|
||||||
evtTotal += exp.Amount
|
evtTotal += exp.Amount
|
||||||
itemNum++
|
itemNum++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event subtotal with separator.
|
// Event subtotal.
|
||||||
pdf.SetDrawColor(71, 85, 105)
|
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.Ln(3)
|
||||||
pdf.SetFont("Helvetica", "B", 9)
|
pdf.SetFont("Helvetica", "B", 9)
|
||||||
pdf.Cell(colWidths[0], 8, "")
|
pdf.Cell(colWidths[0], 8, "")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue