Merge branch 'implement-pdf-generrating-and-download'

* implement-pdf-generrating-and-download:
  feat: add pdf generation
This commit is contained in:
Patryk Hegenberg 2025-11-08 12:07:44 +01:00
commit e931b97037
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
}

View file

@ -1,19 +1,21 @@
{
"type": "application",
"source-directories": ["src"],
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/bytes": "1.0.8",
"elm/core": "1.0.5",
"elm/file": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/time": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}

View file

@ -1,7 +1,9 @@
port module Main exposing (..)
import Browser
import Bytes exposing (Bytes)
import Dict exposing (Dict)
import File.Download
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
@ -430,6 +432,8 @@ type Msg
| SchoolYearActivated (Result Http.Error ())
| DeleteSchoolYear Int
| SchoolYearDeleted (Result Http.Error ())
| DownloadYearlySummaryPDF
| YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes)
update : Msg -> Model -> ( Model, Cmd Msg )
@ -1631,6 +1635,29 @@ update msg model =
SchoolYearDeleted (Err _) ->
( { model | error = Just "Fehler beim Löschen" }, Cmd.none )
DownloadYearlySummaryPDF ->
case model.token of
Just token ->
( { model | isProcessing = True }, downloadYearlySummaryPDF token )
Nothing ->
( model, Cmd.none )
YearlySummaryPDFReceived (Ok pdfBytes) ->
let
filename =
"Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf"
in
( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
YearlySummaryPDFReceived (Err _) ->
( { model
| error = Just "Fehler beim Herunterladen der PDF"
, isProcessing = False
}
, Cmd.none
)
-- SUBSCRIPTIONS
@ -2606,7 +2633,35 @@ viewTimeEntriesTab model =
viewYearlyHoursSummary : Model -> Html Msg
viewYearlyHoursSummary model =
div [ class "box" ]
[ if List.isEmpty model.yearlyHoursSummary then
[ div [ class "level mb-4" ]
[ div [ class "level-left" ]
[ div [ class "level-item" ]
[ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ]
]
]
, div [ class "level-right" ]
[ div [ class "level-item" ]
[ a
[ class "button is-info"
, onClick DownloadYearlySummaryPDF
, disabled model.isProcessing
]
[ span [ class "icon" ]
[ i [ class "fas fa-file-pdf" ] [] ]
, span []
[ text
(if model.isProcessing then
"Wird erstellt..."
else
"PDF exportieren"
)
]
]
]
]
]
, if List.isEmpty model.yearlyHoursSummary then
p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ]
else
@ -2950,7 +3005,7 @@ viewWeekNavigation model =
, style "flex-direction" "column"
, style "align-items" "center"
, style "gap" "0.5rem"
, style "min-width" "250px" -- Verhindert Kompression
, style "min-width" "250px"
]
[ p
[ class "heading"
@ -4225,3 +4280,34 @@ schoolYearDecoder =
(field "start_date" string)
(field "end_date" string)
(field "is_active" bool)
downloadYearlySummaryPDF : String -> Cmd Msg
downloadYearlySummaryPDF token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/yearly-summary/pdf"
, body = Http.emptyBody
, expect =
Http.expectBytesResponse YearlySummaryPDFReceived
(\response ->
case response of
Http.GoodStatus_ _ body ->
Ok body
Http.BadUrl_ url ->
Err (Http.BadUrl url)
Http.Timeout_ ->
Err Http.Timeout
Http.NetworkError_ ->
Err Http.NetworkError
Http.BadStatus_ metadata _ ->
Err (Http.BadStatus metadata.statusCode)
)
, timeout = Nothing
, tracker = Nothing
}