Compare commits
5 Commits
e4b7558abd
...
3b3497079b
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b3497079b | |||
| dada3af968 | |||
| 6a37f9e46e | |||
| 44f306d18f | |||
| cbe77c927a |
224
GerbilManager.Tests/ContractGeneratorTests.cs
Normal file
224
GerbilManager.Tests/ContractGeneratorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal 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<BreederProfile>()</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; } = "";
|
||||
}
|
||||
34
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
34
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal 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);
|
||||
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal file
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal 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 4–8 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
12
GerbilManagerWebAPI/appsettings.Production.json
Normal file
12
GerbilManagerWebAPI/appsettings.Production.json
Normal 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
178
deploy/README.md
Normal 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
|
||||
```
|
||||
126
deploy/scripts/Backup-GerbilManager.ps1
Normal file
126
deploy/scripts/Backup-GerbilManager.ps1
Normal 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"
|
||||
63
deploy/scripts/Register-AutoStart.ps1
Normal file
63
deploy/scripts/Register-AutoStart.ps1
Normal 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"
|
||||
69
deploy/scripts/Register-BackupTask.ps1
Normal file
69
deploy/scripts/Register-BackupTask.ps1
Normal 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`""
|
||||
152
deploy/scripts/Restore-GerbilManager.ps1
Normal file
152
deploy/scripts/Restore-GerbilManager.ps1
Normal 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."
|
||||
72
deploy/scripts/Start-GerbilManager.ps1
Normal file
72
deploy/scripts/Start-GerbilManager.ps1
Normal 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"
|
||||
}
|
||||
49
deploy/scripts/Stop-GerbilManager.ps1
Normal file
49
deploy/scripts/Stop-GerbilManager.ps1
Normal 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)"
|
||||
276
gerbil-manager-web/src/components/GroupComposer.tsx
Normal file
276
gerbil-manager-web/src/components/GroupComposer.tsx
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
105
gerbil-manager-web/src/pages/abgabe.css
Normal file
105
gerbil-manager-web/src/pages/abgabe.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user