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]) + "…" }