Files
flancer/internal/app/export.go
jbergner 57f7470310
All checks were successful
release-tag / release-image (push) Successful in 2m6s
Mobile-Update + Bericht
2026-08-14 18:17:38 +02:00

466 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]) + "…"
}