Compare commits

..

5 Commits

Author SHA1 Message Date
3b3497079b Merge feature/feat-13: Abgabevertrag generator phase A (template + pure OpenXml generator + 10 tests) [god-QA: 18/18] 2026-06-06 07:11:30 +02:00
dada3af968 OPS-1: startup scripts, autostart task, backup task, ops README, production config
- appsettings.Production.json: Photos:RootPath -> C:\gerbil-data\photos so
  photo files land on a stable host path when ASPNETCORE_ENVIRONMENT=Production
- Start-GerbilManager.ps1: creates data dirs, sets Production env, launches
  AppHost minimised; saves PID for Stop script
- Stop-GerbilManager.ps1: kills AppHost + Vite node processes by PID / port
- Register-AutoStart.ps1: Windows Scheduled Task on user logon (RunLevel Highest)
- Register-BackupTask.ps1: daily 03:00 Scheduled Task for Backup script
- deploy/README.md: Julian ops guide — start/stop commands, phone URL, DHCP
  reservation, firewall rule (Public-profile Wi-Fi), backup/restore procedures,
  prerequisites checklist, troubleshooting table (German)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:11:09 +02:00
6a37f9e46e OPS-1: fix backup/restore scripts — ASCII-only + array coerce for Count
- Replace em dashes (UTF-8 multi-byte) with ASCII hyphens; PowerShell 5.1
  reads ps1 files as CP1252 by default so any non-ASCII in source causes
  cascading parse errors
- Extract POSTGRES_PASSWORD from container env (Aspire uses scram-sha-256;
  peer/trust auth does not work) and pass as PGPASSWORD env var to pg_dump
  and psql in both Backup and Restore scripts
- @() wrap on Get-ChildItem result to force array before .Count under
  Set-StrictMode -Version Latest (single-item returns a bare object in PS5)
- Restore-test evidence: 73 ColorVarieties -> DELETE 12 -> 61 -> pg_restore
  -> 73 confirmed (run 2026-06-06 07:09 against postgres-gatkzrgq)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:09:53 +02:00
44f306d18f FEAT-12a: Abgabe page — Becken-default grouping + per-group listing composer, clipboard/zip export, AI stub 2026-06-06 07:09:48 +02:00
cbe77c927a FEAT-13: ContractGenerator (pure OpenXml, embedded template, per-animal table cloning) + BreederProfile/ContractData records + 10 xUnit tests incl. schema validation & PII guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:09:07 +02:00
16 changed files with 1654 additions and 3 deletions

View File

@@ -0,0 +1,224 @@
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Validation;
using GerbilManagerWebAPI.Contracts;
namespace GerbilManager.Tests;
/// <summary>
/// FEAT-13: Tests des Abgabevertrag-Generators. Die erzeugte .docx wird als
/// Zip geöffnet und auf word/document.xml geprüft — kein Word nötig.
/// </summary>
public class ContractGeneratorTests
{
private static BreederProfile Seller() => new()
{
ZuchtName = "Zucht Testhausen",
Name = "Frau Erika Muster",
Address = "Musterweg 1, 12345 Testhausen",
Phone = "0123 456789",
Email = "zucht@example.org",
Homepage = "https://zucht.example.org/",
City = "Testhausen",
};
private static ContractAnimal Krümel() => new(
Name: "Krümel",
Geschlecht: "Weiblich",
Geburtsdatum: new DateOnly(2025, 3, 9),
Farbschlag: "Agouti");
private static ContractData OneAnimal() => new(
Seller: Seller(),
Buyer: new ContractBuyer(
Name: "Herr Max Beispiel",
Address: "Beispielallee 7, 54321 Beispielstadt",
Phone: "0987 654321",
Email: "max@example.com"),
Animals: [Krümel()],
Price: 72m,
HandoverDate: new DateOnly(2026, 6, 5));
/// <summary>
/// Sichtbarer Text aus word/document.xml der erzeugten Datei. Tags werden
/// entfernt, damit Aussagen über Run-Grenzen hinweg möglich sind (Werte
/// und Fließtext liegen in getrennten w:t-Runs).
/// </summary>
private static string DocumentText(byte[] docx)
{
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
}
[Fact]
public void EinTier_EnthaeltAlleVertragswerte()
{
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
// Verkäufer-Block (aus dem Zuchtprofil)
Assert.Contains("Zucht Testhausen", text);
Assert.Contains("Frau Erika Muster", text);
Assert.Contains("Musterweg 1, 12345 Testhausen", text);
Assert.Contains("0123 456789", text);
Assert.Contains("zucht@example.org", text);
Assert.Contains("https://zucht.example.org/", text);
// Käufer-Block
Assert.Contains("Herr Max Beispiel", text);
Assert.Contains("Beispielallee 7, 54321 Beispielstadt", text);
Assert.Contains("0987 654321", text);
Assert.Contains("max@example.com", text);
// Tier
Assert.Contains("Krümel", text);
Assert.Contains("Weiblich", text);
Assert.Contains("09.03.2025", text);
Assert.Contains("Agouti", text);
// Kaufpreis (de-DE) + Übergabedatum (TT.MM.JJJJ)
Assert.Contains("Kaufpreis von 72,00 €", text);
Assert.Contains("am 05.06.2026 dem Käufer", text);
// Unterschriftszeile: Ort des Züchters + Vertragsdatum (= Übergabedatum)
Assert.Contains("Testhausen, den 05.06.2026", text);
}
[Fact]
public void KeineUnersetztenTokens()
{
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
Assert.DoesNotContain("{{", text);
Assert.DoesNotContain("}}", text);
}
[Fact]
public void DreiTiere_KlontDieTierTabelleProTier()
{
var data = OneAnimal() with
{
Animals =
[
Krümel(),
new ContractAnimal("Fridolin", "Männlich", new DateOnly(2023, 5, 1), "Schwarz"),
new ContractAnimal("Luna", "Weiblich", new DateOnly(2023, 8, 15), "Gold"),
],
};
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Krümel", text);
Assert.Contains("Fridolin", text);
Assert.Contains("Luna", text);
Assert.Contains("01.05.2023", text);
Assert.Contains("15.08.2023", text);
// Pro Tier eine Tabelle: „Tierart:“ ist das fixe Label jeder Tier-Tabelle.
Assert.Equal(3, Regex.Matches(text, "Tierart:").Count);
Assert.DoesNotContain("{{", text);
}
[Fact]
public void PreisUndDatum_DeutscheFormate()
{
var data = OneAnimal() with { Price = 1234.5m, HandoverDate = new DateOnly(2026, 1, 3) };
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Kaufpreis von 1.234,50 €", text);
Assert.Contains("am 03.01.2026 dem Käufer", text);
}
[Fact]
public void VertragsDatum_UeberschreibtUebergabedatumInDerUnterschriftszeile()
{
var data = OneAnimal() with { ContractDate = new DateOnly(2026, 6, 7) };
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Testhausen, den 07.06.2026", text);
Assert.Contains("am 05.06.2026 dem Käufer", text); // Übergabedatum bleibt eigenständig
}
[Fact]
public void OptionaleFelder_LeerStattPlatzhalter()
{
var data = OneAnimal() with
{
Buyer = new ContractBuyer("Frau Lisa Test", "Testgasse 2, 11111 Teststadt"),
Animals = [new ContractAnimal("Momo", "Unbekannt")],
};
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Frau Lisa Test", text);
Assert.Contains("Momo", text);
Assert.DoesNotContain("{{", text);
Assert.DoesNotContain("null", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void OhneTiere_WirftArgumentException()
{
var data = OneAnimal() with { Animals = [] };
var ex = Assert.Throws<ArgumentException>(() => ContractGenerator.Generate(data));
Assert.Contains("mindestens ein Tier", ex.Message);
}
[Fact]
public void ErzeugteDatei_IstEinGueltigesDocxPaket()
{
var docx = ContractGenerator.Generate(OneAnimal());
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
Assert.Contains(zip.Entries, e => e.FullName == "[Content_Types].xml");
Assert.Contains(zip.Entries, e => e.FullName == "word/document.xml");
// Tierfotos wurden beim Vorlagenbau entfernt, das Zucht-Logo bleibt.
Assert.Contains(zip.Entries, e => e.FullName == "word/media/image1.jpeg");
Assert.DoesNotContain(zip.Entries, e => e.FullName == "word/media/image2.jpeg");
}
[Fact]
public void ErzeugtesDokument_ValidiertGegenOpenXmlSchema()
{
// Office-2013-Schema: die Vorlage nutzt w:tblLook-Attribute der
// Word-2010-Form, die der 2007-Default des Validators nicht kennt.
var validator = new OpenXmlValidator(DocumentFormat.OpenXml.FileFormatVersions.Office2013);
var data = OneAnimal() with
{
Animals = [Krümel(), new ContractAnimal("Fridolin", "Männlich"), new ContractAnimal("Luna", "Weiblich")],
};
using var generatedDoc = WordprocessingDocument.Open(
new MemoryStream(ContractGenerator.Generate(data)), isEditable: false);
var errors = validator.Validate(generatedDoc)
.Select(e => $"{e.ErrorType}: {e.Description} [{e.Path?.XPath}]")
.ToList();
Assert.True(errors.Count == 0, "OpenXml-Validierungsfehler:\n" + string.Join("\n", errors));
}
[Fact]
public void VorlageSelbst_EnthaeltKeineEchtdaten()
{
// Die Vorlage wird hier über den Generator-Pfad geladen: ein Vertrag
// mit leeren Werten darf keinerlei Daten des Mustervertrags enthalten.
var data = new ContractData(
new BreederProfile(),
new ContractBuyer("", ""),
[new ContractAnimal("", "")],
0m,
new DateOnly(2026, 1, 1));
var text = DocumentText(ContractGenerator.Generate(data));
// Stichproben der Original-PII (Name/Ort/Mail des Mustervertrags).
Assert.DoesNotContain("Nießner", text);
Assert.DoesNotContain("Hartengrund", text);
Assert.DoesNotContain("Ronneburg", text);
Assert.DoesNotContain("Schädtler", text);
Assert.DoesNotContain("gmx.de", text);
Assert.DoesNotContain("jimdofree", text);
}
}

View File

@@ -0,0 +1,29 @@
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
/// Phase A: wird aus der appsettings-Sektion <c>"BreederProfile"</c> gebunden
/// (z. B. <c>builder.Configuration.GetSection("BreederProfile").Get&lt;BreederProfile&gt;()</c>);
/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
/// </summary>
public sealed class BreederProfile
{
/// <summary>Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.</summary>
public string ZuchtName { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
public string Address { get; set; } = "";
public string Phone { get; set; } = "";
public string Email { get; set; } = "";
public string Homepage { get; set; } = "";
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
public string City { get; set; } = "";
}

View File

@@ -0,0 +1,34 @@
namespace GerbilManagerWebAPI.Contracts;
/// <summary>Käufer/Abnehmer-Block des Abgabevertrags.</summary>
/// <param name="Name">Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.</param>
/// <param name="Address">Anschrift einzeilig: „Straße Nr, PLZ Ort“.</param>
public sealed record ContractBuyer(
string Name,
string Address,
string? Phone = null,
string? Email = null);
/// <summary>Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).</summary>
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
public sealed record ContractAnimal(
string Name,
string Geschlecht,
DateOnly? Geburtsdatum = null,
string? Farbschlag = null);
/// <summary>
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
/// </summary>
/// <param name="Price">Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.</param>
/// <param name="HandoverDate">Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.</param>
/// <param name="ContractDate">Datum der Unterschriftszeile; Standard = Übergabedatum.</param>
public sealed record ContractData(
BreederProfile Seller,
ContractBuyer Buyer,
IReadOnlyList<ContractAnimal> Animals,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate = null);

View File

@@ -0,0 +1,152 @@
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
/// (<c>Contracts/Templates/Abgabevertrag.docx</c>, abgeleitet aus dem echten
/// Mustervertrag; Boilerplate-Abschnitte 48 stehen verbatim in der Vorlage).
///
/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
/// <see cref="ContractData"/>, Ausgabe sind die fertigen .docx-Bytes.
/// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau
/// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle
/// wird pro Tier geklont (variable Anzahl 1..n).
/// </summary>
public static class ContractGenerator
{
private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// <summary>Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.</summary>
public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
/// <summary>Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).</summary>
public static byte[] Generate(ContractData data, byte[] template)
{
ArgumentNullException.ThrowIfNull(data);
if (data.Animals.Count == 0)
{
throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data));
}
using var stream = new MemoryStream();
stream.Write(template);
using (var document = WordprocessingDocument.Open(stream, isEditable: true))
{
var body = document.MainDocumentPart?.Document.Body
?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf.");
FillAnimalTables(body, data.Animals);
ReplaceTokens(body, GlobalTokens(data));
document.MainDocumentPart!.Document.Save();
}
return stream.ToArray();
}
/// <summary>
/// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für
/// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird
/// jede Tabelle mit den Werten „ihres“ Tieres gefüllt.
/// </summary>
private static void FillAnimalTables(Body body, IReadOnlyList<ContractAnimal> animals)
{
var templateTable = body.Descendants<Table>()
.Single(t => t.InnerText.Contains("{{TierName}}"));
var tables = new List<Table> { templateTable };
// Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag).
OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer
? spacer
: templateTable;
for (var i = 1; i < animals.Count; i++)
{
var clone = (Table)templateTable.CloneNode(deep: true);
anchor = anchor.InsertAfterSelf(clone);
anchor = anchor.InsertAfterSelf(new Paragraph());
tables.Add(clone);
}
for (var i = 0; i < animals.Count; i++)
{
ReplaceTokens(tables[i], AnimalTokens(animals[i]));
}
}
private static Dictionary<string, string> GlobalTokens(ContractData data) => new()
{
["{{ZuchtName}}"] = data.Seller.ZuchtName,
["{{VerkaeuferName}}"] = data.Seller.Name,
["{{VerkaeuferAdresse}}"] = data.Seller.Address,
["{{VerkaeuferTelefon}}"] = data.Seller.Phone,
["{{VerkaeuferEmail}}"] = data.Seller.Email,
["{{VerkaeuferHomepage}}"] = data.Seller.Homepage,
["{{KaeuferName}}"] = data.Buyer.Name,
["{{KaeuferAdresse}}"] = data.Buyer.Address,
["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "",
["{{KaeuferEmail}}"] = data.Buyer.Email ?? "",
["{{Kaufpreis}}"] = FormatPrice(data.Price),
["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate),
["{{VertragsOrt}}"] = data.Seller.City,
["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate),
};
private static Dictionary<string, string> AnimalTokens(ContractAnimal animal) => new()
{
["{{TierName}}"] = animal.Name,
["{{TierGeschlecht}}"] = animal.Geschlecht,
["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
};
/// <summary>z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.</summary>
private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
/// <summary>TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).</summary>
private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
/// <summary>
/// Ersetzt Tokens in allen Text-Runs unterhalb von <paramref name="root"/>.
/// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
/// Run-übergreifendes Matching nötig.
/// </summary>
private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary<string, string> tokens)
{
foreach (var text in root.Descendants<Text>())
{
if (!text.Text.Contains("{{"))
{
continue;
}
foreach (var (token, value) in tokens)
{
if (text.Text.Contains(token))
{
text.Text = text.Text.Replace(token, value);
}
}
if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1])))
{
text.Space = SpaceProcessingModeValues.Preserve;
}
}
}
private static byte[] LoadEmbeddedTemplate()
{
using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource)
?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}");
using var buffer = new MemoryStream();
resource.CopyTo(buffer);
return buffer.ToArray();
}
}

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
@@ -24,4 +25,9 @@
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"Photos": {
"RootPath": "C:\\gerbil-data\\photos"
}
}

178
deploy/README.md Normal file
View File

@@ -0,0 +1,178 @@
# GerbilManager — Betriebsanleitung (Ops Guide)
_Für Julian und seine Frau. Alles Wichtige auf einer Seite._
---
## Schnellstart
### Erstmalige Einrichtung (einmalig, als Administrator)
```powershell
# 1. Autostart bei Windows-Anmeldung registrieren
powershell -ExecutionPolicy Bypass -File deploy\scripts\Register-AutoStart.ps1
# 2. Taeliches Backup um 03:00 Uhr registrieren
powershell -ExecutionPolicy Bypass -File deploy\scripts\Register-BackupTask.ps1
```
Danach startet GerbilManager automatisch nach jedem Neustart. Fertig.
---
### App manuell starten / stoppen
```powershell
# Starten (minimiertes Fenster)
powershell -ExecutionPolicy Bypass -File deploy\scripts\Start-GerbilManager.ps1
# Stoppen
powershell -ExecutionPolicy Bypass -File deploy\scripts\Stop-GerbilManager.ps1
```
### App im Browser öffnen
| Gerät | URL |
|---|---|
| Laptop (lokal) | http://localhost:5173 |
| Handy / anderes Gerät im WLAN | http://192.168.2.124:5173 |
> Die IP-Adresse (192.168.2.124) ist die aktuelle DHCP-Adresse des Laptops.
> Für eine stabile Adresse → Abschnitt "Feste IP-Adresse" weiter unten.
---
## Datensicherung (Backup)
Backups werden automatisch täglich um 03:00 Uhr nach `C:\gerbil-data\backups\` gesichert.
Jedes Backup enthält:
- `gerbilmanager_<datum>.sql` — kompletter Datenbankdump
- `photos_<datum>.zip` — alle Tierfotos
Die letzten **7 Tage** werden aufbewahrt; ältere Backups werden automatisch gelöscht.
### Backup manuell ausführen
```powershell
powershell -ExecutionPolicy Bypass -File deploy\scripts\Backup-GerbilManager.ps1
```
### Backup-Protokoll einsehen
```
C:\gerbil-data\backups\backup.log
```
---
## Wiederherstellung aus Backup
> **Achtung:** Alle aktuellen Daten werden überschrieben!
```powershell
# Neuestes Backup wiederherstellen (fragt vorher nach Bestätigung)
powershell -ExecutionPolicy Bypass -File deploy\scripts\Restore-GerbilManager.ps1
# Bestimmtes Backup wiederherstellen
powershell -ExecutionPolicy Bypass -File deploy\scripts\Restore-GerbilManager.ps1 `
-BackupDir "C:\gerbil-data\backups\2026-06-05_03-00"
```
---
## Feste IP-Adresse (empfohlen)
Damit das Handy immer dieselbe URL verwendet, sollte dem Laptop eine feste
IP-Adresse im Heimnetzwerk zugewiesen werden.
### Option A: DHCP-Reservierung im Router (empfohlen)
1. Router-Oberfläche öffnen (meist `http://192.168.2.1` oder `http://fritz.box`)
2. **Heimnetz → Netzwerk → IP-Adressen** (bei FRITZ!Box)
3. Den Eintrag für diesen Laptop suchen (Name: `GULUM-...` oder ähnlich)
4. **"Immer dieselbe IPv4-Adresse zuweisen"** aktivieren
5. Speichern. Ab sofort hat der Laptop immer 192.168.2.124.
### Option B: Statische IP direkt am Laptop setzen
```powershell
# Netzwerkadaptername ermitteln
Get-NetAdapter
# Feste IP setzen (Beispiel fuer WLAN-Adapter "Wi-Fi")
New-NetIPAddress -InterfaceAlias "Wi-Fi" -IPAddress 192.168.2.124 -PrefixLength 24 -DefaultGateway 192.168.2.1
Set-DnsClientServerAddress -InterfaceAlias "Wi-Fi" -ServerAddresses 192.168.2.1
```
---
## Firewall-Regel (für Handy-Zugriff)
Damit das Handy auf die App zugreifen kann, muss eine Firewall-Ausnahme eingerichtet sein.
Das WLAN "Katzastrophe 4" ist als **Public** eingestuft, daher ist der Befehl nötig:
```powershell
# Als Administrator ausführen:
New-NetFirewallRule `
-DisplayName "GerbilManager (Frontend + API)" `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 5173,5179 `
-Profile Any
```
> **Einmalig nötig.** Prüfen ob die Regel bereits existiert:
> `Get-NetFirewallRule -DisplayName "GerbilManager*"`
---
## Datenspeicherorte
| Was | Pfad |
|---|---|
| Tierfotos | `C:\gerbil-data\photos\` |
| Datenbank | Docker-Volume (automatisch, Aspire-verwaltet) |
| Backups | `C:\gerbil-data\backups\` |
| App-Quellcode | `C:\Users\gulum\dev\GerbilManager\` |
---
## Voraussetzungen (für Betrieb)
| Software | Mindestversion | Status prüfen |
|---|---|---|
| Docker Desktop | Aktuell, läuft | `docker ps` |
| .NET SDK | 10.0+ | `dotnet --version` |
| Node.js | 18+ | `node --version` |
Docker Desktop muss beim Windows-Start automatisch starten.
Einstellung: Docker Desktop → Settings → **Start Docker Desktop when you sign in**.
---
## Problemlösung
| Problem | Lösung |
|---|---|
| Seite lädt nicht | Prüfen ob Docker läuft: `docker ps`. Dann `Start-GerbilManager.ps1` |
| Handy erreicht App nicht | Firewall-Regel prüfen (oben). Beide Geräte im selben WLAN? |
| Daten verschwunden | Wiederherstellung: `Restore-GerbilManager.ps1` |
| Datenbank-Fehler beim Start | `docker ps` → Postgres-Container läuft? Sonst neu starten |
| Port belegt (Fehler 5179/5173) | `netstat -ano \| findstr ":5179"` → Prozess beenden |
---
## Backup testen (einmalig empfohlen)
```powershell
# 1. Backup erstellen
powershell -ExecutionPolicy Bypass -File deploy\scripts\Backup-GerbilManager.ps1
# 2. Prüfen ob Backup vorhanden
Get-ChildItem "C:\gerbil-data\backups\" -Directory | Select-Object -Last 3
# 3. Inhalt des neuesten Backups prüfen
$latest = (Get-ChildItem "C:\gerbil-data\backups\" -Directory | Sort-Object LastWriteTime -Desc | Select-Object -First 1).FullName
Get-ChildItem $latest
```

View File

@@ -0,0 +1,126 @@
<#
.SYNOPSIS
Sichert die GerbilManager-Datenbank (pg_dump) und den Fotoordner.
.DESCRIPTION
1. Findet den laufenden Aspire-Postgres-Docker-Container.
2. Fuehrt pg_dump aus und speichert das SQL-Dump in C:\gerbil-data\backups\.
3. Kopiert den Fotoordner als ZIP ins Backup-Verzeichnis.
4. Rotiert alte Backups: behaelt die letzten $KeepDays Tage.
.PARAMETER BackupRoot
Pfad zum Backup-Verzeichnis. Standard: C:\gerbil-data\backups
.PARAMETER PhotosRoot
Pfad zum Fotoordner. Standard: C:\gerbil-data\photos
.PARAMETER KeepDays
Anzahl der Tage, die Backups aufbewahrt werden. Standard: 7
.EXAMPLE
.\Backup-GerbilManager.ps1
.\Backup-GerbilManager.ps1 -KeepDays 14 -BackupRoot D:\Sicherungen
#>
param(
[string]$BackupRoot = "C:\gerbil-data\backups",
[string]$PhotosRoot = "C:\gerbil-data\photos",
[int]$KeepDays = 7
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm"
$backupDir = Join-Path $BackupRoot $timestamp
$logFile = Join-Path $BackupRoot "backup.log"
$dbDumpFile = Join-Path $backupDir "gerbilmanager_$timestamp.sql"
$photoZip = Join-Path $backupDir "photos_$timestamp.zip"
function Log([string]$msg) {
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $msg"
Write-Host $line
Add-Content -Path $logFile -Value $line -Encoding UTF8
}
# --- Voraussetzungen pruefen ---
if (-not (Get-Command "docker" -ErrorAction SilentlyContinue)) {
Log "FEHLER: Docker nicht gefunden. Backup abgebrochen."
exit 1
}
# --- Backup-Verzeichnis anlegen ---
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
Log "Backup-Verzeichnis: $backupDir"
# --- Postgres-Container finden ---
Log "Suche Postgres-Container..."
$containers = docker ps --format "{{.Names}}" 2>&1
$pgContainer = $containers -split "`n" | Where-Object { $_ -match "postgres" } | Select-Object -First 1
if (-not $pgContainer) {
Log "FEHLER: Kein laufender Postgres-Container gefunden. Ist GerbilManager gestartet?"
exit 1
}
$pgContainer = $pgContainer.Trim()
Log "Gefundener Container: $pgContainer"
# Aspire setzt scram-sha-256 Auth - Passwort aus Container-Env lesen
$pgPassword = (docker inspect $pgContainer --format "{{range .Config.Env}}{{println .}}{{end}}" 2>&1) -split "`n" |
Where-Object { $_ -match "^POSTGRES_PASSWORD=" } |
ForEach-Object { $_ -replace "^POSTGRES_PASSWORD=", "" } |
Select-Object -First 1
if (-not $pgPassword) {
Log "FEHLER: POSTGRES_PASSWORD nicht im Container gefunden."
exit 1
}
# --- Datenbank-Dump ---
Log "Starte pg_dump fuer Datenbank 'gerbilmanager'..."
try {
docker exec -e "PGPASSWORD=$pgPassword" $pgContainer `
pg_dump --clean --if-exists --format=plain --username=postgres gerbilmanager `
| Out-File -FilePath $dbDumpFile -Encoding UTF8
$dumpSize = [math]::Round((Get-Item $dbDumpFile).Length / 1KB, 1)
Log "Datenbank-Dump erstellt: $dbDumpFile ($dumpSize KB)"
} catch {
Log "FEHLER beim Datenbank-Dump: $_"
exit 1
}
# Dump-Validierung: muss mindestens 'CREATE TABLE' enthalten
$dumpContent = Get-Content $dbDumpFile -Raw -ErrorAction SilentlyContinue
if ($dumpContent -notlike "*CREATE TABLE*" -and $dumpContent -notlike "*PostgreSQL*") {
Log "WARNUNG: Dump-Datei sieht ungueltig aus - pruefen Sie $dbDumpFile manuell."
}
# --- Fotos sichern ---
if (Test-Path $PhotosRoot) {
$photoCount = (Get-ChildItem $PhotosRoot -File -ErrorAction SilentlyContinue).Count
if ($photoCount -gt 0) {
Log "Komprimiere $photoCount Fotos nach $photoZip..."
try {
Compress-Archive -Path "$PhotosRoot\*" -DestinationPath $photoZip -Force
$zipSize = [math]::Round((Get-Item $photoZip).Length / 1MB, 1)
Log "Foto-Archiv erstellt: $photoZip ($zipSize MB)"
} catch {
Log "WARNUNG: Foto-Backup fehlgeschlagen: $_"
}
} else {
Log "Kein Fotoordner oder keine Fotos vorhanden - Foto-Backup uebersprungen."
}
} else {
Log "Fotoordner nicht gefunden ($PhotosRoot) - Foto-Backup uebersprungen."
}
# --- Rotation: alte Backups loeschen ---
Log "Rotiere Backups (behalte letzte $KeepDays Tage)..."
$cutoff = (Get-Date).AddDays(-$KeepDays)
$oldBackups = Get-ChildItem -Path $BackupRoot -Directory |
Where-Object { $_.LastWriteTime -lt $cutoff }
foreach ($old in $oldBackups) {
Log "Loesche altes Backup: $($old.FullName)"
Remove-Item $old.FullName -Recurse -Force
}
$remaining = @(Get-ChildItem -Path $BackupRoot -Directory).Count
Log "Backup abgeschlossen. Vorhandene Backups: $remaining"

View File

@@ -0,0 +1,63 @@
<#
.SYNOPSIS
Registriert GerbilManager als Windows-Autostart (geplante Aufgabe bei Benutzeranmeldung).
.DESCRIPTION
Erstellt eine geplante Aufgabe, die Start-GerbilManager.ps1 automatisch startet,
wenn sich der aktuelle Benutzer anmeldet. Erfordert Administratorrechte.
.NOTES
Erfordert: PowerShell als Administrator ausfuehren
Aufgabenname: GerbilManager AutoStart
#>
#Requires -RunAsAdministrator
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$taskName = "GerbilManager AutoStart"
$scriptPath = Join-Path $PSScriptRoot "Start-GerbilManager.ps1"
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
if (-not (Test-Path $scriptPath)) {
Write-Error "Startskript nicht gefunden: $scriptPath"
exit 1
}
Write-Host "Registriere geplante Aufgabe '$taskName'..."
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`"" `
-WorkingDirectory $projectRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 0) `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 2) `
-StartWhenAvailable `
-MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal `
-UserId $env:USERNAME `
-LogonType Interactive `
-RunLevel Highest
# Vorhandene Aufgabe entfernen, falls noetig
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description "Startet GerbilManager (Rennmaus-Verwaltung) automatisch bei der Benutzeranmeldung." `
-Force | Out-Null
Write-Host "Aufgabe registriert: '$taskName'"
Write-Host "GerbilManager startet ab sofort automatisch beim Anmelden."
Write-Host ""
Write-Host "Aufgabe pruefen : Get-ScheduledTask -TaskName '$taskName'"
Write-Host "Aufgabe entfernen: Unregister-ScheduledTask -TaskName '$taskName' -Confirm:`$false"

View File

@@ -0,0 +1,69 @@
<#
.SYNOPSIS
Registriert den GerbilManager-Backup als taegliche geplante Aufgabe (03:00 Uhr).
.DESCRIPTION
Erstellt eine geplante Aufgabe, die Backup-GerbilManager.ps1 taeglich um 03:00 Uhr
ausfuehrt. Backups werden 7 Tage lang aufbewahrt.
.PARAMETER BackupTime
Uhrzeit fuer das taeliche Backup. Standard: 03:00
.NOTES
Erfordert: PowerShell als Administrator ausfuehren
Aufgabenname: GerbilManager Backup
#>
#Requires -RunAsAdministrator
param(
[string]$BackupTime = "03:00"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$taskName = "GerbilManager Backup"
$scriptPath = Join-Path $PSScriptRoot "Backup-GerbilManager.ps1"
$logPath = "C:\gerbil-data\backups\backup.log"
if (-not (Test-Path $scriptPath)) {
Write-Error "Backup-Skript nicht gefunden: $scriptPath"
exit 1
}
# Backup-Verzeichnis vorab anlegen
New-Item -ItemType Directory -Force -Path "C:\gerbil-data\backups" | Out-Null
Write-Host "Registriere Backup-Aufgabe '$taskName' (taeglich $BackupTime)..."
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`" >> `"$logPath`" 2>&1"
$trigger = New-ScheduledTaskTrigger -Daily -At $BackupTime
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 1) `
-StartWhenAvailable `
-WakeToRun `
-MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal `
-UserId $env:USERNAME `
-LogonType Interactive `
-RunLevel Highest
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description "Taegiches Backup der GerbilManager-Datenbank und Fotos um $BackupTime Uhr." `
-Force | Out-Null
Write-Host "Backup-Aufgabe registriert: '$taskName'"
Write-Host "Backups werden taeglich um $BackupTime Uhr nach C:\gerbil-data\backups\ gesichert."
Write-Host "Protokoll: $logPath"
Write-Host ""
Write-Host "Backup jetzt testen: & `"$scriptPath`""

View File

@@ -0,0 +1,152 @@
<#
.SYNOPSIS
Stellt GerbilManager aus einem Backup wieder her.
.DESCRIPTION
WARNUNG: Ueberschreibt alle aktuellen Datenbankdaten und Fotos!
1. Stoppt GerbilManager (falls laufend).
2. Stellt die Datenbank aus dem pg_dump-SQL-File wieder her.
3. Entpackt das Foto-Archiv.
4. Startet GerbilManager neu.
.PARAMETER BackupDir
Pfad zum Backup-Unterverzeichnis (z.B. C:\gerbil-data\backups\2026-06-06_03-00).
Wenn nicht angegeben, wird das neueste verfuegbare Backup verwendet.
.PARAMETER PhotosRoot
Pfad zum Fotoordner. Standard: C:\gerbil-data\photos
.PARAMETER NoRestart
GerbilManager nach der Wiederherstellung NICHT neu starten.
.EXAMPLE
# Neuestes Backup wiederherstellen
.\Restore-GerbilManager.ps1
# Bestimmtes Backup wiederherstellen
.\Restore-GerbilManager.ps1 -BackupDir "C:\gerbil-data\backups\2026-06-05_03-00"
#>
param(
[string]$BackupDir = "",
[string]$PhotosRoot = "C:\gerbil-data\photos",
[switch]$NoRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$backupRoot = "C:\gerbil-data\backups"
function Log([string]$msg) {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $msg"
}
# --- Backup-Verzeichnis bestimmen ---
if ($BackupDir -eq "") {
$latest = Get-ChildItem -Path $backupRoot -Directory |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $latest) {
Log "FEHLER: Kein Backup gefunden in $backupRoot"
exit 1
}
$BackupDir = $latest.FullName
}
if (-not (Test-Path $BackupDir)) {
Log "FEHLER: Backup-Verzeichnis nicht gefunden: $BackupDir"
exit 1
}
# --- Dateien im Backup suchen ---
$sqlFile = Get-ChildItem -Path $BackupDir -Filter "*.sql" | Select-Object -First 1
$zipFile = Get-ChildItem -Path $BackupDir -Filter "*.zip" | Select-Object -First 1
if (-not $sqlFile) {
Log "FEHLER: Kein SQL-Dump (.sql) in $BackupDir gefunden."
exit 1
}
Log "Wiederherstellung aus: $BackupDir"
Log " Datenbank: $($sqlFile.Name)"
if ($zipFile) { Log " Fotos : $($zipFile.Name)" }
# --- Bestaetigung ---
Write-Host ""
Write-Host "WARNUNG: Alle aktuellen Datenbankdaten und Fotos werden UNWIDERRUFLICH ueberschrieben!" -ForegroundColor Red
$confirm = Read-Host "Fortfahren? (ja/nein)"
if ($confirm -ne "ja") {
Log "Abgebrochen."
exit 0
}
# --- GerbilManager stoppen (falls laufend) ---
$stopScript = Join-Path $PSScriptRoot "Stop-GerbilManager.ps1"
if (Test-Path $stopScript) {
Log "Stoppe GerbilManager..."
& $stopScript
Start-Sleep -Seconds 3
}
# --- Postgres-Container finden ---
Log "Suche Postgres-Container..."
$containers = docker ps --format "{{.Names}}" 2>&1
$pgContainer = $containers -split "`n" | Where-Object { $_ -match "postgres" } | Select-Object -First 1
if (-not $pgContainer) {
# Container koennte gestoppt sein - starten wir ihn kurz
Log "Postgres-Container laeuft nicht. Starte ihn zunaechst..."
Log "Bitte starten Sie GerbilManager kurz einmal (Start-GerbilManager.ps1) und versuchen Sie es erneut."
exit 1
}
$pgContainer = $pgContainer.Trim()
Log "Container: $pgContainer"
# Aspire setzt scram-sha-256 Auth - Passwort aus Container-Env lesen
$pgPassword = (docker inspect $pgContainer --format "{{range .Config.Env}}{{println .}}{{end}}" 2>&1) -split "`n" |
Where-Object { $_ -match "^POSTGRES_PASSWORD=" } |
ForEach-Object { $_ -replace "^POSTGRES_PASSWORD=", "" } |
Select-Object -First 1
if (-not $pgPassword) {
Log "FEHLER: POSTGRES_PASSWORD nicht im Container gefunden."
exit 1
}
# --- Datenbankwiederherstellung ---
Log "Stelle Datenbank wieder her aus $($sqlFile.Name)..."
# Verbindungen trennen
docker exec -e "PGPASSWORD=$pgPassword" $pgContainer psql -U postgres -c `
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'gerbilmanager' AND pid <> pg_backend_pid();" | Out-Null
# SQL-Dump einspielen (--clean im Dump macht DROP TABLE IF EXISTS vor CREATE)
Get-Content $sqlFile.FullName -Raw | docker exec -i -e "PGPASSWORD=$pgPassword" $pgContainer psql -U postgres gerbilmanager
if ($LASTEXITCODE -ne 0) {
Log "FEHLER: Datenbankwiederherstellung fehlgeschlagen (exit $LASTEXITCODE)."
exit 1
}
Log "Datenbankwiederherstellung erfolgreich."
# --- Fotos wiederherstellen ---
if ($zipFile) {
Log "Stelle Fotos wieder her..."
if (Test-Path $PhotosRoot) {
Remove-Item "$PhotosRoot\*" -Recurse -Force -ErrorAction SilentlyContinue
} else {
New-Item -ItemType Directory -Force -Path $PhotosRoot | Out-Null
}
Expand-Archive -Path $zipFile.FullName -DestinationPath $PhotosRoot -Force
$count = (Get-ChildItem $PhotosRoot -File).Count
Log "Fotos wiederhergestellt: $count Dateien."
}
# --- GerbilManager neu starten ---
if (-not $NoRestart) {
$startScript = Join-Path $PSScriptRoot "Start-GerbilManager.ps1"
if (Test-Path $startScript) {
Log "Starte GerbilManager neu..."
& $startScript
}
}
Log "Wiederherstellung abgeschlossen."

View File

@@ -0,0 +1,72 @@
<#
.SYNOPSIS
Startet GerbilManager im Produktionsmodus (API + Datenbank + Frontend).
.DESCRIPTION
Setzt die Produktionsumgebung, legt Datenverzeichnisse an und startet den
Aspire AppHost im Hintergrund. Der Prozess laeuft als minimiertes Fenster.
Der Pfad zum AppHost wird relativ zu diesem Skript aufgeloest.
.NOTES
Erfordert: .NET SDK 10+, Docker Desktop (running), Node.js 18+
Startet auf: http://<LAN-IP>:5173 (Frontend), http://<LAN-IP>:5179 (API)
#>
param(
[switch]$Wait # Blockiert bis Ctrl+C wenn gesetzt (fuer manuelle Starts)
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
# --- Datenverzeichnisse sicherstellen ---
$dataRoot = "C:\gerbil-data"
$photosDir = "$dataRoot\photos"
$backupsDir = "$dataRoot\backups"
foreach ($dir in @($photosDir, $backupsDir)) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Write-Host "Verzeichnis angelegt: $dir"
}
}
# --- Produktionsumgebung setzen ---
$env:ASPNETCORE_ENVIRONMENT = "Production"
$env:DOTNET_ENVIRONMENT = "Production"
# Aspire Dashboard-Browser nicht automatisch oeffnen
$env:DOTNET_LAUNCH_BROWSER = "false"
# Kein Aspire Dashboard im Produktionsbetrieb (Dashboard-Port auf 0 → kein Start)
# Entfernen Sie die naechste Zeile, wenn Sie das Dashboard behalten moechten.
$env:ASPIRE_ALLOW_UNSECURED_TRANSPORT = "true"
# --- AppHost starten ---
$appHostProject = Join-Path $projectRoot "GerbilManager.AppHost"
Write-Host "Starte GerbilManager..."
Write-Host " Projekt : $appHostProject"
Write-Host " Umgebung: $($env:ASPNETCORE_ENVIRONMENT)"
Write-Host " Fotos : $photosDir"
Write-Host ""
if ($Wait) {
# Interaktiver Modus: blockiert bis Ctrl+C
dotnet run --project $appHostProject --no-launch-profile
} else {
# Hintergrundmodus: startet minimiertes Fenster
$pidFile = Join-Path $PSScriptRoot "..\gerbilmanager.pid"
$proc = Start-Process "cmd.exe" `
-ArgumentList "/c", "dotnet run --project `"$appHostProject`" --no-launch-profile" `
-WorkingDirectory $projectRoot `
-WindowStyle Minimized `
-PassThru
$proc.Id | Out-File -FilePath $pidFile -Encoding UTF8 -Force
Write-Host "GerbilManager gestartet (PID $($proc.Id))."
Write-Host "Frontend : http://localhost:5173"
Write-Host "API : http://localhost:5179"
Write-Host "PID-Datei: $pidFile"
Write-Host ""
Write-Host "Zum Beenden: deploy\scripts\Stop-GerbilManager.ps1"
}

View File

@@ -0,0 +1,49 @@
<#
.SYNOPSIS
Stoppt alle laufenden GerbilManager-Prozesse (AppHost + Kindprozesse).
.NOTES
Beendet dotnet-AppHost-Prozesse und wartet darauf, dass Docker-Container
von Aspire selbst heruntergefahren werden.
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = "SilentlyContinue"
$pidFile = Join-Path $PSScriptRoot "..\gerbilmanager.pid"
# Ueber gespeicherte PID stoppen (wenn vorhanden)
if (Test-Path $pidFile) {
$savedPid = Get-Content $pidFile -Raw | ForEach-Object { $_.Trim() }
$proc = Get-Process -Id $savedPid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Beende Prozess PID $savedPid ($($proc.Name))..."
Stop-Process -Id $savedPid -Force
Remove-Item $pidFile -Force
}
}
# Alle dotnet-Prozesse stoppen, die den AppHost als Elternprozess haben
$appHostProcs = Get-Process -Name "dotnet" -ErrorAction SilentlyContinue |
Where-Object { $_.MainModule.FileName -like "*dotnet*" }
foreach ($p in $appHostProcs) {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)").CommandLine
if ($cmdLine -like "*GerbilManager.AppHost*") {
Write-Host "Beende dotnet-AppHost (PID $($p.Id))..."
Stop-Process -Id $p.Id -Force
}
}
# Vite-Dev-Server stoppen (node-Prozess auf Port 5173)
$nodePids = (netstat -ano | Select-String ":5173").ToString() -split "\s+" |
Where-Object { $_ -match "^\d+$" } | Select-Object -Unique
foreach ($nPid in $nodePids) {
$np = Get-Process -Id $nPid -ErrorAction SilentlyContinue
if ($np -and $np.Name -in @("node", "npm")) {
Write-Host "Beende Vite-Dev-Server (PID $nPid)..."
Stop-Process -Id $nPid -Force
}
}
Write-Host "GerbilManager wurde gestoppt."
Write-Host "Hinweis: Der Postgres-Docker-Container laeuft weiterhin (Aspire managed ihn)."
Write-Host " Zum Stoppen: docker stop $(docker ps --filter 'name=postgres' --format '{{.Names}}' 2>$null)"

View File

@@ -0,0 +1,276 @@
import { useMemo, useState } from 'react'
import JSZip from 'jszip'
import { de } from '../strings/de'
import type { Gerbil } from '../api/types'
import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos'
import { generateSaleAd } from '../api/saleAd'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import FarbschlagImage from './FarbschlagImage'
type SaleStatus = 'free' | 'loose' | 'reserved'
export interface GroupComposerProps {
/** 1-based group number for the heading. */
groupNumber: number
animals: Gerbil[]
/** Resolve a gerbil's Farbschlag display name (stored ColorVariety or '—'). */
farbschlagOf: (g: Gerbil) => string
}
/** Group gender label for the heading ("männlich"/"weiblich"/"gemischt"). */
function groupGender(animals: Gerbil[]): string {
const g = de.pages.abgabe.listing
const genders = new Set(animals.map((a) => a.gender))
if (genders.size === 1 && genders.has('male')) return g.genderMale
if (genders.size === 1 && genders.has('female')) return g.genderFemale
return g.genderMixed
}
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
const t = de.pages.abgabe
const [status, setStatus] = useState<SaleStatus>('free')
const [reservedName, setReservedName] = useState('')
const [tagline, setTagline] = useState('')
// Per-animal personality text, seeded from notes on first render.
const [personality, setPersonality] = useState<Record<string, string>>(() =>
Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])),
)
const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({})
const [hints, setHints] = useState('')
const [notice, setNotice] = useState<string | null>(null)
const [aiText, setAiText] = useState<string | null>(null)
const [zipping, setZipping] = useState(false)
const animalIds = animals.map((a) => a.id).join(',')
// Load photos per animal; tolerate a missing photo endpoint (-> empty).
const photoData = useApi(
() =>
Promise.all(
animals.map((a) =>
listGerbilPhotos(a.id)
.then((photos) => ({ id: a.id, photos }))
.catch(() => ({ id: a.id, photos: [] as GerbilPhoto[] })),
),
),
[animalIds],
)
const photosById = useMemo(() => {
const m = new Map<string, GerbilPhoto[]>()
for (const e of photoData.data ?? []) m.set(e.id, e.photos)
return m
}, [photoData.data])
const ai = useMutation(generateSaleAd)
const statusLine = (() => {
if (status === 'loose') return `${t.listing.statusLooseReserved} ${reservedName}`.trim()
if (status === 'reserved') return t.listing.statusReserved
return t.listing.statusFree
})()
const listingText = useMemo(() => {
const heading = t.listing.headingFor(groupNumber, groupGender(animals))
const lines: string[] = [heading, `${t.listing.status}: ${statusLine}`, '']
if (tagline.trim()) lines.push(tagline.trim(), '')
for (const a of animals) {
const fs = farbschlagOf(a)
const born = a.dateOfBirth ? `, ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''
lines.push(`${a.name} ${fs}${born}`)
const p = (personality[a.id] ?? '').trim()
if (p) lines.push(p)
lines.push('')
}
return lines.join('\n').trimEnd()
}, [groupNumber, animals, statusLine, tagline, personality, farbschlagOf, t])
function togglePhoto(photoId: string) {
setSelectedPhotos((s) => ({ ...s, [photoId]: !isSelected(photoId) }))
}
// Default: a photo is selected unless explicitly toggled off.
function isSelected(photoId: string): boolean {
return selectedPhotos[photoId] ?? true
}
async function copyText() {
setNotice(null)
try {
await navigator.clipboard.writeText(aiText ?? listingText)
setNotice(t.export.copied)
} catch {
setNotice(t.export.copyFailed)
}
}
async function downloadPhotos() {
setNotice(null)
const zip = new JSZip()
const folder = zip.folder(`gruppe-${groupNumber}`)!
let count = 0
for (const a of animals) {
const photos = (photosById.get(a.id) ?? []).filter((p) => isSelected(p.id))
let n = 1
for (const p of photos) {
try {
const blob = await fetch(photoSrc(p)).then((r) => (r.ok ? r.blob() : null))
if (!blob) continue
const ext = (p.fileName.split('.').pop() ?? 'jpg').toLowerCase()
folder.file(`${slug(a.name)}-${n}.${ext}`, blob)
n += 1
count += 1
} catch {
/* skip unreachable photo */
}
}
}
if (count === 0) {
setNotice(t.export.noPhotosSelected)
return
}
try {
setZipping(true)
const out = await zip.generateAsync({ type: 'blob' })
triggerDownload(out, `gruppe-${groupNumber}-fotos.zip`)
} catch {
setNotice(t.export.zipFailed)
} finally {
setZipping(false)
}
}
async function improveWithAi() {
setNotice(null)
const result = await ai.run({
animals: animals.map((a) => ({
name: a.name,
farbschlag: farbschlagOf(a),
dateOfBirth: a.dateOfBirth,
notes: personality[a.id] ?? a.notes ?? null,
})),
statusLine,
hints,
})
if (result.ok) setAiText(result.value.text)
// On failure (endpoint not configured yet) the notice below shows the German hint.
}
return (
<section className="group-composer">
<h3>{t.listing.headingFor(groupNumber, groupGender(animals))}</h3>
<div className="group-composer__controls">
<label className="field">
<span>{t.listing.status}</span>
<select value={status} onChange={(e) => setStatus(e.target.value as SaleStatus)}>
<option value="free">{t.listing.statusFree}</option>
<option value="loose">{t.listing.statusLooseReserved}</option>
<option value="reserved">{t.listing.statusReserved}</option>
</select>
</label>
{status === 'loose' && (
<label className="field">
<span>{t.listing.reservedName}</span>
<input
className="input"
value={reservedName}
onChange={(e) => setReservedName(e.target.value)}
/>
</label>
)}
</div>
<label className="field">
<span>{t.listing.tagline}</span>
<input
className="input"
value={tagline}
placeholder={t.listing.taglinePlaceholder}
onChange={(e) => setTagline(e.target.value)}
/>
</label>
{animals.map((a) => (
<div key={a.id} className="group-composer__animal">
<div className="group-composer__animal-head">
<FarbschlagImage name={farbschlagOf(a)} size={36} />
<strong>{a.name}</strong>
<span className="muted">
{farbschlagOf(a)} · {genderLabel(a.gender)}
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
</span>
</div>
<label className="field">
<span>{t.listing.personality}</span>
<textarea
value={personality[a.id] ?? ''}
placeholder={t.listing.personalityPlaceholder}
onChange={(e) => setPersonality((s) => ({ ...s, [a.id]: e.target.value }))}
/>
</label>
<div className="photo-select">
{(photosById.get(a.id) ?? []).length === 0 ? (
<small className="muted">{t.listing.noPhotos}</small>
) : (
(photosById.get(a.id) ?? []).map((p) => (
<button
key={p.id}
type="button"
className={isSelected(p.id) ? 'photo-chip photo-chip--on' : 'photo-chip'}
onClick={() => togglePhoto(p.id)}
aria-pressed={isSelected(p.id)}
>
<img src={photoSrc(p)} alt={p.caption ?? a.name} loading="lazy" />
</button>
))
)}
</div>
</div>
))}
<label className="field">
<span>{t.ai.hintsLabel}</span>
<input className="input" value={hints} onChange={(e) => setHints(e.target.value)} />
</label>
<pre className="listing-preview">{aiText ?? listingText}</pre>
<div className="group-composer__actions">
<button type="button" className="btn btn--primary" onClick={copyText}>
{t.export.copyText}
</button>
<button type="button" className="btn" disabled={zipping} onClick={downloadPhotos}>
{zipping ? t.export.zipping : t.export.downloadPhotos}
</button>
<button type="button" className="btn" disabled={ai.pending} onClick={improveWithAi}>
{ai.pending ? t.ai.generating : t.ai.improve}
</button>
<button type="button" className="btn" title={t.export.finishHint} disabled>
{t.export.finish}
</button>
</div>
{ai.error && <div className="alert alert--warning">{t.ai.notConfigured}</div>}
{notice && <div className="alert">{notice}</div>}
</section>
)
}
function slug(name: string): string {
return name
.toLowerCase()
.replace(/[äöü]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[c] ?? c)
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}

View File

@@ -1,11 +1,115 @@
import { useMemo, useState } from 'react'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import type { Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
import GroupComposer from '../components/GroupComposer'
import './abgabe.css'
const UNGROUPED = 'ungrouped'
export default function AbgabePage() {
// Fleshed out in the next FEAT-12a increments (grouping + listing composer + export).
const t = de.pages.abgabe
const forSale = useApi(
() =>
listGerbils({
filter: condition({ field: 'status', op: '==', value: 'ForSale' }),
orderBy: 'name',
page: 1,
pageSize: 1000,
}),
[],
)
const colorVarieties = useApi(() => listColorVarieties(), [])
const animals = useMemo(() => forSale.data?.items ?? [], [forSale.data])
const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
// Manual group assignment, seeded from Becken (enclosure) occupancy.
const [groupOf, setGroupOf] = useState<Record<string, string>>({})
const assignment = (g: Gerbil): string =>
groupOf[g.id] ?? g.enclosureId ?? UNGROUPED
// Ordered distinct group ids (in animal order) -> stable 1-based numbering.
const groupIds = useMemo(() => {
const seen: string[] = []
for (const a of animals) {
const gid = assignment(a)
if (!seen.includes(gid)) seen.push(gid)
}
return seen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animals, groupOf])
const farbschlagOf = (g: Gerbil): string => {
if (g.colorVarietyId && colorName.has(g.colorVarietyId)) return colorName.get(g.colorVarietyId)!
if (g.genotype) {
try {
return genotypeToFarbschlag(fromDisplayString(g.genotype))
} catch {
/* fall through */
}
}
return '—'
}
function moveTo(gerbilId: string, groupId: string) {
setGroupOf((s) => ({ ...s, [gerbilId]: groupId }))
}
if (forSale.loading) return <p className="muted">{de.common.loading}</p>
return (
<section className="page">
<h2>{de.pages.abgabe.title}</h2>
<p className="muted">{de.pages.abgabe.subtitle}</p>
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
{animals.length === 0 ? (
<p className="muted">{t.empty}</p>
) : (
<>
{/* Regrouping roster */}
<div className="abgabe-roster">
<h3>{t.grouping.title}</h3>
<p className="muted">{t.grouping.byEnclosure}</p>
<ul className="abgabe-roster__list">
{animals.map((a) => (
<li key={a.id}>
<span>{a.name}</span>
<select
value={assignment(a)}
onChange={(e) => moveTo(a.id, e.target.value)}
aria-label={t.grouping.moveTo}
>
{groupIds.map((gid, i) => (
<option key={gid} value={gid}>
{t.grouping.groupLabel} {i + 1}
</option>
))}
<option value={`new-${a.id}`}>{t.grouping.newGroup}</option>
</select>
</li>
))}
</ul>
</div>
{groupIds.map((gid, i) => (
<GroupComposer
key={gid}
groupNumber={i + 1}
animals={animals.filter((a) => assignment(a) === gid)}
farbschlagOf={farbschlagOf}
/>
))}
</>
)}
</section>
)
}

View File

@@ -0,0 +1,105 @@
/* FEAT-12a Abgabe — page-scoped styles (keeps index.css contention-free). */
.abgabe-roster {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 0.75rem 1rem;
margin: 1rem 0;
background: var(--color-surface);
}
.abgabe-roster__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.abgabe-roster__list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.abgabe-roster__list select {
width: auto;
min-width: 9rem;
}
.group-composer {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 1rem;
margin: 1rem 0;
background: var(--color-surface);
}
.group-composer__controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.group-composer__animal {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-top: 0.75rem;
}
.group-composer__animal-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.photo-select {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.4rem;
}
.photo-chip {
padding: 0;
border: 2px solid var(--color-border);
border-radius: 0.4rem;
background: none;
cursor: pointer;
opacity: 0.5;
line-height: 0;
}
.photo-chip--on {
border-color: var(--color-accent);
opacity: 1;
}
.photo-chip img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 0.25rem;
}
.listing-preview {
white-space: pre-wrap;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.75rem;
font-family: inherit;
font-size: 0.9rem;
margin: 0.75rem 0;
}
.group-composer__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}