From 57f74703101158d1d720e0db0ee713066f5b6479 Mon Sep 17 00:00:00 2001 From: jbergner Date: Fri, 14 Aug 2026 18:17:38 +0200 Subject: [PATCH] Mobile-Update + Bericht --- README.md | 31 ++++++ internal/app/db.go | 56 +++++++++- internal/app/export.go | 212 ++++++++++++++++++++++++++++++++++++ internal/app/export_test.go | 27 +++++ internal/app/filter_test.go | 16 +++ internal/app/server.go | 78 ++++++++++++- internal/app/web/app.css | 88 +++++++++++++++ internal/app/web/app.js | 77 ++++++++++++- internal/app/web/index.html | 39 ++++++- 9 files changed, 608 insertions(+), 16 deletions(-) create mode 100644 internal/app/filter_test.go diff --git a/README.md b/README.md index 2e92e94..7a2677b 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ Die Anwendung besteht aus einem Go-Binary, eingebettetem HTML/CSS/Vanilla-JavaSc - Rundung 1–60 Minuten, normal oder immer aufwärts - CSV-Export (Semikolon + UTF-8 BOM für Excel) - PDF-Export ohne PDF-Framework +- Service-Bericht als PDF pro Kunde mit Tätigkeitsübersicht, Auftrag/Ticket, Ansprechpartner, Einsatzort, Bemerkungen und Unterschriftsfeldern für Kunde/Techniker - Export wahlweise für aktuelle Ansicht oder freien Datumsbereich, auf-/absteigend und kompakt - Responsive Desktop-/Mobile-Oberfläche +- Mobile Kompaktansicht: startet mit dem Timer im Fokus; Verlauf/Übersicht und Einstellungen werden separat über die untere Navigation geöffnet - Mehrbenutzerbetrieb mit strikt getrennten Daten - Admin-/Benutzerrollen - Benutzer anlegen, deaktivieren und Passwörter zurücksetzen @@ -135,6 +137,35 @@ Schreibende API-Aufrufe benötigen zusätzlich ein zufälliges, sitzungsgebunden Jede SQL-Operation auf Zeiten enthält die `user_id` aus der authentifizierten Session. IDs aus einem anderen Benutzerkonto reichen daher nicht aus, um fremde Einträge zu lesen oder zu verändern. +## Mobile Kompaktansicht + +Auf Smartphones (bis 820 px Breite) ist standardmäßig die **Kompaktansicht** aktiv. Dabei verschwindet die Desktop-Kopfleiste und die Timer-Erfassung nutzt nahezu den gesamten verfügbaren Bildschirm. Kunde, Tätigkeit, Timer, Start/Stop und die Tages-/Wochensummen bleiben direkt sichtbar. + +Die untere mobile Navigation trennt die Bereiche bewusst: + +- **Timer** – konzentrierte Zeiterfassung +- **Übersicht** – Verlauf, Suche, Filter, Nachtragen und Export +- **Mehr** – Einstellungen, Benutzerverwaltung (für Admins) und Abmelden + +Die Einstellung **„Kompaktansicht auf Mobilgeräten“** kann pro Benutzer deaktiviert werden. Sie wird in SQLite in `user_settings.mobile_compact` gespeichert. Bestehende Datenbanken werden beim Start automatisch um die neue Spalte ergänzt; der Standardwert ist aktiviert. + +## Service-Berichte + +Im Exportdialog steht zusätzlich **„Service-Bericht für Unterschrift“** zur Verfügung. Der Bericht verwendet den gewählten Exportzeitraum und filtert anschließend exakt auf einen Kunden. Dadurch können keine Tätigkeiten anderer Kunden versehentlich in denselben unterschreibbaren Bericht geraten. + +Der Service-Bericht enthält: + +- Kunde und Ansprechpartner +- Einsatzort +- Auftrags-/Ticketnummer +- Betreff sowie optionale Zusammenfassung/Bemerkungen +- chronologische Tätigkeiten mit Datum, Uhrzeit und gerundeter Dauer +- Gesamtdauer +- Ort und Berichtsdatum +- getrennte Unterschriftsfelder für Kunde und Techniker + +Die zusätzlichen Berichtsdaten werden per `POST /api/service-report.pdf` übertragen und nicht als Query-Parameter in der Download-URL abgelegt. Es werden keine Service-Berichte oder Unterschriften dauerhaft in SQLite gespeichert; der PDF-Export wird bei Bedarf erzeugt. + ## Backup Wegen WAL sollte die Datenbank nicht blind während Schreibzugriffen als einzelne Datei kopiert werden. Der einfachste konsistente Weg bei Docker Compose: diff --git a/internal/app/db.go b/internal/app/db.go index f100d28..372f2aa 100644 --- a/internal/app/db.go +++ b/internal/app/db.go @@ -29,6 +29,7 @@ type Settings struct { ShowWeekTotal bool `json:"show_week_total"` StickyDays bool `json:"sticky_days"` LongRunReminder bool `json:"long_run_reminder"` + MobileCompact bool `json:"mobile_compact"` ExportName string `json:"export_name"` Timezone string `json:"timezone"` ExportDate bool `json:"export_date"` @@ -135,12 +136,49 @@ CREATE TABLE IF NOT EXISTS user_settings ( show_week_total INTEGER NOT NULL DEFAULT 1 CHECK(show_week_total IN (0,1)), sticky_days INTEGER NOT NULL DEFAULT 1 CHECK(sticky_days IN (0,1)), long_run_reminder INTEGER NOT NULL DEFAULT 1 CHECK(long_run_reminder IN (0,1)), + mobile_compact INTEGER NOT NULL DEFAULT 1 CHECK(mobile_compact IN (0,1)), export_name TEXT NOT NULL DEFAULT '', timezone TEXT NOT NULL DEFAULT 'UTC', export_date INTEGER NOT NULL DEFAULT 1 CHECK(export_date IN (0,1)) ); ` - _, err := s.db.ExecContext(ctx, schema) + if _, err := s.db.ExecContext(ctx, schema); err != nil { + return err + } + return s.ensureMobileCompactColumn(ctx) +} + +// ensureMobileCompactColumn upgrades databases created before the mobile compact +// view existed. SQLite has no ADD COLUMN IF NOT EXISTS, so inspect the table first. +func (s *store) ensureMobileCompactColumn(ctx context.Context) error { + rows, err := s.db.QueryContext(ctx, `PRAGMA table_info(user_settings)`) + if err != nil { + return err + } + found := false + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var defaultValue sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + rows.Close() + return err + } + if name == "mobile_compact" { + found = true + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if found { + return nil + } + _, err = s.db.ExecContext(ctx, `ALTER TABLE user_settings ADD COLUMN mobile_compact INTEGER NOT NULL DEFAULT 1 CHECK(mobile_compact IN (0,1))`) return err } @@ -286,14 +324,15 @@ func (s *store) resetPassword(ctx context.Context, id, hash string) error { func (s *store) settings(ctx context.Context, userID string) (Settings, error) { var x Settings - var up, week, sticky, reminder, exportDate int - err := s.db.QueryRowContext(ctx, `SELECT language,time_format,rounding_minutes,round_up,show_week_total,sticky_days,long_run_reminder,export_name,timezone,export_date FROM user_settings WHERE user_id=?`, userID).Scan( - &x.Language, &x.TimeFormat, &x.RoundingMinutes, &up, &week, &sticky, &reminder, &x.ExportName, &x.Timezone, &exportDate, + var up, week, sticky, reminder, mobileCompact, exportDate int + err := s.db.QueryRowContext(ctx, `SELECT language,time_format,rounding_minutes,round_up,show_week_total,sticky_days,long_run_reminder,mobile_compact,export_name,timezone,export_date FROM user_settings WHERE user_id=?`, userID).Scan( + &x.Language, &x.TimeFormat, &x.RoundingMinutes, &up, &week, &sticky, &reminder, &mobileCompact, &x.ExportName, &x.Timezone, &exportDate, ) x.RoundUp = up == 1 x.ShowWeekTotal = week == 1 x.StickyDays = sticky == 1 x.LongRunReminder = reminder == 1 + x.MobileCompact = mobileCompact == 1 x.ExportDate = exportDate == 1 return x, err } @@ -305,8 +344,8 @@ func (s *store) updateSettings(ctx context.Context, userID string, x Settings) e } return 0 } - _, err := s.db.ExecContext(ctx, `UPDATE user_settings SET language=?,time_format=?,rounding_minutes=?,round_up=?,show_week_total=?,sticky_days=?,long_run_reminder=?,export_name=?,timezone=?,export_date=? WHERE user_id=?`, - x.Language, x.TimeFormat, x.RoundingMinutes, boolInt(x.RoundUp), boolInt(x.ShowWeekTotal), boolInt(x.StickyDays), boolInt(x.LongRunReminder), strings.TrimSpace(x.ExportName), x.Timezone, boolInt(x.ExportDate), userID) + _, err := s.db.ExecContext(ctx, `UPDATE user_settings SET language=?,time_format=?,rounding_minutes=?,round_up=?,show_week_total=?,sticky_days=?,long_run_reminder=?,mobile_compact=?,export_name=?,timezone=?,export_date=? WHERE user_id=?`, + x.Language, x.TimeFormat, x.RoundingMinutes, boolInt(x.RoundUp), boolInt(x.ShowWeekTotal), boolInt(x.StickyDays), boolInt(x.LongRunReminder), boolInt(x.MobileCompact), strings.TrimSpace(x.ExportName), x.Timezone, boolInt(x.ExportDate), userID) return err } @@ -381,6 +420,7 @@ func (s *store) deleteEntry(ctx context.Context, userID, id string) error { type entryFilter struct { Query string + Client string FromMS int64 ToMS int64 Limit int @@ -471,6 +511,10 @@ func buildEntryWhere(userID string, f entryFilter) (string, []any) { where += ` AND start_ms 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 { diff --git a/internal/app/export_test.go b/internal/app/export_test.go index 54f749e..bac0182 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -26,3 +26,30 @@ func TestCSVCompactOmitsActivity(t *testing.T) { t.Fatal("compact CSV contains activity") } } + +func TestServiceReportPDFContainsSignatureFields(t *testing.T) { + end := int64(3_600_000) + pdf := makeServiceReportPDF( + []Entry{{ID: "x", Client: "ACME", Activity: "Router geprüft und Firmware aktualisiert", StartMS: 0, EndMS: &end}}, + Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, + User{DisplayName: "Max Techniker", Username: "max"}, + serviceReportMeta{Client: "ACME", Contact: "Erika Kunde", OrderNumber: "SR-42", ReportDate: "14.08.2026"}, + ) + for _, want := range [][]byte{[]byte("%PDF-1.4"), []byte("SERVICE-BERICHT"), []byte("ACME"), []byte("Gesamtdauer"), []byte("Unterschrift Kunde"), []byte("Unterschrift Techniker")} { + if !bytes.Contains(pdf, want) { + t.Fatalf("service report missing %q", want) + } + } +} + +func TestWrapPDFText(t *testing.T) { + lines := wrapPDFText("eins zwei drei vier", 9) + if len(lines) < 2 { + t.Fatalf("expected wrapped lines, got %#v", lines) + } + for _, line := range lines { + if len([]rune(line)) > 9 { + t.Fatalf("line too long: %q", line) + } + } +} diff --git a/internal/app/filter_test.go b/internal/app/filter_test.go new file mode 100644 index 0000000..5625a97 --- /dev/null +++ b/internal/app/filter_test.go @@ -0,0 +1,16 @@ +package app + +import ( + "strings" + "testing" +) + +func TestBuildEntryWhereExactClient(t *testing.T) { + where, args := buildEntryWhere("user-1", entryFilter{Client: " ACME "}) + if !strings.Contains(where, "client = ? COLLATE NOCASE") { + t.Fatalf("exact client condition missing: %s", where) + } + if len(args) != 2 || args[0] != "user-1" || args[1] != "ACME" { + t.Fatalf("unexpected args: %#v", args) + } +} diff --git a/internal/app/server.go b/internal/app/server.go index bf21d08..a683ecd 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -92,6 +92,7 @@ func (a *App) routes() { api.HandleFunc("DELETE /api/entries/{id}", a.deleteEntry) api.HandleFunc("GET /api/export.csv", a.exportCSV) api.HandleFunc("GET /api/export.pdf", a.exportPDF) + api.HandleFunc("POST /api/service-report.pdf", a.serviceReportPDF) api.HandleFunc("GET /api/admin/users", a.adminUsers) api.HandleFunc("POST /api/admin/users", a.adminCreateUser) api.HandleFunc("PATCH /api/admin/users/{id}", a.adminPatchUser) @@ -505,6 +506,76 @@ func (a *App) exportPDF(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(b) } +func (a *App) serviceReportPDF(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var in struct { + Client string `json:"client"` + Contact string `json:"contact"` + Location string `json:"location"` + OrderNumber string `json:"order_number"` + Subject string `json:"subject"` + Notes string `json:"notes"` + Place string `json:"place"` + ReportDate string `json:"report_date"` + Query string `json:"q"` + FromMS int64 `json:"from_ms"` + ToMS int64 `json:"to_ms"` + } + if !decodeJSON(w, r, &in) { + return + } + in.Client = strings.TrimSpace(in.Client) + if in.Client == "" { + jsonError(w, 400, "client", "Bitte einen Kunden für den Service-Bericht auswählen.") + return + } + if len(in.Client) > 200 || len(in.Contact) > 200 || len(in.Location) > 400 || len(in.OrderNumber) > 120 || len(in.Subject) > 200 || len(in.Notes) > 6000 || len(in.Place) > 120 || len(in.Query) > 200 { + jsonError(w, 400, "too_long", "Ein Feld im Service-Bericht ist zu lang.") + return + } + if in.FromMS < 0 || in.ToMS < 0 || (in.FromMS > 0 && in.ToMS > 0 && in.ToMS <= in.FromMS) { + jsonError(w, 400, "period", "Ungültiger Berichtszeitraum.") + return + } + reportDate := "" + if strings.TrimSpace(in.ReportDate) != "" { + d, err := time.Parse("2006-01-02", in.ReportDate) + if err != nil { + jsonError(w, 400, "report_date", "Ungültiges Berichtsdatum.") + return + } + reportDate = d.Format("02.01.2006") + } + + entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, entryFilter{ + Query: strings.TrimSpace(in.Query), Client: in.Client, FromMS: in.FromMS, ToMS: in.ToMS, SortAsc: true, + }) + if err != nil { + jsonError(w, 500, "db", "Service-Bericht konnte nicht erstellt werden.") + return + } + if len(entries) == 0 { + jsonError(w, 400, "empty", "Für diesen Kunden und Zeitraum wurden keine abgeschlossenen Tätigkeiten gefunden.") + return + } + cfg, err := a.store.settings(r.Context(), sessionOf(r).User.ID) + if err != nil { + jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.") + return + } + pdf := makeServiceReportPDF(entries, cfg, sessionOf(r).User, serviceReportMeta{ + Client: in.Client, Contact: strings.TrimSpace(in.Contact), Location: strings.TrimSpace(in.Location), + OrderNumber: strings.TrimSpace(in.OrderNumber), Subject: strings.TrimSpace(in.Subject), Notes: strings.TrimSpace(in.Notes), + Place: strings.TrimSpace(in.Place), ReportDate: reportDate, + }) + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="service-bericht.pdf"`) + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(pdf) +} + func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) { if !requireAdmin(w, r) { return @@ -623,7 +694,12 @@ func parseFilter(w http.ResponseWriter, r *http.Request) (entryFilter, bool) { } offset = n } - return entryFilter{Query: q.Get("q"), FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true + client := strings.TrimSpace(q.Get("client")) + if len(client) > 200 { + jsonError(w, 400, "client", "Kunde ist zu lang.") + return entryFilter{}, false + } + return entryFilter{Query: q.Get("q"), Client: client, FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true } func parseInt64Param(w http.ResponseWriter, q url.Values, key string) (int64, bool) { x := q.Get(key) diff --git a/internal/app/web/app.css b/internal/app/web/app.css index 50f0682..f7fbcc0 100644 --- a/internal/app/web/app.css +++ b/internal/app/web/app.css @@ -138,6 +138,7 @@ button { border: 0; cursor: pointer; } .export-compact { align-self: end; min-height: 42px; padding-bottom: 9px; } .export-preview { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--bg); border: 1px solid var(--border); color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } .export-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.service-report-button { grid-column: 1 / -1; } .muted { color: var(--muted); } .small { font-size: 12px; } .toast { position: fixed; z-index: 30; bottom: 24px; left: 50%; transform: translateX(-50%); background: #25292e; border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; box-shadow: var(--shadow); font-size: 13px; } @@ -198,3 +199,90 @@ button { border: 0; cursor: pointer; } .history-actions .btn { padding-inline: 10px; } .auth-card { padding: 23px; } } + +/* Mobile compact view: timer first, overview as a separate screen. */ +.mobile-view-nav, .mobile-compact-only { display: none; } + +@media (max-width: 820px) { + body.mobile-compact .topbar { display: none; } + body.mobile-compact .app-shell { height: 100dvh; } + body.mobile-compact .main-grid { min-height: 0; flex: 1 1 auto; } + body.mobile-compact .tracker-pane, + body.mobile-compact .history-pane { height: 100%; min-height: 0; } + body.mobile-compact .tracker-pane { padding: 15px; gap: 14px; } + body.mobile-compact .mobile-pane-head, + body.mobile-compact .sidebar-footer { display: none; } + + body.mobile-compact .tracker-card { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 18px; + padding: 24px 26px; + border-radius: 28px; + } + body.mobile-compact .tracker-date { font-size: 15px; } + body.mobile-compact .field-label { gap: 10px; font-size: 15px; } + body.mobile-compact .tracker-card input, + body.mobile-compact .tracker-card textarea { border-radius: 18px; padding: 15px 16px; font-size: 16px; } + body.mobile-compact .tracker-card input { min-height: 58px; } + body.mobile-compact .activity-field { display: flex; flex: 1 1 180px; min-height: 0; flex-direction: column; } + body.mobile-compact .activity-field textarea { flex: 1 1 auto; min-height: 120px; resize: none; } + body.mobile-compact .timer-display { padding-top: 4px; font-size: clamp(40px, 11vw, 58px); } + body.mobile-compact .timer-btn { min-height: 64px; border-radius: 18px; font-size: 18px; } + body.mobile-compact .metric-grid { gap: 12px; } + body.mobile-compact .metric-card { min-height: 92px; justify-content: center; border-radius: 22px; padding: 18px 20px; } + body.mobile-compact .metric-card span { font-size: 13px; } + body.mobile-compact .metric-card strong { font-size: 24px; } + + body.mobile-compact .history-title .mobile-only { display: none; } + body.mobile-compact .history-actions #history-export { display: inline-block; } + + body.mobile-compact .mobile-view-nav { + flex: 0 0 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; + padding: 6px 10px calc(6px + env(safe-area-inset-bottom)); + background: rgba(17, 19, 22, .98); + border-top: 1px solid var(--divider); + } + body.mobile-compact .mobile-nav-button { + min-height: 48px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + border-radius: 13px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 750; + } + body.mobile-compact .mobile-nav-button:hover { background: var(--raised); color: var(--text); } + body.mobile-compact .mobile-nav-button.active { background: var(--raised); color: var(--amber); } + body.mobile-compact .mobile-nav-icon { font-size: 18px; line-height: 1; } + body.mobile-compact .mobile-compact-only { display: inline-flex; } +} + +@media (max-width: 430px) { + body.mobile-compact .tracker-card { padding: 20px; gap: 15px; border-radius: 24px; } + body.mobile-compact .tracker-card input, + body.mobile-compact .tracker-card textarea { padding: 13px 14px; } + body.mobile-compact .tracker-card input { min-height: 52px; } + body.mobile-compact .timer-btn { min-height: 58px; } + body.mobile-compact .metric-card { min-height: 82px; padding: 14px 16px; } + body.mobile-compact .mobile-nav-button { gap: 5px; } +} + +@media (max-height: 720px) and (max-width: 820px) { + body.mobile-compact .tracker-pane { gap: 9px; padding: 9px; } + body.mobile-compact .tracker-card { gap: 10px; padding: 15px; border-radius: 20px; } + body.mobile-compact .tracker-card input { min-height: 44px; padding-block: 10px; } + body.mobile-compact .activity-field textarea { min-height: 80px; padding-block: 10px; } + body.mobile-compact .timer-display { font-size: 36px; } + body.mobile-compact .timer-btn { min-height: 48px; } + body.mobile-compact .metric-card { min-height: 66px; padding: 10px 13px; border-radius: 16px; } + body.mobile-compact .mobile-nav-button { min-height: 42px; } +} diff --git a/internal/app/web/app.js b/internal/app/web/app.js index 632607d..064376e 100644 --- a/internal/app/web/app.js +++ b/internal/app/web/app.js @@ -14,6 +14,23 @@ const state = { const $ = (s) => document.querySelector(s); const $$ = (s) => [...document.querySelectorAll(s)]; +function applyMobileCompactPreference(enabled = true) { + document.body.classList.toggle('mobile-compact', !!enabled); + const checkbox = $('#setting-mobile-compact'); + if (checkbox) checkbox.checked = !!enabled; + syncMobileNav(); +} +function switchMobileView(view) { + document.body.classList.toggle('mobile-history', view === 'history'); + syncMobileNav(); +} +function syncMobileNav() { + const history = document.body.classList.contains('mobile-history'); + const track = $('#mobile-nav-track'), overview = $('#mobile-nav-history'); + if (track) { track.classList.toggle('active', !history); if (!history) track.setAttribute('aria-current','page'); else track.removeAttribute('aria-current'); } + if (overview) { overview.classList.toggle('active', history); if (history) overview.setAttribute('aria-current','page'); else overview.removeAttribute('aria-current'); } +} + async function api(url, options = {}) { const headers = { ...(options.headers || {}) }; if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'; @@ -103,6 +120,7 @@ async function init() { const me = await api('/api/me'); state.me = me.user; state.csrf = me.csrf_token; const [settings, running, clients] = await Promise.all([api('/api/settings'), api('/api/running'), api('/api/clients')]); state.settings = settings; state.running = running.entry; state.clients = clients.clients || []; + applyMobileCompactPreference(state.settings.mobile_compact !== false); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') { state.settings.timezone = tz; @@ -210,9 +228,9 @@ function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e async function saveEntry(ev){ev.preventDefault();hideError('#entry-error');const id=$('#entry-id').value;const start=msFromLocalValue($('#edit-start').value),end=msFromLocalValue($('#edit-end').value);if(!start||!end||end(e.client||'').trim()).filter(Boolean))]; + if(!client&&visible.length===1) client=visible[0]; + if(!client&&state.filter.q){const exact=state.clients.find(x=>x.toLowerCase()===state.filter.q.toLowerCase());if(exact)client=exact;} + $('#service-client').value=client; + if(!$('#service-date').value) $('#service-date').value=dateInputValue(new Date()); + $('#export-dialog').close(); + $('#service-report-dialog').showModal(); + updateServicePreview(); +} +async function downloadServiceReport(ev){ + ev.preventDefault();hideError('#service-error'); + const submit=$('#service-report-form button[type="submit"]');submit.disabled=true; + const body={...serviceFilterPayload(),client:$('#service-client').value.trim(),contact:$('#service-contact').value.trim(),location:$('#service-location').value.trim(),order_number:$('#service-order').value.trim(),subject:$('#service-subject').value.trim(),notes:$('#service-notes').value.trim(),place:$('#service-place').value.trim(),report_date:$('#service-date').value}; + try{ + const res=await fetch('/api/service-report.pdf',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':state.csrf},body:JSON.stringify(body)}); + if(res.status===401){location.assign('/login');return;} + if(!res.ok){const ct=res.headers.get('content-type')||'';const x=ct.includes('application/json')?await res.json().catch(()=>({})):await res.text();throw new Error(x?.error?.message||x||`HTTP ${res.status}`);} + const blob=await res.blob(),url=URL.createObjectURL(blob),a=document.createElement('a'); + a.href=url;a.download='service-bericht.pdf';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000); + $('#service-report-dialog').close();toast('Service-Bericht erstellt.'); + }catch(e){showError('#service-error',e);}finally{submit.disabled=false;} +} + +async function logout() { + if(!confirm('Abmelden?')) return; + try { await api('/api/logout',{method:'POST',body:'{}'}); } finally { location.assign('/login'); } +} + function wireEvents(){ $('#timer-button').addEventListener('click',timerToggle); $('#activity-input').addEventListener('blur',()=>saveRunningActivity().catch(e=>toast(e.message))); $('#edit-running').addEventListener('click',()=>openEntry(null,true)); $('#add-entry').addEventListener('click',()=>openEntry()); $('#entry-form').addEventListener('submit',saveEntry); $('#delete-entry').addEventListener('click',deleteEntry); $('#edit-start').addEventListener('input',updateEditDuration); $('#edit-end').addEventListener('input',updateEditDuration); $('#settings-open').addEventListener('click',openSettings); $('#settings-form').addEventListener('submit',saveSettings); $('#change-own-password').addEventListener('click',()=>{hideError('#account-password-error');$('#account-password-form').reset();$('#account-password-dialog').showModal()}); $('#account-password-form').addEventListener('submit',changeOwnPassword); $('#add-user').addEventListener('click',()=>{hideError('#user-error');$('#user-dialog').showModal()}); $('#user-form').addEventListener('submit',createUser); $('#password-form').addEventListener('submit',resetPassword); - $('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview); - $('#account-menu').addEventListener('click',async()=>{if(!confirm('Abmelden?'))return;try{await api('/api/logout',{method:'POST',body:'{}'});}finally{location.assign('/login')}}); + $('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $('#export-service').addEventListener('click',openServiceReport); $('#service-report-form').addEventListener('submit',downloadServiceReport); let serviceClientTimer; $('#service-client').addEventListener('input',()=>{clearTimeout(serviceClientTimer);serviceClientTimer=setTimeout(updateServicePreview,220)}); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview); + $('#account-menu').addEventListener('click',logout); $('#mobile-logout').addEventListener('click',logout); let searchTimer; $('#search-input').addEventListener('input',e=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.filter.q=e.target.value.trim();state.filter.offset=0;refreshEntries(true)},220)}); $$('.period-tabs button').forEach(b=>b.addEventListener('click',()=>{$$('.period-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.filter.period=b.dataset.period;state.filter.anchor=new Date();state.filter.offset=0;refreshEntries(true)})); $('#period-prev').addEventListener('click',()=>stepPeriod(-1)); $('#period-next').addEventListener('click',()=>stepPeriod(1)); $('#load-more').addEventListener('click',()=>{state.filter.offset=state.entries.length;refreshEntries(false)}); $$('[data-close]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.close).close())); $$('dialog').forEach(d=>d.addEventListener('click',e=>{const r=d.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)d.close()})); - $('#mobile-history').addEventListener('click',()=>document.body.classList.add('mobile-history')); $('#mobile-track').addEventListener('click',()=>document.body.classList.remove('mobile-history')); + $('#mobile-history').addEventListener('click',()=>switchMobileView('history')); $('#mobile-track').addEventListener('click',()=>switchMobileView('track')); $('#mobile-nav-track').addEventListener('click',()=>switchMobileView('track')); $('#mobile-nav-history').addEventListener('click',()=>switchMobileView('history')); $('#mobile-nav-settings').addEventListener('click',openSettings); } wireEvents(); init(); diff --git a/internal/app/web/index.html b/internal/app/web/index.html index 5e60fb5..ca950a9 100644 --- a/internal/app/web/index.html +++ b/internal/app/web/index.html @@ -8,7 +8,7 @@ Pocketwatch - +
pocketwatch
@@ -29,7 +29,7 @@ -
@@ -96,6 +102,7 @@ +

Export

@@ -111,7 +118,7 @@
- +
@@ -164,10 +171,34 @@

0 Einträge · 0:00

-
+
+ + + +