feat(frontend): Koeniginnen-Views (Modul 4)
/queens: Liste; /queens/new: Anlegeformular; /queens/[id]: Bearbeiten (Zucht/Herkunft/Status/Notizen), "Volk zuweisen"-Dialog (nur ACTIVE Voelker zur Auswahl) und Volk-Historie. Bearbeitungsformular initialisiert Zustand direkt aus der geladenen Queen (gleiches Key-Reset-Pattern wie bei Apiaries/Colonies). Nav-Leiste und proxy.ts-Matcher um /queens erweitert. 10 Vitest-Tests, Lint, Typecheck und Build gruen. Kompletter Flow (Anlegen, Volk zuweisen, Historie) live im Browser gegen den echten Server verifiziert.
This commit is contained in:
1 parent
b18d61640d
commit
49ce23147c
12 files changed
+593
-2
No files matched your search
@@ -22,5 +22,11 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/login", "/settings/:path*", "/apiaries/:path*", "/colonies/:path*"],
|
||||
matcher: [
|
||||
"/login",
|
||||
"/settings/:path*",
|
||||
"/apiaries/:path*",
|
||||
"/colonies/:path*",
|
||||
"/queens/:path*",
|
||||
],
|
||||
};
|
||||
@@ -31,6 +31,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/colonies">
|
||||
Völker
|
||||
</Link>
|
||||
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/queens">
|
||||
Königinnen
|
||||
</Link>
|
||||
{user.role === "ADMIN" ? (
|
||||
<Link
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { assignQueenColony } from "@/lib/queens/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function AssignColonyDialog({ queenId }: { queenId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState("");
|
||||
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: fetchColonies });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => assignQueenColony(queenId, { colonyId }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens", queenId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens", queenId, "history"] });
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button variant="outline">Volk zuweisen</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Königin einem Volk zuweisen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="assign-colony">Volk</Label>
|
||||
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
|
||||
<SelectTrigger id="assign-colony">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(colonies ?? [])
|
||||
.filter((colony) => colony.status === "ACTIVE")
|
||||
.map((colony) => (
|
||||
<SelectItem key={colony.id} value={colony.id}>
|
||||
{colony.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!colonyId || mutation.isPending}>
|
||||
Zuweisen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchQueen } from "@/lib/queens/api";
|
||||
import { QueenEditCard } from "./queen-edit-card";
|
||||
import { AssignColonyDialog } from "./assign-colony-dialog";
|
||||
import { QueenHistoryCard } from "./queen-history";
|
||||
|
||||
export default function QueenDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { data: queen, isLoading, isError } = useQuery({
|
||||
queryKey: ["queens", params.id],
|
||||
queryFn: () => fetchQueen(params.id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
||||
}
|
||||
if (isError || !queen) {
|
||||
return (
|
||||
<p role="alert" className="p-4 text-sm text-red-600 sm:p-6">
|
||||
Königin konnte nicht geladen werden.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<QueenEditCard key={queen.id} queen={queen} />
|
||||
<AssignColonyDialog queenId={queen.id} />
|
||||
<QueenHistoryCard queenId={queen.id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateQueen } from "@/lib/queens/api";
|
||||
import { Queen, QueenStatus } from "@/lib/queens/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const QUEEN_STATUSES: QueenStatus[] = [
|
||||
"ACTIVE",
|
||||
"RESERVE",
|
||||
"UNMATED",
|
||||
"MATED",
|
||||
"LOST",
|
||||
"REPLACED",
|
||||
"SOLD",
|
||||
"DECEASED",
|
||||
];
|
||||
|
||||
export function QueenEditCard({ queen }: { queen: Queen }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [breed, setBreed] = useState(queen.breed ?? "");
|
||||
const [origin, setOrigin] = useState(queen.origin ?? "");
|
||||
const [notes, setNotes] = useState(queen.notes ?? "");
|
||||
const [status, setStatus] = useState<QueenStatus>(queen.status);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateQueen(queen.id, {
|
||||
breed: breed || undefined,
|
||||
origin: origin || undefined,
|
||||
notes: notes || undefined,
|
||||
status,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens", queen.id] });
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{queen.code}</CardTitle>
|
||||
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
|
||||
{queen.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
|
||||
<Input
|
||||
id="queen-breed"
|
||||
value={breed}
|
||||
onChange={(event) => setBreed(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-origin">Herkunft</Label>
|
||||
<Input
|
||||
id="queen-origin"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-status">Status</Label>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
|
||||
<SelectTrigger id="queen-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUEEN_STATUSES.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="queen-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchQueenHistory } from "@/lib/queens/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function QueenHistoryCard({ queenId }: { queenId: string }) {
|
||||
const { data: history, isLoading } = useQuery({
|
||||
queryKey: ["queens", queenId, "history"],
|
||||
queryFn: () => fetchQueenHistory(queenId),
|
||||
});
|
||||
|
||||
if (isLoading || !history) {
|
||||
return <p className="text-sm text-muted-foreground">Historie lädt…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Volk-Historie</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-muted-foreground">Noch keine Historie vorhanden.</p>
|
||||
) : null}
|
||||
{history.map((entry) => (
|
||||
<p key={entry.id}>
|
||||
Seit {new Date(entry.startDate).toLocaleDateString("de-DE")}
|
||||
{entry.endDate
|
||||
? ` bis ${new Date(entry.endDate).toLocaleDateString("de-DE")}`
|
||||
: " (aktuell)"}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createQueen } from "@/lib/queens/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function NewQueenPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [code, setCode] = useState("");
|
||||
const [breed, setBreed] = useState("");
|
||||
const [origin, setOrigin] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createQueen,
|
||||
onSuccess: (queen) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens"] });
|
||||
router.push(`/queens/${queen.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Königin konnte nicht angelegt werden.");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({ code, breed: breed || undefined, origin: origin || undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-4 sm:p-6">
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Königin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-code">Code</Label>
|
||||
<Input
|
||||
id="queen-code"
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
|
||||
<Input
|
||||
id="queen-breed"
|
||||
value={breed}
|
||||
onChange={(event) => setBreed(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="queen-origin">Herkunft</Label>
|
||||
<Input
|
||||
id="queen-origin"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import QueensPage from "./page";
|
||||
import { fetchQueens } from "@/lib/queens/api";
|
||||
|
||||
vi.mock("@/lib/queens/api", () => ({
|
||||
fetchQueens: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithClient(ui: ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("QueensPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchQueens).mockReset();
|
||||
});
|
||||
|
||||
it("renders the fetched queens", async () => {
|
||||
vi.mocked(fetchQueens).mockResolvedValueOnce([
|
||||
{
|
||||
id: "1",
|
||||
code: "Q-001",
|
||||
hatchYear: null,
|
||||
breed: "Carnica",
|
||||
origin: null,
|
||||
breeder: null,
|
||||
markingColor: null,
|
||||
markingNumber: null,
|
||||
matingType: null,
|
||||
matingStatus: null,
|
||||
inseminationData: null,
|
||||
motherLine: null,
|
||||
fatherLine: null,
|
||||
status: "ACTIVE",
|
||||
notes: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deactivatedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithClient(<QueensPage />);
|
||||
|
||||
expect(await screen.findByText("Q-001")).toBeInTheDocument();
|
||||
expect(screen.getByText("Carnica")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchQueens } from "@/lib/queens/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export default function QueensPage() {
|
||||
const { data: queens, isLoading, isError } = useQuery({
|
||||
queryKey: ["queens"],
|
||||
queryFn: fetchQueens,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Königinnen</h1>
|
||||
<Button nativeButton={false} render={<Link href="/queens/new" />}>
|
||||
+ Neue Königin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
Königinnen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{queens ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Zucht</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{queens.map((queen) => (
|
||||
<TableRow key={queen.id}>
|
||||
<TableCell>
|
||||
<Link className="underline" href={`/queens/${queen.id}`}>
|
||||
{queen.code}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{queen.breed ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
|
||||
{queen.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
AssignColonyInput,
|
||||
CreateQueenInput,
|
||||
Queen,
|
||||
QueenColonyHistoryEntry,
|
||||
UpdateQueenInput,
|
||||
} from "./types";
|
||||
|
||||
export function fetchQueens(): Promise<Queen[]> {
|
||||
return apiFetch<Queen[]>("/queens");
|
||||
}
|
||||
|
||||
export function fetchQueen(id: string): Promise<Queen> {
|
||||
return apiFetch<Queen>(`/queens/${id}`);
|
||||
}
|
||||
|
||||
export function fetchQueenHistory(id: string): Promise<QueenColonyHistoryEntry[]> {
|
||||
return apiFetch<QueenColonyHistoryEntry[]>(`/queens/${id}/history`);
|
||||
}
|
||||
|
||||
export function createQueen(input: CreateQueenInput): Promise<Queen> {
|
||||
return apiFetch<Queen>("/queens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateQueen(id: string, input: UpdateQueenInput): Promise<Queen> {
|
||||
return apiFetch<Queen>(`/queens/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function assignQueenColony(id: string, input: AssignColonyInput): Promise<Queen> {
|
||||
return apiFetch<Queen>(`/queens/${id}/assign-colony`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type QueenStatus =
|
||||
| "ACTIVE"
|
||||
| "RESERVE"
|
||||
| "UNMATED"
|
||||
| "MATED"
|
||||
| "LOST"
|
||||
| "REPLACED"
|
||||
| "SOLD"
|
||||
| "DECEASED";
|
||||
|
||||
export interface Queen {
|
||||
id: string;
|
||||
code: string;
|
||||
hatchYear: number | null;
|
||||
breed: string | null;
|
||||
origin: string | null;
|
||||
breeder: string | null;
|
||||
markingColor: string | null;
|
||||
markingNumber: string | null;
|
||||
matingType: string | null;
|
||||
matingStatus: string | null;
|
||||
inseminationData: string | null;
|
||||
motherLine: string | null;
|
||||
fatherLine: string | null;
|
||||
status: QueenStatus;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deactivatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateQueenInput {
|
||||
code: string;
|
||||
breed?: string;
|
||||
origin?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateQueenInput {
|
||||
breed?: string;
|
||||
origin?: string;
|
||||
notes?: string;
|
||||
status?: QueenStatus;
|
||||
}
|
||||
|
||||
export interface AssignColonyInput {
|
||||
colonyId: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface QueenColonyHistoryEntry {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
Reference in new issue
Block a user