All checks were successful
release-tag / release-image (push) Successful in 2m6s
466 lines
12 KiB
Go
466 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
func makeCSV(entries []Entry, cfg Settings, compact bool) ([]byte, error) {
|
||
loc := location(cfg.Timezone)
|
||
var b bytes.Buffer
|
||
b.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM.
|
||
w := csv.NewWriter(&b)
|
||
w.Comma = ';'
|
||
if compact {
|
||
_ = w.Write([]string{"Datum", "Kunde", "Start", "Ende", "Dauer"})
|
||
} else {
|
||
_ = w.Write([]string{"Datum", "Kunde", "Tätigkeit", "Start", "Ende", "Dauer"})
|
||
}
|
||
for _, e := range entries {
|
||
if e.EndMS == nil {
|
||
continue
|
||
}
|
||
start := time.UnixMilli(e.StartMS).In(loc)
|
||
end := time.UnixMilli(*e.EndMS).In(loc)
|
||
dur := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
|
||
if compact {
|
||
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
|
||
} else {
|
||
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, e.Activity, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
|
||
}
|
||
}
|
||
w.Flush()
|
||
return b.Bytes(), w.Error()
|
||
}
|
||
|
||
type pdfPageLine struct {
|
||
x, y, size float64
|
||
bold bool
|
||
text string
|
||
}
|
||
|
||
func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte {
|
||
loc := location(cfg.Timezone)
|
||
name := strings.TrimSpace(cfg.ExportName)
|
||
if name == "" {
|
||
name = owner.DisplayName
|
||
}
|
||
|
||
const pageW, pageH = 595.0, 842.0 // A4 points.
|
||
var pages [][]pdfPageLine
|
||
var page []pdfPageLine
|
||
y := 795.0
|
||
|
||
newPage := func() {
|
||
if len(page) > 0 {
|
||
pages = append(pages, page)
|
||
}
|
||
page = []pdfPageLine{}
|
||
y = 795
|
||
page = append(page, pdfPageLine{50, y, 19, true, "Zeiterfassung"})
|
||
y -= 24
|
||
page = append(page, pdfPageLine{50, y, 10, false, name})
|
||
y -= 22
|
||
if compact {
|
||
page = append(page,
|
||
pdfPageLine{50, y, 9, true, "Datum"},
|
||
pdfPageLine{112, y, 9, true, "Kunde"},
|
||
pdfPageLine{430, y, 9, true, "Zeit"},
|
||
pdfPageLine{515, y, 9, true, "Dauer"},
|
||
)
|
||
} else {
|
||
page = append(page,
|
||
pdfPageLine{50, y, 9, true, "Datum"},
|
||
pdfPageLine{112, y, 9, true, "Kunde"},
|
||
pdfPageLine{255, y, 9, true, "Tätigkeit"},
|
||
pdfPageLine{430, y, 9, true, "Zeit"},
|
||
pdfPageLine{515, y, 9, true, "Dauer"},
|
||
)
|
||
}
|
||
y -= 16
|
||
}
|
||
newPage()
|
||
|
||
var total int64
|
||
for _, e := range entries {
|
||
if e.EndMS == nil {
|
||
continue
|
||
}
|
||
if y < 65 {
|
||
newPage()
|
||
}
|
||
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
|
||
page = append(page,
|
||
pdfPageLine{50, y, 8.5, false, start.Format("02.01.06")},
|
||
pdfPageLine{112, y, 8.5, false, clipRunes(e.Client, func() int {
|
||
if compact {
|
||
return 50
|
||
}
|
||
return 25
|
||
}())},
|
||
)
|
||
if !compact {
|
||
page = append(page, pdfPageLine{255, y, 8.5, false, clipRunes(e.Activity, 29)})
|
||
}
|
||
page = append(page,
|
||
pdfPageLine{430, y, 8.5, false, formatClock(start, cfg.TimeFormat) + "-" + formatClock(end, cfg.TimeFormat)},
|
||
pdfPageLine{515, y, 8.5, false, formatDuration(d)},
|
||
)
|
||
y -= 15
|
||
}
|
||
if y < 60 {
|
||
newPage()
|
||
}
|
||
page = append(page, pdfPageLine{430, y - 4, 10, true, "Gesamt"}, pdfPageLine{515, y - 4, 10, true, formatDuration(total)})
|
||
pages = append(pages, page)
|
||
|
||
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 {
|
||
objs := make([][]byte, 0)
|
||
add := func(s string) int {
|
||
objs = append(objs, []byte(s))
|
||
return len(objs)
|
||
}
|
||
catalogID := add("")
|
||
pagesID := add("")
|
||
fontID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`)
|
||
boldID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>`)
|
||
|
||
pageIDs := make([]int, 0, len(pages))
|
||
for i, lines := range pages {
|
||
var c strings.Builder
|
||
for _, ln := range lines {
|
||
font := "F1"
|
||
if ln.bold {
|
||
font = "F2"
|
||
}
|
||
fmt.Fprintf(&c, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, ln.size, ln.x, ln.y, pdfEscape(ln.text))
|
||
}
|
||
footer := "Seite " + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(pages))
|
||
if exportDate {
|
||
footer = "Export: " + time.Now().In(loc).Format("02.01.2006") + " - " + footer
|
||
}
|
||
fmt.Fprintf(&c, "BT /F1 7.5 Tf 50 28 Td (%s) Tj ET\n", pdfEscape(footer))
|
||
|
||
content := c.String()
|
||
contentID := add(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content))
|
||
pageID := add(fmt.Sprintf(
|
||
"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 %d 0 R /F2 %d 0 R >> >> /Contents %d 0 R >>",
|
||
pagesID, pageW, pageH, fontID, boldID, contentID,
|
||
))
|
||
pageIDs = append(pageIDs, pageID)
|
||
}
|
||
kids := make([]string, len(pageIDs))
|
||
for i, id := range pageIDs {
|
||
kids[i] = fmt.Sprintf("%d 0 R", id)
|
||
}
|
||
objs[catalogID-1] = []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesID))
|
||
objs[pagesID-1] = []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(pageIDs), strings.Join(kids, " ")))
|
||
|
||
var out bytes.Buffer
|
||
out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")
|
||
offsets := make([]int, len(objs)+1)
|
||
for i, obj := range objs {
|
||
offsets[i+1] = out.Len()
|
||
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
|
||
out.Write(obj)
|
||
out.WriteString("\nendobj\n")
|
||
}
|
||
xref := out.Len()
|
||
fmt.Fprintf(&out, "xref\n0 %d\n", len(objs)+1)
|
||
out.WriteString("0000000000 65535 f \n")
|
||
for i := 1; i <= len(objs); i++ {
|
||
fmt.Fprintf(&out, "%010d 00000 n \n", offsets[i])
|
||
}
|
||
fmt.Fprintf(&out, "trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, catalogID, xref)
|
||
return out.Bytes()
|
||
}
|
||
|
||
func pdfEscape(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
var c byte
|
||
switch r {
|
||
case '–', '—':
|
||
c = '-'
|
||
case '€':
|
||
c = 0x80
|
||
case '“', '”':
|
||
c = '"'
|
||
case '’':
|
||
c = '\''
|
||
default:
|
||
if r >= 32 && r <= 255 {
|
||
c = byte(r)
|
||
} else if r == '\n' || r == '\r' {
|
||
c = ' '
|
||
} else {
|
||
c = '?'
|
||
}
|
||
}
|
||
if c == '(' || c == ')' || c == '\\' {
|
||
b.WriteByte('\\')
|
||
}
|
||
b.WriteByte(c)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func formatClock(t time.Time, f string) string {
|
||
if f == "12" {
|
||
return t.Format("03:04 PM")
|
||
}
|
||
return t.Format("15:04")
|
||
}
|
||
|
||
func formatDuration(ms int64) string {
|
||
if ms < 0 {
|
||
ms = 0
|
||
}
|
||
mins := (ms + 30_000) / 60_000
|
||
return fmt.Sprintf("%d:%02d", mins/60, mins%60)
|
||
}
|
||
|
||
func location(name string) *time.Location {
|
||
if name != "" {
|
||
if x, err := time.LoadLocation(name); err == nil {
|
||
return x
|
||
}
|
||
}
|
||
return time.UTC
|
||
}
|
||
|
||
func clipRunes(s string, max int) string {
|
||
r := []rune(strings.TrimSpace(s))
|
||
if len(r) <= max {
|
||
return string(r)
|
||
}
|
||
if max < 2 {
|
||
return string(r[:max])
|
||
}
|
||
return string(r[:max-1]) + "…"
|
||
}
|