diff --git a/GerbilManager.Tests/ExportTests.cs b/GerbilManager.Tests/ExportTests.cs new file mode 100644 index 0000000..3e56061 --- /dev/null +++ b/GerbilManager.Tests/ExportTests.cs @@ -0,0 +1,144 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.Export; +using GerbilManagerWebAPI.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace GerbilManager.Tests +{ + /// + /// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export + /// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile). + /// + public class ExportTests + { + // ── CSV-Escaping ─────────────────────────────────────────────────── + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten + [InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten + [InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln + [InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten + [InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV) + public void Escape_behandelt_Sonderfälle(string? input, string expected) + => Assert.Equal(expected, ExportService.Escape(input)); + + [Fact] + public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen() + { + var csv = ExportService.BuildCsv( + ["Name", "Notizen"], + [["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]); + + var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.Equal("Name;Notizen", lines[0]); + Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]); + Assert.Equal("Bo;", lines[2]); + } + + // ── Round-Trip über die API ──────────────────────────────────────── + + [Fact] + public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten() + { + using var factory = new ApiFactory(); + + // Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht + Guid gerbilId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" }; + var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" }; + var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 }; + var colorVariety = db.ColorVarieties.First(); // HasData-Seed + var gerbil = new Gerbil + { + Id = Guid.NewGuid(), + Name = "Krümel", + Gender = Gender.female, + Status = GerbilStatus.Active, + DateOfBirth = new DateOnly(2025, 3, 12), + LitterId = litter.Id, + EnclosureId = enclosure.Id, + OriginContactId = contact.Id, + ColorVarietyId = colorVariety.Id, + Genotype = "Aa CC Dd EE GG Pp Spsp rere", + ImportSource = "Stammbaum von Akio Kids.xlsx", + }; + gerbilId = gerbil.Id; + db.AddRange(contact, enclosure, litter, gerbil, + new HealthRecord + { + Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15), + Type = HealthRecordType.Vaccination, Description = "Jahresimpfung", + CreatedAt = DateTimeOffset.UtcNow, + }, + new WeightRecord + { + Id = Guid.NewGuid(), GerbilId = gerbil.Id, + Date = new DateOnly(2026, 5, 1), WeightGrams = 78, + }); + await db.SaveChangesAsync(); + } + + var client = factory.CreateClient(); + var response = await client.GetAsync("/export"); + response.EnsureSuccessStatusCode(); + Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); + Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName); + + using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read); + string[] expectedEntries = + ["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv", + "gesundheit.csv", "gewichte.csv", "LIESMICH.txt"]; + foreach (var name in expectedEntries) + Assert.NotNull(zip.GetEntry(name)); + + // export.json: Zähler + volle Treue (Genotyp, Import-Herkunft) + using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _)); + var root = json.RootElement; + Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength()); + Assert.Equal(1, root.GetProperty("litters").GetArrayLength()); + Assert.Equal(1, root.GetProperty("contacts").GetArrayLength()); + Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed + var g = root.GetProperty("gerbils")[0]; + Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString()); + Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString()); + Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString()); + + // tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum + var tiere = ReadEntry(zip, "tiere.csv", out var hadBom); + Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel"); + var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]); + var row = lines[1]; + Assert.Contains("Krümel", row); + Assert.Contains("Weiblich", row); + Assert.Contains("Aktiv", row); + Assert.Contains("12.03.2025", row); + Assert.Contains("Großbecken", row); + Assert.Contains("Wurf K", row); + + // kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen) + var kontakte = ReadEntry(zip, "kontakte.csv", out _); + Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte); + + // LIESMICH erklärt den Foto-Speicherort + Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _)); + } + + private static string ReadEntry(ZipArchive zip, string name, out bool hadBom) + { + using var stream = zip.GetEntry(name)!.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + var bytes = ms.ToArray(); + hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; + return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0)); + } + } +} diff --git a/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs new file mode 100644 index 0000000..7f0ea52 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs @@ -0,0 +1,36 @@ +using GerbilManagerWebAPI.Export; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// EXPORT-1: GET /export — komplette Datensicherung als Zip + /// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos). + /// + public static class ExportEndpoints + { + public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) => + { + var data = new ExportService.ExportData( + await db.Gerbils.AsNoTracking().ToListAsync(ct), + await db.Litters.AsNoTracking().ToListAsync(ct), + await db.Contacts.AsNoTracking().ToListAsync(ct), + await db.Enclosures.AsNoTracking().ToListAsync(ct), + await db.ColorVarieties.AsNoTracking().ToListAsync(ct), + await db.HealthRecords.AsNoTracking().ToListAsync(ct), + await db.WeightRecords.AsNoTracking().ToListAsync(ct), + await db.GerbilPhotos.AsNoTracking().ToListAsync(ct)); + + var today = DateOnly.FromDateTime(DateTime.Now); + var bytes = ExportService.BuildZip(data, today); + return Results.File(bytes, "application/zip", + $"rennmaus-export-{today:yyyy-MM-dd}.zip"); + }) + .WithTags("Export"); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Export/ExportService.cs b/GerbilManagerWebAPI/Export/ExportService.cs new file mode 100644 index 0000000..9f83ff5 --- /dev/null +++ b/GerbilManagerWebAPI/Export/ExportService.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GerbilManagerWebAPI.Models; + +namespace GerbilManagerWebAPI.Export +{ + /// + /// EXPORT-1: user-facing Datenexport — builds a zip containing + /// - export.json (full fidelity: every entity incl. genotypes + import provenance) + /// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers, + /// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel) + /// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip) + /// + /// Pure function over (no DbContext) so the round-trip + /// and escaping tests run without a database. + /// + public static class ExportService + { + public sealed record ExportData( + List Gerbils, + List Litters, + List Contacts, + List Enclosures, + List ColorVarieties, + List HealthRecords, + List WeightRecords, + List Photos); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + WriteIndented = true, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + }; + + // German labels for enum values (CSV is the wife-facing backup format; + // the JSON keeps the enum member names for lossless re-import). + private static readonly Dictionary GenderDe = new() + { + [Gender.unknown] = "Unbekannt", + [Gender.male] = "Männlich", + [Gender.female] = "Weiblich", + }; + + private static readonly Dictionary StatusDe = new() + { + [GerbilStatus.Active] = "Aktiv", + [GerbilStatus.Deceased] = "Verstorben", + [GerbilStatus.GivenAway] = "Abgegeben", + }; + + private static readonly Dictionary HealthTypeDe = new() + { + [HealthRecordType.Examination] = "Untersuchung", + [HealthRecordType.Treatment] = "Behandlung", + [HealthRecordType.Injury] = "Verletzung", + [HealthRecordType.Vaccination] = "Impfung", + [HealthRecordType.Other] = "Sonstiges", + }; + + public static byte[] BuildZip(ExportData data, DateOnly exportDate) + { + var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name); + var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name); + var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name); + var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name); + var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name); + + string? Lookup(Dictionary map, TKey? key) where TKey : struct => + key is null ? null : map.GetValueOrDefault(key.Value); + + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddText(zip, "export.json", JsonSerializer.Serialize(new + { + exportedAt = exportDate.ToString("yyyy-MM-dd"), + data.Gerbils, + data.Litters, + data.Contacts, + data.Enclosures, + data.ColorVarieties, + data.HealthRecords, + data.WeightRecords, + data.Photos, + }, JsonOptions), bom: false); + + AddCsv(zip, "tiere.csv", + ["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache", + "Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer", + "Genotyp", "Notizen"], + data.Gerbils.OrderBy(g => g.Name).Select(g => new[] + { + g.Name, GenderDe[g.Gender], StatusDe[g.Status], + De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath, + De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId), + Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId), + Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId), + g.Genotype, g.Notes, + })); + + AddCsv(zip, "wuerfe.csv", + ["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke", + "Voraussichtliches Abgabedatum", "Notizen"], + data.Litters.OrderBy(l => l.Date).Select(l => new[] + { + l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId), + l.TotalBorn?.ToString(CultureInfo.InvariantCulture), + De(l.ExpectedGoHomeDate), l.Notes, + })); + + AddCsv(zip, "kontakte.csv", + ["Name", "E-Mail", "Telefon", "Adresse", "Notizen"], + data.Contacts.OrderBy(c => c.Name).Select(c => new[] + { + c.Name, c.Email, c.Phone, c.Address, c.Notes, + })); + + AddCsv(zip, "gesundheit.csv", + ["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"], + data.HealthRecords.OrderBy(h => h.Date).Select(h => new[] + { + gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date), + HealthTypeDe[h.Type], h.Description, h.Veterinarian, + })); + + AddCsv(zip, "gewichte.csv", + ["Tier", "Datum", "Gewicht (g)", "Notizen"], + data.WeightRecords.OrderBy(w => w.Date).Select(w => new[] + { + gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date), + w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes, + })); + + AddText(zip, "LIESMICH.txt", $""" + Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)} + + Enthalten: + - export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte, + Becken, Farbschläge, Gesundheits- und Gewichtseinträge, + Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft). + - tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice). + - wuerfe.csv : alle Würfe. + - kontakte.csv : alle Kontakte. + - gesundheit.csv: alle Gesundheitseinträge. + - gewichte.csv : alle Gewichtseinträge. + + Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem + deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ. + + FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als + normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage" + neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können + von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier + steht in export.json (Abschnitt "photos"). + """, bom: false); + } + return stream.ToArray(); + } + + // ── CSV building blocks ──────────────────────────────────────────── + + /// "2024-03-12" -> "12.03.2024"; null -> empty. + private static string? De(DateOnly? date) => + date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + private static string De(DateOnly date) => + date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + /// + /// Semicolon-separated (German Excel default), CRLF rows, fields quoted when + /// they contain separator/quote/newline; quotes doubled. + /// + internal static string BuildCsv(string[] header, IEnumerable rows) + { + var sb = new StringBuilder(); + sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n"); + foreach (var row in rows) + sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n"); + return sb.ToString(); + } + + internal static string Escape(string? field) + { + if (string.IsNullOrEmpty(field)) return ""; + return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r') + ? $"\"{field.Replace("\"", "\"\"")}\"" + : field; + } + + private static void AddCsv(ZipArchive zip, string name, string[] header, + IEnumerable rows) => + AddText(zip, name, BuildCsv(header, rows), bom: true); + + private static void AddText(ZipArchive zip, string name, string content, bool bom) + { + var entry = zip.CreateEntry(name, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + if (bom) + { + // UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an. + entryStream.Write(Encoding.UTF8.GetPreamble()); + } + var bytes = Encoding.UTF8.GetBytes(content); + entryStream.Write(bytes); + } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 1ae2173..76dd181 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -75,6 +75,7 @@ app.MapSaleAdEndpoints(); app.MapImportEndpoints(); app.MapContractEndpoints(); app.MapSettingsEndpoints(); +app.MapExportEndpoints(); app.Run(); diff --git a/gerbil-manager-web/e2e/einstellungen.spec.ts b/gerbil-manager-web/e2e/einstellungen.spec.ts new file mode 100644 index 0000000..ef9bbae --- /dev/null +++ b/gerbil-manager-web/e2e/einstellungen.spec.ts @@ -0,0 +1,20 @@ +/** EXPORT-1: Einstellungen — Datenexport-Karte + Download-Smoke. */ +import { de, expect, gotoSection, test } from './fixtures' + +const t = de.pages.datenexport + +test('Einstellungen zeigt die Datenexport-Karte und der Download startet', async ({ page }) => { + await gotoSection(page, de.nav.settings) + await expect(page.getByRole('heading', { name: de.pages.einstellungen.title })).toBeVisible() + + // Datenexport-Karte mit deutschem Erklärtext + await expect(page.getByRole('heading', { name: t.title })).toBeVisible() + await expect(page.getByText(t.intro)).toBeVisible() + await expect(page.getByText(t.photoNote)).toBeVisible() + + // Klick startet den Zip-Download + const downloadPromise = page.waitForEvent('download') + await page.getByRole('link', { name: t.button }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toContain('rennmaus-export') +}) diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 240aaa9..2aea058 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -98,11 +98,37 @@ export async function installMockApi(page: Page): Promise { 'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'), } - await page.route(`${API_ORIGIN}/**`, async (route) => { + const handler = async (route: Route) => { const request = route.request() const url = new URL(request.url()) const method = request.method() - const path = url.pathname + // OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy); + // den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert. + const path = url.pathname.replace(/^\/api(?=\/)/, '') + + // EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der + // Download startet; ein leeres Zip = End-of-central-directory-Record). + if (path === '/export' && method === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/zip', + headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' }, + body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]), + }) + } + + // FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern) + if (path === '/settings/breeder-profile') { + if (method === 'GET') { + return json(route, 200, { + zuchtName: 'Zucht der kleinen Chaoten', + name: 'Frau Erika Muster', + address: 'Musterweg 1, 12345 Musterstadt', + phone: '', email: '', homepage: '', city: 'Musterstadt', + }) + } + if (method === 'PUT') return json(route, 204) + } // Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge) let m = path.match(/^\/gerbils\/([^/]+)\/photos$/) @@ -157,7 +183,15 @@ export async function installMockApi(page: Page): Promise { return json(route, 204) } return json(route, 405) - }) + } + + // Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy). + // URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests + // (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten. + await page.route( + (url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'), + handler, + ) return db } diff --git a/gerbil-manager-web/playwright.config.ts b/gerbil-manager-web/playwright.config.ts index cc8dff3..bc6f2b9 100644 --- a/gerbil-manager-web/playwright.config.ts +++ b/gerbil-manager-web/playwright.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, + // CI-Flake-Schutz (Dwights Beobachtung, 2× 'Kontakt anlegen'): alle Worker + // teilen sich EINEN Vite-Dev-Server; unbegrenzte Parallelität erzeugt dort + // Timing-Druck (on-demand-Transforms). Lokal bleibt Playwrights Default. + workers: process.env.CI ? 4 : undefined, reporter: [['list']], timeout: 30_000, use: { diff --git a/gerbil-manager-web/src/components/DatenexportCard.tsx b/gerbil-manager-web/src/components/DatenexportCard.tsx new file mode 100644 index 0000000..28817f8 --- /dev/null +++ b/gerbil-manager-web/src/components/DatenexportCard.tsx @@ -0,0 +1,21 @@ +/** + * EXPORT-1: Datenexport-Karte (auf /einstellungen) — lädt GET /export als Zip. + * Schlichter Anker-Download (kein fetch/Blob nötig); der Server setzt + * Content-Disposition mit Datumsdateinamen. + */ +import { API_BASE_URL } from '../api/client' +import { de } from '../strings/de' + +export default function DatenexportCard() { + const t = de.pages.datenexport + return ( +
+

{t.title}

+

{t.intro}

+

{t.photoNote}

+ + {t.button} + +
+ ) +} diff --git a/gerbil-manager-web/src/pages/EinstellungenPage.tsx b/gerbil-manager-web/src/pages/EinstellungenPage.tsx index a340778..4eaac87 100644 --- a/gerbil-manager-web/src/pages/EinstellungenPage.tsx +++ b/gerbil-manager-web/src/pages/EinstellungenPage.tsx @@ -13,6 +13,8 @@ import { type BreederProfile, } from '../api/settings' import { useApi, useMutation } from '../hooks/useApi' +// EXPORT-1 (Oscar): Datenexport-Karte +import DatenexportCard from '../components/DatenexportCard' export default function EinstellungenPage() { const t = de.pages.einstellungen @@ -95,6 +97,8 @@ export default function EinstellungenPage() { {saved && {tz.saved}} + + ) } diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index e7b1ace..24747d0 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -565,6 +565,15 @@ export const de = { }, }, }, + // ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ── + datenexport: { + title: 'Datenexport', + intro: + 'Sicherung deiner Daten — alle Tiere, Würfe und Kontakte als Tabellen (CSV für Excel) plus eine vollständige Datensicherung (JSON), gebündelt als Zip.', + button: 'Export herunterladen', + photoNote: + 'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.', + }, }, // ── HELP-1: In-App-Anleitung ── hilfe: {