feat: Königinnen-Abstammungsbaum (Mutterlinie)
Neue Funktion jenseits der Lastenheft-Checkliste, explizit im Text als spätere Erweiterung angedacht (§4.4: "ApiCore SOLL später Abstammungsbäume darstellen können"). Königin.motherQueenId ist eine neue optionale Selbstreferenz (additive Migration), zusätzlich zu den bestehenden freien Textfeldern motherLine/fatherLine – letztere bleiben für Fälle, in denen die Mutter nicht selbst in ApiCore erfasst ist. Die Vaterlinie bleibt bewusst Freitext, da eine Königin mit vielen Drohnen aus unterschiedlichen Völkern begattet wird und es keine einzelne "Vater-Königin" gibt, die referenzierbar wäre. Backend: GET /queens/:id/lineage baut die mütterliche Ahnenkette (rekursiv über motherQueenId, Tiefe 8) sowie den Töchterbaum (rekursiv über die neue daughterQueens-Relation) in einer Antwort; create/update lehnen eine nicht existierende oder auf sich selbst zeigende motherQueenId ab. Frontend: neue "Abstammungsbaum"-Karte auf der Königin-Detailseite zeigt die mütterliche Linie als Liste sowie die Töchter als eingerückten Baum, beide mit Links zu den jeweiligen Königinnen. Anlage- und Bearbeiten-Formular bekommen ein "Mutter"-Auswahlfeld (Bearbeiten schließt die Königin selbst aus der Liste aus). Live in Chrome verifiziert: dreistufige Kette (Großmutter → Mutter → Tochter) korrekt in beide Richtungen dargestellt; dabei einen echten Anzeigefehler gefunden und behoben – das Mutter-Auswahlfeld zeigte nach dem Speichern die rohe Datenbank-ID statt des Königin-Codes an (derselbe SelectValue-ohne-children-Fehler wie zuvor bei der Import-Entitätsauswahl).
This commit is contained in:
1 parent
68f459001a
commit
e458a7cf07
14 files changed
+475
-10
No files matched your search
@@ -9,6 +9,7 @@ import { QueenEditCard } from "./queen-edit-card";
|
||||
import { AssignColonyDialog } from "./assign-colony-dialog";
|
||||
import { QueenHistoryCard } from "./queen-history";
|
||||
import { QueenEvaluationsCard } from "./queen-evaluations";
|
||||
import { QueenLineageCard } from "./queen-lineage";
|
||||
|
||||
export default function QueenDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -36,6 +37,7 @@ export default function QueenDetailPage() {
|
||||
<QueenEditCard key={queen.id} queen={queen} />
|
||||
<AssignColonyDialog queenId={queen.id} />
|
||||
<QueenEvaluationsCard queenId={queen.id} />
|
||||
<QueenLineageCard queenId={queen.id} />
|
||||
<QueenHistoryCard queenId={queen.id} />
|
||||
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
|
||||
</main>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Crown } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateQueen } from "@/lib/queens/api";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchQueens, updateQueen } from "@/lib/queens/api";
|
||||
import { Queen, QueenStatus } from "@/lib/queens/types";
|
||||
import { statusTone } from "@/lib/status-tone";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,14 +40,22 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [breed, setBreed] = useState(queen.breed ?? "");
|
||||
const [origin, setOrigin] = useState(queen.origin ?? "");
|
||||
const [motherQueenId, setMotherQueenId] = useState(queen.motherQueenId ?? "");
|
||||
const [notes, setNotes] = useState(queen.notes ?? "");
|
||||
const [status, setStatus] = useState<QueenStatus>(queen.status);
|
||||
|
||||
const { data: queens } = useQuery({
|
||||
queryKey: ["queens"],
|
||||
queryFn: () => fetchQueens(),
|
||||
});
|
||||
const motherOptions = queens?.filter((option) => option.id !== queen.id) ?? [];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateQueen(queen.id, {
|
||||
breed: breed || undefined,
|
||||
origin: origin || undefined,
|
||||
motherQueenId: motherQueenId || undefined,
|
||||
notes: notes || undefined,
|
||||
status,
|
||||
}),
|
||||
@@ -94,6 +102,31 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-mother">Mutter</Label>
|
||||
<Select
|
||||
value={motherQueenId || "NONE"}
|
||||
onValueChange={(value) =>
|
||||
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="queen-mother" className="w-full">
|
||||
<SelectValue placeholder="Keine Mutter hinterlegt">
|
||||
{motherQueenId
|
||||
? motherOptions.find((option) => option.id === motherQueenId)?.code
|
||||
: "Keine Mutter hinterlegt"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
|
||||
{motherOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-status">Status</Label>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchQueenLineage } from "@/lib/queens/api";
|
||||
import { QueenLineageDescendant } from "@/lib/queens/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
function DescendantTree({ nodes }: { nodes: QueenLineageDescendant[] }) {
|
||||
if (nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col gap-1.5 border-l pl-4">
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<Link href={`/queens/${node.id}`} className="font-mono text-sm underline">
|
||||
{node.code}
|
||||
</Link>
|
||||
{node.hatchYear ? (
|
||||
<span className="text-muted-foreground"> ({node.hatchYear})</span>
|
||||
) : null}
|
||||
{node.daughters.length > 0 ? (
|
||||
<div className="mt-1.5">
|
||||
<DescendantTree nodes={node.daughters} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueenLineageCard({ queenId }: { queenId: string }) {
|
||||
const { data: lineage, isLoading } = useQuery({
|
||||
queryKey: ["queens", queenId, "lineage"],
|
||||
queryFn: () => fetchQueenLineage(queenId),
|
||||
});
|
||||
|
||||
const ancestors: { id: string; code: string; hatchYear: number | null }[] = [];
|
||||
let current = lineage?.queen.mother ?? null;
|
||||
while (current) {
|
||||
ancestors.push(current);
|
||||
current = current.mother;
|
||||
}
|
||||
|
||||
const hasLineage = ancestors.length > 0 || (lineage?.descendants.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch className="size-4" />
|
||||
Abstammungsbaum
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 text-sm">
|
||||
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||
{!isLoading && !hasLineage ? (
|
||||
<p className="text-muted-foreground">
|
||||
Keine Abstammungsdaten hinterlegt. Trage bei einer Tochterkönigin
|
||||
diese Königin als Mutter ein, um den Stammbaum aufzubauen.
|
||||
</p>
|
||||
) : null}
|
||||
{ancestors.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 font-medium">Mütterliche Linie</p>
|
||||
<ol className="flex flex-col gap-1">
|
||||
{ancestors.map((ancestor) => (
|
||||
<li key={ancestor.id}>
|
||||
<Link
|
||||
href={`/queens/${ancestor.id}`}
|
||||
className="font-mono underline"
|
||||
>
|
||||
{ancestor.code}
|
||||
</Link>
|
||||
{ancestor.hatchYear ? (
|
||||
<span className="text-muted-foreground"> ({ancestor.hatchYear})</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
{lineage && lineage.descendants.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 font-medium">Töchter</p>
|
||||
<DescendantTree nodes={lineage.descendants} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Crown } from "lucide-react";
|
||||
import { createQueen } from "@/lib/queens/api";
|
||||
import { createQueen, fetchQueens } from "@/lib/queens/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export default function NewQueenPage() {
|
||||
const router = useRouter();
|
||||
@@ -17,8 +24,14 @@ export default function NewQueenPage() {
|
||||
const [code, setCode] = useState("");
|
||||
const [breed, setBreed] = useState("");
|
||||
const [origin, setOrigin] = useState("");
|
||||
const [motherQueenId, setMotherQueenId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: queens } = useQuery({
|
||||
queryKey: ["queens"],
|
||||
queryFn: () => fetchQueens(),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createQueen,
|
||||
onSuccess: (queen) => {
|
||||
@@ -33,7 +46,12 @@ export default function NewQueenPage() {
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({ code, breed: breed || undefined, origin: origin || undefined });
|
||||
mutation.mutate({
|
||||
code,
|
||||
breed: breed || undefined,
|
||||
origin: origin || undefined,
|
||||
motherQueenId: motherQueenId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,6 +90,31 @@ export default function NewQueenPage() {
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-mother">Mutter</Label>
|
||||
<Select
|
||||
value={motherQueenId || "NONE"}
|
||||
onValueChange={(value) =>
|
||||
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="queen-mother" className="w-full">
|
||||
<SelectValue placeholder="Keine Mutter hinterlegt">
|
||||
{motherQueenId
|
||||
? queens?.find((option) => option.id === motherQueenId)?.code
|
||||
: "Keine Mutter hinterlegt"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
|
||||
{queens?.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("QueensPage", () => {
|
||||
inseminationData: null,
|
||||
motherLine: null,
|
||||
fatherLine: null,
|
||||
motherQueenId: null,
|
||||
status: "ACTIVE",
|
||||
notes: null,
|
||||
createdAt: "",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Queen,
|
||||
QueenColonyHistoryEntry,
|
||||
QueenEvaluation,
|
||||
QueenLineage,
|
||||
QueenStatus,
|
||||
UpdateQueenInput,
|
||||
} from "./types";
|
||||
@@ -29,6 +30,10 @@ export function fetchQueenHistory(id: string): Promise<QueenColonyHistoryEntry[]
|
||||
return apiFetch<QueenColonyHistoryEntry[]>(`/queens/${id}/history`);
|
||||
}
|
||||
|
||||
export function fetchQueenLineage(id: string): Promise<QueenLineage> {
|
||||
return apiFetch<QueenLineage>(`/queens/${id}/lineage`);
|
||||
}
|
||||
|
||||
export function createQueen(input: CreateQueenInput): Promise<Queen> {
|
||||
return apiFetch<Queen>("/queens", {
|
||||
method: "POST",
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Queen {
|
||||
inseminationData: string | null;
|
||||
motherLine: string | null;
|
||||
fatherLine: string | null;
|
||||
motherQueenId: string | null;
|
||||
status: QueenStatus;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
@@ -33,16 +34,39 @@ export interface CreateQueenInput {
|
||||
code: string;
|
||||
breed?: string;
|
||||
origin?: string;
|
||||
motherQueenId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateQueenInput {
|
||||
breed?: string;
|
||||
origin?: string;
|
||||
motherQueenId?: string;
|
||||
notes?: string;
|
||||
status?: QueenStatus;
|
||||
}
|
||||
|
||||
export interface QueenLineageAncestor {
|
||||
id: string;
|
||||
code: string;
|
||||
hatchYear: number | null;
|
||||
status: QueenStatus;
|
||||
mother: QueenLineageAncestor | null;
|
||||
}
|
||||
|
||||
export interface QueenLineageDescendant {
|
||||
id: string;
|
||||
code: string;
|
||||
hatchYear: number | null;
|
||||
status: QueenStatus;
|
||||
daughters: QueenLineageDescendant[];
|
||||
}
|
||||
|
||||
export interface QueenLineage {
|
||||
queen: QueenLineageAncestor;
|
||||
descendants: QueenLineageDescendant[];
|
||||
}
|
||||
|
||||
export interface AssignColonyInput {
|
||||
colonyId: string;
|
||||
notes?: string;
|
||||
|
||||
Reference in new issue
Block a user