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:
Nick Szaunig committed 2026-08-25 05:13:45 +02:00
1 parent 68f459001a
commit e458a7cf07
14 files changed
+472 -7

No files matched your search

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Queen" ADD COLUMN "motherQueenId" TEXT;
-- CreateIndex
CREATE INDEX "Queen_motherQueenId_idx" ON "Queen"("motherQueenId");
-- AddForeignKey
ALTER TABLE "Queen" ADD CONSTRAINT "Queen_motherQueenId_fkey" FOREIGN KEY ("motherQueenId") REFERENCES "Queen"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+4
View File
@@ -249,17 +249,21 @@ model Queen {
inseminationData String? inseminationData String?
motherLine String? motherLine String?
fatherLine String? fatherLine String?
motherQueenId String?
status QueenStatus @default(ACTIVE) status QueenStatus @default(ACTIVE)
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deactivatedAt DateTime? deactivatedAt DateTime?
motherQueen Queen? @relation("QueenMotherline", fields: [motherQueenId], references: [id])
daughterQueens Queen[] @relation("QueenMotherline")
colony Colony? @relation("ColonyCurrentQueen") colony Colony? @relation("ColonyCurrentQueen")
colonyHistory QueenColonyHistory[] colonyHistory QueenColonyHistory[]
evaluations QueenEvaluation[] evaluations QueenEvaluation[]
@@index([status]) @@index([status])
@@index([motherQueenId])
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -61,6 +61,11 @@ export class CreateQueenDto {
@IsString() @IsString()
fatherLine?: string; fatherLine?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
motherQueenId?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -39,6 +39,11 @@ export class QueensController {
return this.queensService.getHistory(id); return this.queensService.getHistory(id);
} }
@Get(':id/lineage')
getLineage(@Param('id') id: string) {
return this.queensService.getLineage(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER) @Roles(Role.ADMIN, Role.BEEKEEPER)
@Post() @Post()
create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) { create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) {
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { QueensService } from './queens.service'; import { QueensService } from './queens.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
@@ -133,4 +133,89 @@ describe('QueensService', () => {
NotFoundException, NotFoundException,
); );
}); });
it('create rejects a motherQueenId that does not exist', async () => {
prisma.queen.findUnique.mockResolvedValueOnce(null);
await expect(
service.create({ code: 'Q-002', motherQueenId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.queen.create).not.toHaveBeenCalled();
});
it('update rejects a queen being set as her own mother', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
await expect(
service.update('queen-1', { motherQueenId: 'queen-1' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.queen.update).not.toHaveBeenCalled();
});
it('update rejects a motherQueenId that does not exist', async () => {
prisma.queen.findUnique
.mockResolvedValueOnce(queen)
.mockResolvedValueOnce(null);
await expect(
service.update('queen-1', { motherQueenId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.queen.update).not.toHaveBeenCalled();
});
it('getLineage builds the ancestor chain and the descendant tree', async () => {
const grandmother = {
id: 'queen-gm',
code: 'Q-GM',
hatchYear: 2022,
status: 'ACTIVE',
motherQueenId: null,
};
const mother = {
id: 'queen-m',
code: 'Q-M',
hatchYear: 2023,
status: 'ACTIVE',
motherQueenId: 'queen-gm',
};
const self = {
id: 'queen-1',
code: 'Q-001',
hatchYear: 2024,
status: 'ACTIVE',
motherQueenId: 'queen-m',
};
const daughter = {
id: 'queen-d',
code: 'Q-D',
hatchYear: 2025,
status: 'ACTIVE',
motherQueenId: 'queen-1',
};
prisma.queen.findUnique.mockImplementation(
({ where }: { where: { id: string } }) => {
const byId: Record<string, unknown> = {
'queen-1': self,
'queen-m': mother,
'queen-gm': grandmother,
};
return Promise.resolve(byId[where.id] ?? null);
},
);
prisma.queen.findMany.mockImplementation(
({ where }: { where: { motherQueenId: string } }) => {
if (where.motherQueenId === 'queen-1')
return Promise.resolve([daughter]);
return Promise.resolve([]);
},
);
const lineage = await service.getLineage('queen-1');
expect(lineage.queen.code).toBe('Q-001');
expect(lineage.queen.mother?.code).toBe('Q-M');
expect(lineage.queen.mother?.mother?.code).toBe('Q-GM');
expect(lineage.queen.mother?.mother?.mother).toBeNull();
expect(lineage.descendants).toHaveLength(1);
expect(lineage.descendants[0].code).toBe('Q-D');
expect(lineage.descendants[0].daughters).toEqual([]);
});
}); });
+103 -1
View File
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
@@ -12,6 +16,29 @@ export interface QueenFilter {
status?: QueenStatus; 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[];
}
const MAX_LINEAGE_DEPTH = 8;
@Injectable() @Injectable()
export class QueensService { export class QueensService {
constructor( constructor(
@@ -36,6 +63,9 @@ export class QueensService {
} }
async create(dto: CreateQueenDto, actingUserId: string): Promise<Queen> { async create(dto: CreateQueenDto, actingUserId: string): Promise<Queen> {
if (dto.motherQueenId) {
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.create({ data: { ...dto } }); const queen = await this.prisma.queen.create({ data: { ...dto } });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'Queen', entityType: 'Queen',
@@ -54,6 +84,12 @@ export class QueensService {
actingUserId: string, actingUserId: string,
): Promise<Queen> { ): Promise<Queen> {
const before = await this.findById(id); const before = await this.findById(id);
if (dto.motherQueenId) {
if (dto.motherQueenId === id) {
throw new BadRequestException('A queen cannot be her own mother');
}
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.update({ const queen = await this.prisma.queen.update({
where: { id }, where: { id },
data: { ...dto }, data: { ...dto },
@@ -171,4 +207,70 @@ export class QueensService {
}); });
return evaluation; return evaluation;
} }
async getLineage(id: string): Promise<QueenLineage> {
const queen = await this.findById(id);
const ancestorChain = await this.buildAncestorChain(
queen,
MAX_LINEAGE_DEPTH,
);
const descendants = await this.buildDescendantTree(id, MAX_LINEAGE_DEPTH);
return { queen: ancestorChain, descendants };
}
private async assertMotherQueenExists(motherQueenId: string): Promise<void> {
const mother = await this.prisma.queen.findUnique({
where: { id: motherQueenId },
});
if (!mother) {
throw new NotFoundException('Mother queen not found');
}
}
private async buildAncestorChain(
queen: Queen,
depthRemaining: number,
): Promise<QueenLineageAncestor> {
const node: QueenLineageAncestor = {
id: queen.id,
code: queen.code,
hatchYear: queen.hatchYear,
status: queen.status,
mother: null,
};
if (queen.motherQueenId && depthRemaining > 0) {
const mother = await this.prisma.queen.findUnique({
where: { id: queen.motherQueenId },
});
if (mother) {
node.mother = await this.buildAncestorChain(mother, depthRemaining - 1);
}
}
return node;
}
private async buildDescendantTree(
queenId: string,
depthRemaining: number,
): Promise<QueenLineageDescendant[]> {
if (depthRemaining <= 0) {
return [];
}
const daughters = await this.prisma.queen.findMany({
where: { motherQueenId: queenId },
orderBy: { code: 'asc' },
});
return Promise.all(
daughters.map(async (daughter) => ({
id: daughter.id,
code: daughter.code,
hatchYear: daughter.hatchYear,
status: daughter.status,
daughters: await this.buildDescendantTree(
daughter.id,
depthRemaining - 1,
),
})),
);
}
} }
+53
View File
@@ -21,6 +21,7 @@ describe('Queens (e2e)', () => {
let apiaryId: string; let apiaryId: string;
let colonyId: string; let colonyId: string;
let queenId: string; let queenId: string;
let daughterQueenId: string;
beforeAll(async () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp();
@@ -63,6 +64,9 @@ describe('Queens (e2e)', () => {
data: { currentQueenId: null }, data: { currentQueenId: null },
}); });
} }
if (daughterQueenId) {
await prisma.queen.delete({ where: { id: daughterQueenId } });
}
if (queenId) { if (queenId) {
await prisma.queen.delete({ where: { id: queenId } }); await prisma.queen.delete({ where: { id: queenId } });
} }
@@ -130,4 +134,53 @@ describe('Queens (e2e)', () => {
expect(evaluations).toHaveLength(1); expect(evaluations).toHaveLength(1);
expect(evaluations[0]?.colonyStrength).toBe(4); expect(evaluations[0]?.colonyStrength).toBe(4);
}); });
it('rejects creating a queen with a motherQueenId that does not exist', () => {
return request(app.getHttpServer())
.post('/api/v1/queens')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ code: `Q-orphan-${Date.now()}`, motherQueenId: 'does-not-exist' })
.expect(404);
});
it('rejects a queen set as her own mother', () => {
return request(app.getHttpServer())
.patch(`/api/v1/queens/${queenId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ motherQueenId: queenId })
.expect(400);
});
it('creates a daughter queen and returns the ancestor/descendant lineage', async () => {
const daughterResponse = await request(app.getHttpServer())
.post('/api/v1/queens')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ code: `Q-daughter-${Date.now()}`, motherQueenId: queenId })
.expect(201);
daughterQueenId = (daughterResponse.body as QueenBody).id;
const motherLineage = await request(app.getHttpServer())
.get(`/api/v1/queens/${queenId}/lineage`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const motherBody = motherLineage.body as {
queen: { id: string; mother: unknown };
descendants: { id: string }[];
};
expect(motherBody.queen.id).toBe(queenId);
expect(motherBody.queen.mother).toBeNull();
expect(motherBody.descendants).toHaveLength(1);
expect(motherBody.descendants[0]?.id).toBe(daughterQueenId);
const daughterLineage = await request(app.getHttpServer())
.get(`/api/v1/queens/${daughterQueenId}/lineage`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const daughterBody = daughterLineage.body as {
queen: { id: string; mother: { id: string } | null };
descendants: unknown[];
};
expect(daughterBody.queen.mother?.id).toBe(queenId);
expect(daughterBody.descendants).toHaveLength(0);
});
}); });
@@ -9,6 +9,7 @@ import { QueenEditCard } from "./queen-edit-card";
import { AssignColonyDialog } from "./assign-colony-dialog"; import { AssignColonyDialog } from "./assign-colony-dialog";
import { QueenHistoryCard } from "./queen-history"; import { QueenHistoryCard } from "./queen-history";
import { QueenEvaluationsCard } from "./queen-evaluations"; import { QueenEvaluationsCard } from "./queen-evaluations";
import { QueenLineageCard } from "./queen-lineage";
export default function QueenDetailPage() { export default function QueenDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -36,6 +37,7 @@ export default function QueenDetailPage() {
<QueenEditCard key={queen.id} queen={queen} /> <QueenEditCard key={queen.id} queen={queen} />
<AssignColonyDialog queenId={queen.id} /> <AssignColonyDialog queenId={queen.id} />
<QueenEvaluationsCard queenId={queen.id} /> <QueenEvaluationsCard queenId={queen.id} />
<QueenLineageCard queenId={queen.id} />
<QueenHistoryCard queenId={queen.id} /> <QueenHistoryCard queenId={queen.id} />
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} /> <DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
</main> </main>
@@ -2,8 +2,8 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { Crown } from "lucide-react"; import { Crown } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { updateQueen } from "@/lib/queens/api"; import { fetchQueens, updateQueen } from "@/lib/queens/api";
import { Queen, QueenStatus } from "@/lib/queens/types"; import { Queen, QueenStatus } from "@/lib/queens/types";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -40,14 +40,22 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [breed, setBreed] = useState(queen.breed ?? ""); const [breed, setBreed] = useState(queen.breed ?? "");
const [origin, setOrigin] = useState(queen.origin ?? ""); const [origin, setOrigin] = useState(queen.origin ?? "");
const [motherQueenId, setMotherQueenId] = useState(queen.motherQueenId ?? "");
const [notes, setNotes] = useState(queen.notes ?? ""); const [notes, setNotes] = useState(queen.notes ?? "");
const [status, setStatus] = useState<QueenStatus>(queen.status); 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({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
updateQueen(queen.id, { updateQueen(queen.id, {
breed: breed || undefined, breed: breed || undefined,
origin: origin || undefined, origin: origin || undefined,
motherQueenId: motherQueenId || undefined,
notes: notes || undefined, notes: notes || undefined,
status, status,
}), }),
@@ -94,6 +102,31 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
onChange={(event) => setOrigin(event.target.value)} onChange={(event) => setOrigin(event.target.value)}
/> />
</div> </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"> <div className="flex flex-col gap-2">
<Label htmlFor="queen-status">Status</Label> <Label htmlFor="queen-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}> <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 { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; 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 { Crown } from "lucide-react";
import { createQueen } from "@/lib/queens/api"; import { createQueen, fetchQueens } from "@/lib/queens/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function NewQueenPage() { export default function NewQueenPage() {
const router = useRouter(); const router = useRouter();
@@ -17,8 +24,14 @@ export default function NewQueenPage() {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [breed, setBreed] = useState(""); const [breed, setBreed] = useState("");
const [origin, setOrigin] = useState(""); const [origin, setOrigin] = useState("");
const [motherQueenId, setMotherQueenId] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { data: queens } = useQuery({
queryKey: ["queens"],
queryFn: () => fetchQueens(),
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: createQueen, mutationFn: createQueen,
onSuccess: (queen) => { onSuccess: (queen) => {
@@ -33,7 +46,12 @@ export default function NewQueenPage() {
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
mutation.mutate({ code, breed: breed || undefined, origin: origin || undefined }); mutation.mutate({
code,
breed: breed || undefined,
origin: origin || undefined,
motherQueenId: motherQueenId || undefined,
});
} }
return ( return (
@@ -72,6 +90,31 @@ export default function NewQueenPage() {
onChange={(event) => setOrigin(event.target.value)} onChange={(event) => setOrigin(event.target.value)}
/> />
</div> </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 ? ( {error ? (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
{error} {error}
@@ -35,6 +35,7 @@ describe("QueensPage", () => {
inseminationData: null, inseminationData: null,
motherLine: null, motherLine: null,
fatherLine: null, fatherLine: null,
motherQueenId: null,
status: "ACTIVE", status: "ACTIVE",
notes: null, notes: null,
createdAt: "", createdAt: "",
+5
View File
@@ -6,6 +6,7 @@ import {
Queen, Queen,
QueenColonyHistoryEntry, QueenColonyHistoryEntry,
QueenEvaluation, QueenEvaluation,
QueenLineage,
QueenStatus, QueenStatus,
UpdateQueenInput, UpdateQueenInput,
} from "./types"; } from "./types";
@@ -29,6 +30,10 @@ export function fetchQueenHistory(id: string): Promise<QueenColonyHistoryEntry[]
return apiFetch<QueenColonyHistoryEntry[]>(`/queens/${id}/history`); 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> { export function createQueen(input: CreateQueenInput): Promise<Queen> {
return apiFetch<Queen>("/queens", { return apiFetch<Queen>("/queens", {
method: "POST", method: "POST",
+24
View File
@@ -22,6 +22,7 @@ export interface Queen {
inseminationData: string | null; inseminationData: string | null;
motherLine: string | null; motherLine: string | null;
fatherLine: string | null; fatherLine: string | null;
motherQueenId: string | null;
status: QueenStatus; status: QueenStatus;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
@@ -33,16 +34,39 @@ export interface CreateQueenInput {
code: string; code: string;
breed?: string; breed?: string;
origin?: string; origin?: string;
motherQueenId?: string;
notes?: string; notes?: string;
} }
export interface UpdateQueenInput { export interface UpdateQueenInput {
breed?: string; breed?: string;
origin?: string; origin?: string;
motherQueenId?: string;
notes?: string; notes?: string;
status?: QueenStatus; 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 { export interface AssignColonyInput {
colonyId: string; colonyId: string;
notes?: string; notes?: string;