This commit is contained in:
@@ -124,6 +124,218 @@ func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte {
|
||||
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
|
||||
}
|
||||
|
||||
type serviceReportMeta struct {
|
||||
Client string
|
||||
Contact string
|
||||
Location string
|
||||
OrderNumber string
|
||||
Subject string
|
||||
Notes string
|
||||
Place string
|
||||
ReportDate string
|
||||
}
|
||||
|
||||
func makeServiceReportPDF(entries []Entry, cfg Settings, owner User, meta serviceReportMeta) []byte {
|
||||
loc := location(cfg.Timezone)
|
||||
technician := strings.TrimSpace(cfg.ExportName)
|
||||
if technician == "" {
|
||||
technician = strings.TrimSpace(owner.DisplayName)
|
||||
}
|
||||
if technician == "" {
|
||||
technician = owner.Username
|
||||
}
|
||||
if strings.TrimSpace(meta.Subject) == "" {
|
||||
meta.Subject = "Service-Bericht"
|
||||
}
|
||||
if strings.TrimSpace(meta.ReportDate) == "" {
|
||||
meta.ReportDate = time.Now().In(loc).Format("02.01.2006")
|
||||
}
|
||||
|
||||
const pageW, pageH = 595.0, 842.0
|
||||
var pages [][]pdfPageLine
|
||||
var page []pdfPageLine
|
||||
y := 795.0
|
||||
|
||||
add := func(x, yy, size float64, bold bool, text string) {
|
||||
page = append(page, pdfPageLine{x, yy, size, bold, text})
|
||||
}
|
||||
addTableHeader := func() {
|
||||
add(50, y, 8.5, true, "Datum")
|
||||
add(108, y, 8.5, true, "Zeit")
|
||||
add(184, y, 8.5, true, "Dauer")
|
||||
add(244, y, 8.5, true, "Tätigkeit / Leistung")
|
||||
y -= 10
|
||||
add(50, y, 6.5, false, strings.Repeat("-", 104))
|
||||
y -= 14
|
||||
}
|
||||
newPage := func(continuation, includeTable bool) {
|
||||
if len(page) > 0 {
|
||||
pages = append(pages, page)
|
||||
}
|
||||
page = []pdfPageLine{}
|
||||
y = 795
|
||||
title := "SERVICE-BERICHT"
|
||||
if continuation {
|
||||
title += " - FORTSETZUNG"
|
||||
}
|
||||
add(50, y, 18, true, title)
|
||||
if strings.TrimSpace(meta.OrderNumber) != "" {
|
||||
add(405, y+2, 8.5, true, "Auftrag / Ticket")
|
||||
add(405, y-11, 9.5, false, clipRunes(meta.OrderNumber, 25))
|
||||
}
|
||||
y -= 30
|
||||
if !continuation {
|
||||
add(50, y, 8, true, "Kunde")
|
||||
add(50, y-14, 10, false, clipRunes(meta.Client, 48))
|
||||
add(310, y, 8, true, "Ansprechpartner")
|
||||
add(310, y-14, 10, false, clipRunes(meta.Contact, 39))
|
||||
y -= 42
|
||||
add(50, y, 8, true, "Einsatzort")
|
||||
add(50, y-14, 9.5, false, clipRunes(meta.Location, 48))
|
||||
add(310, y, 8, true, "Berichtsdatum")
|
||||
add(310, y-14, 9.5, false, meta.ReportDate)
|
||||
y -= 42
|
||||
add(50, y, 8, true, "Techniker")
|
||||
add(50, y-14, 9.5, false, clipRunes(technician, 48))
|
||||
add(310, y, 8, true, "Betreff")
|
||||
add(310, y-14, 9.5, false, clipRunes(meta.Subject, 39))
|
||||
y -= 38
|
||||
}
|
||||
if includeTable {
|
||||
addTableHeader()
|
||||
}
|
||||
}
|
||||
newPage(false, true)
|
||||
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
if e.EndMS == nil {
|
||||
continue
|
||||
}
|
||||
start := time.UnixMilli(e.StartMS).In(loc)
|
||||
end := time.UnixMilli(*e.EndMS).In(loc)
|
||||
d := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
|
||||
total += d
|
||||
activity := strings.TrimSpace(e.Activity)
|
||||
if activity == "" {
|
||||
activity = "-"
|
||||
}
|
||||
activityLines := wrapPDFText(activity, 50)
|
||||
rowLines := len(activityLines)
|
||||
if rowLines < 1 {
|
||||
rowLines = 1
|
||||
}
|
||||
rowHeight := float64(rowLines*12 + 10)
|
||||
if y-rowHeight < 105 {
|
||||
newPage(true, true)
|
||||
}
|
||||
add(50, y, 8.5, false, start.Format("02.01.06"))
|
||||
add(108, y, 8.5, false, formatClock(start, cfg.TimeFormat)+"-"+formatClock(end, cfg.TimeFormat))
|
||||
add(184, y, 8.5, false, formatDuration(d))
|
||||
for i, line := range activityLines {
|
||||
add(244, y-float64(i*12), 8.5, false, line)
|
||||
}
|
||||
y -= rowHeight - 4
|
||||
add(50, y, 6, false, strings.Repeat("-", 104))
|
||||
y -= 10
|
||||
}
|
||||
|
||||
if y < 155 {
|
||||
newPage(true, false)
|
||||
}
|
||||
add(392, y, 10, true, "Gesamtdauer")
|
||||
add(493, y, 10, true, formatDuration(total))
|
||||
y -= 30
|
||||
|
||||
if notes := strings.TrimSpace(meta.Notes); notes != "" {
|
||||
if y < 145 {
|
||||
newPage(true, false)
|
||||
}
|
||||
add(50, y, 9, true, "Zusammenfassung / Bemerkungen")
|
||||
y -= 16
|
||||
for _, line := range wrapPDFText(notes, 86) {
|
||||
if y < 130 {
|
||||
newPage(true, false)
|
||||
}
|
||||
add(50, y, 9, false, line)
|
||||
y -= 13
|
||||
}
|
||||
y -= 12
|
||||
}
|
||||
|
||||
// Reserve enough room so both signatures always stay together.
|
||||
if y < 175 {
|
||||
newPage(true, false)
|
||||
}
|
||||
add(50, y, 8.5, false, "Die aufgeführten Leistungen und Zeiten wurden erbracht und zur Kenntnis genommen.")
|
||||
y -= 25
|
||||
placeDate := strings.TrimSpace(meta.Place)
|
||||
if placeDate != "" {
|
||||
placeDate += ", "
|
||||
}
|
||||
placeDate += meta.ReportDate
|
||||
add(50, y, 8, true, "Ort / Datum")
|
||||
add(117, y, 8.5, false, clipRunes(placeDate, 65))
|
||||
y -= 47
|
||||
add(50, y, 9, false, "____________________________________")
|
||||
add(330, y, 9, false, "____________________________________")
|
||||
y -= 14
|
||||
add(50, y, 8, true, "Unterschrift Kunde")
|
||||
add(330, y, 8, true, "Unterschrift Techniker")
|
||||
y -= 13
|
||||
add(50, y, 7.5, false, clipRunes(meta.Contact, 40))
|
||||
add(330, y, 7.5, false, clipRunes(technician, 40))
|
||||
|
||||
pages = append(pages, page)
|
||||
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
|
||||
}
|
||||
|
||||
func wrapPDFText(s string, max int) []string {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
var out []string
|
||||
for _, paragraph := range strings.Split(s, "\n") {
|
||||
paragraph = strings.TrimSpace(paragraph)
|
||||
if paragraph == "" {
|
||||
out = append(out, "")
|
||||
continue
|
||||
}
|
||||
words := strings.Fields(paragraph)
|
||||
line := ""
|
||||
for _, word := range words {
|
||||
for len([]rune(word)) > max {
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
line = ""
|
||||
}
|
||||
r := []rune(word)
|
||||
out = append(out, string(r[:max]))
|
||||
word = string(r[max:])
|
||||
}
|
||||
candidate := word
|
||||
if line != "" {
|
||||
candidate = line + " " + word
|
||||
}
|
||||
if len([]rune(candidate)) > max && line != "" {
|
||||
out = append(out, line)
|
||||
line = word
|
||||
} else {
|
||||
line = candidate
|
||||
}
|
||||
}
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildSimplePDF writes a small, standards-compliant PDF using only built-in Type 1
|
||||
// fonts. This avoids pulling a PDF framework into the server binary.
|
||||
func buildSimplePDF(pageW, pageH float64, pages [][]pdfPageLine, exportDate bool, loc *time.Location) []byte {
|
||||
|
||||
Reference in New Issue
Block a user