feat: add pdf generation

This commit is contained in:
Patryk Hegenberg 2025-11-08 12:07:29 +01:00
parent bb891aea0b
commit d4265cc046
7 changed files with 245 additions and 5 deletions

View file

@ -3,6 +3,7 @@ module school-timetracker
go 1.25.3
require (
github.com/jung-kurt/gofpdf v1.16.2
github.com/labstack/echo/v4 v4.13.4
golang.org/x/crypto v0.43.0
golang.org/x/time v0.11.0

View file

@ -1,3 +1,5 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@ -6,6 +8,9 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@ -16,10 +21,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@ -30,6 +39,7 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
@ -39,6 +49,7 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=

View file

@ -2,6 +2,7 @@ package main
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"time"
@ -494,3 +495,31 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
}
return c.JSON(http.StatusOK, year)
}
func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error {
isAdmin, _ := c.Get("is_admin").(bool)
if !isAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs")
}
schoolYear, err := GetActiveSchoolYear(app.DB)
if err != nil || schoolYear == nil {
return echo.NewHTTPError(http.StatusNotFound, "No active school year found")
}
summary, err := GetYearlyHoursSummary(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error())
}
filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name)
c.Response().Header().Set("Content-Type", "application/pdf")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
return c.Blob(http.StatusOK, "application/pdf", pdfBytes)
}

View file

@ -68,6 +68,7 @@ func main() {
admin.GET("/school-years", app.GetSchoolYearsHandler)
admin.POST("/school-years", app.CreateSchoolYearHandler)
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
}
e.Static("/", "./static")

110
backend/pdf.go Normal file
View file

@ -0,0 +1,110 @@
package main
import (
"fmt"
"time"
"github.com/jung-kurt/gofpdf"
)
func GenerateYearlySummaryPDF(schoolYear *SchoolYear, summary []WeeklyHours) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Arial", "B", 20)
title := fmt.Sprintf("Stundenjahresübersicht für Schuljahr %s", schoolYear.Name)
pdf.Cell(0, 15, title)
pdf.Ln(10)
pdf.SetFont("Arial", "", 12)
subtitle := fmt.Sprintf("%s bis %s", schoolYear.StartDate, schoolYear.EndDate)
pdf.Cell(0, 10, subtitle)
pdf.Ln(15)
pdf.SetFont("Arial", "B", 10)
pdf.SetFillColor(52, 152, 219)
pdf.SetTextColor(255, 255, 255)
colWidths := []float64{60, 40, 40, 40}
headers := []string{"Mitarbeiter", "Soll (Std.)", "Ist (Std.)", "Differenz (Std.)"}
for i, header := range headers {
pdf.CellFormat(colWidths[i], 10, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(0, 0, 0)
fill := false
for _, entry := range summary {
if fill {
pdf.SetFillColor(240, 240, 240)
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.CellFormat(colWidths[0], 8, entry.Username, "1", 0, "L", true, 0, "")
pdf.CellFormat(colWidths[1], 8, fmt.Sprintf("%.1f", entry.YearlyTarget), "1", 0, "R", true, 0, "")
pdf.CellFormat(colWidths[2], 8, fmt.Sprintf("%.1f", entry.YearlyActual), "1", 0, "R", true, 0, "")
diffStr := fmt.Sprintf("%.1f", entry.RemainingYearly)
if entry.RemainingYearly > 0 {
pdf.SetTextColor(220, 53, 69)
} else {
pdf.SetTextColor(40, 167, 69)
}
pdf.CellFormat(colWidths[3], 8, diffStr, "1", 0, "R", true, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(5)
pdf.SetFont("Arial", "B", 10)
totalTarget := 0.0
totalActual := 0.0
totalRemaining := 0.0
for _, entry := range summary {
totalTarget += entry.YearlyTarget
totalActual += entry.YearlyActual
totalRemaining += entry.RemainingYearly
}
pdf.SetFillColor(52, 152, 219)
pdf.SetTextColor(255, 255, 255)
pdf.CellFormat(colWidths[0], 10, "GESAMT", "1", 0, "L", true, 0, "")
pdf.CellFormat(colWidths[1], 10, fmt.Sprintf("%.1f", totalTarget), "1", 0, "R", true, 0, "")
pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%.1f", totalActual), "1", 0, "R", true, 0, "")
pdf.CellFormat(colWidths[3], 10, fmt.Sprintf("%.1f", totalRemaining), "1", 0, "R", true, 0, "")
pdf.Ln(15)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(128, 128, 128)
pdf.Cell(0, 10, fmt.Sprintf("Erstellt am: %s", time.Now().Format("02.01.2006 15:04")))
var buf []byte
w := &pdfWriter{buf: &buf}
err := pdf.Output(w)
if err != nil {
return nil, err
}
return buf, nil
}
type pdfWriter struct {
buf *[]byte
}
func (w *pdfWriter) Write(p []byte) (n int, err error) {
*w.buf = append(*w.buf, p...)
return len(p), nil
}