fix:sample/plate 之前的开发

This commit is contained in:
彭帅
2026-05-28 11:56:17 +08:00
parent fc36bc83e3
commit 8b65de36b8
367 changed files with 57752 additions and 947 deletions

View File

@@ -0,0 +1,84 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
getCrossPedigreeSnapshot,
invalidateCrossPedigreeCache,
loadCrossPedigreeSnapshot,
type CrossPedigreeSnapshot,
} from "./crossPedigreeCache";
interface CrossPedigreeContextValue {
snapshot: CrossPedigreeSnapshot | null;
loading: boolean;
error: string | null;
refresh: (force?: boolean) => Promise<CrossPedigreeSnapshot>;
invalidate: () => void;
}
const CrossPedigreeContext = createContext<CrossPedigreeContextValue | null>(null);
export function CrossPedigreeProvider({ children }: { children: ReactNode }) {
const [snapshot, setSnapshot] = useState<CrossPedigreeSnapshot | null>(() => getCrossPedigreeSnapshot());
const [loading, setLoading] = useState(() => !getCrossPedigreeSnapshot());
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async (force = false) => {
const cached = getCrossPedigreeSnapshot();
if (!force && cached) {
setSnapshot(cached);
return cached;
}
setLoading(true);
setError(null);
try {
const next = await loadCrossPedigreeSnapshot(force);
setSnapshot(next);
return next;
} catch (event) {
const message = event instanceof Error ? event.message : "数据加载失败";
setError(message);
throw event;
} finally {
setLoading(false);
}
}, []);
const invalidate = useCallback(() => {
invalidateCrossPedigreeCache();
setSnapshot(null);
}, []);
useEffect(() => {
if (getCrossPedigreeSnapshot()) return;
refresh(false).catch(() => {});
}, [refresh]);
const value = useMemo(
() => ({ snapshot, loading, error, refresh, invalidate }),
[snapshot, loading, error, refresh, invalidate],
);
return (
<CrossPedigreeContext.Provider value={value}>
{children}
</CrossPedigreeContext.Provider>
);
}
export function useCrossPedigree() {
const context = useContext(CrossPedigreeContext);
if (!context) {
throw new Error("useCrossPedigree must be used within CrossPedigreeProvider");
}
return context;
}

View File

@@ -0,0 +1,576 @@
import type { Cross, CrossParent, CrossingProject, PedigreeNode, PlannedCross } from "@/lib/api/types.gen";
import { getAuthToken } from "@/utils/token";
import {
invalidateCrossPedigreeCache,
loadCrossPedigreeSnapshot,
} from "./crossPedigreeCache";
import { mapCross, mapCrossingProject, mapPlannedCross } from "./mappers";
import {
NONE_SELECT_VALUE,
type CrossParentFormState,
type CrossParentRow,
type CrossRecord,
type CrossingProjectRecord,
type PedigreeEdgeFormState,
type PedigreeEdgeRow,
type PedigreeRecord,
type PlannedCrossRecord,
type SelectOption,
} from "./types";
interface BrapiPagination {
currentPage: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface BrapiListResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: {
data: T[];
};
}
interface BrapiSingleResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: T;
}
type CrossingProjectPayload = Partial<Record<"name" | "description" | "program_id", unknown>>;
type PlannedCrossPayload = Partial<Record<"name" | "cross_type" | "status" | "crossing_project_id", unknown>>;
type CrossPayload = Partial<Record<"name" | "cross_type" | "crossing_project_id" | "planned_cross_id", unknown>>;
type PedigreePayload = Partial<Record<
"germplasm_id" | "crossing_project_id" | "crossing_year" | "family_code" | "pedigree_string",
unknown
>>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`);
}
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const optionalNumber = (value: unknown) => {
const normalized = optionalText(value);
if (normalized === null) return null;
const parsed = Number(normalized);
return Number.isNaN(parsed) ? null : parsed;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const buildCrossParent = (
parentType: unknown,
germplasmId: unknown,
observationUnitId: unknown,
): CrossParent | null => {
const parentTypeValue = optionalText(parentType);
const germplasmDbId = optionalText(germplasmId);
const observationUnitDbId = optionalText(observationUnitId);
if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null;
if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type");
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm 或 observation_unit 至少一项");
return {
parentType: parentTypeValue as CrossParent["parentType"],
...(germplasmDbId ? { germplasmDbId } : {}),
...(observationUnitDbId ? { observationUnitDbId } : {}),
};
};
export { mapCross, mapCrossingProject, mapPlannedCross } from "./mappers";
export { invalidateCrossPedigreeCache, loadCrossPedigreeSnapshot } from "./crossPedigreeCache";
const invalidateAfterMutation = () => {
invalidateCrossPedigreeCache();
};
export function normalizeCrossingProjectForm(record: CrossingProjectRecord): Record<string, unknown> {
return {
...record,
name: record.name ?? "",
description: record.description ?? "",
program_id: record.program_id && record.program_id !== NONE_SELECT_VALUE ? record.program_id : NONE_SELECT_VALUE,
};
}
export function normalizePlannedCrossForm(record: PlannedCrossRecord): Record<string, unknown> {
return {
...record,
name: record.name ?? "",
cross_type: record.cross_type && record.cross_type !== NONE_SELECT_VALUE ? record.cross_type : NONE_SELECT_VALUE,
status: record.status && record.status !== NONE_SELECT_VALUE ? record.status : "TODO",
crossing_project_id:
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
? record.crossing_project_id
: NONE_SELECT_VALUE,
};
}
export function normalizeCrossForm(record: CrossRecord): Record<string, unknown> {
return {
...record,
name: record.name ?? "",
cross_type: record.cross_type && record.cross_type !== NONE_SELECT_VALUE ? record.cross_type : NONE_SELECT_VALUE,
crossing_project_id:
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
? record.crossing_project_id
: NONE_SELECT_VALUE,
planned_cross_id:
record.planned_cross_id && record.planned_cross_id !== NONE_SELECT_VALUE
? record.planned_cross_id
: NONE_SELECT_VALUE,
};
}
export function buildCrossParentFormState(
crossId: string,
planned: boolean,
crossingProjectId: string | null,
crossingProjectName: string | null,
parent1: CrossParent | null | undefined,
parent2: CrossParent | null | undefined,
): CrossParentFormState {
return {
cross_id: crossId,
planned,
crossing_project_id: crossingProjectId,
crossing_project_name: crossingProjectName,
parent1_type: parent1?.parentType ?? NONE_SELECT_VALUE,
parent1_germplasm_id: parent1?.germplasmDbId ?? NONE_SELECT_VALUE,
parent1_observation_unit_id: parent1?.observationUnitDbId ?? NONE_SELECT_VALUE,
parent2_type: parent2?.parentType ?? NONE_SELECT_VALUE,
parent2_germplasm_id: parent2?.germplasmDbId ?? NONE_SELECT_VALUE,
parent2_observation_unit_id: parent2?.observationUnitDbId ?? NONE_SELECT_VALUE,
};
}
const crossingProjectBody = (payload: CrossingProjectPayload) => {
const programDbId = requiredText(payload.program_id, "请选择所属 Program");
return {
crossingProjectName: requiredText(payload.name, "请填写杂交项目名称"),
crossingProjectDescription: optionalText(payload.description),
programDbId,
};
};
const plannedCrossBody = (payload: PlannedCrossPayload) => ({
plannedCrossName: requiredText(payload.name, "请填写计划杂交名称"),
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
...(optionalText(payload.status) ? { status: optionalText(payload.status) } : { status: "TODO" }),
});
const crossBody = (payload: CrossPayload) => ({
crossName: requiredText(payload.name, "请填写实际杂交名称"),
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
...(optionalText(payload.planned_cross_id) ? { plannedCrossDbId: optionalText(payload.planned_cross_id) } : {}),
});
const mapPedigree = (node: PedigreeRecord & PedigreeNode): PedigreeRecord => {
const germplasmId = node.germplasmDbId || node.germplasm_id || null;
return {
...node,
id: germplasmId || node.pedigreeNodeDbId || node.id,
germplasmDbId: germplasmId,
germplasm_id: germplasmId,
germplasm_name: node.germplasm_name || node.germplasmName || null,
crossing_project_id: node.crossing_project_id || node.crossingProjectDbId || null,
crossing_project_name: node.crossing_project_name || node.crossingProjectName || null,
crossing_year: node.crossing_year ?? node.crossingYear ?? null,
family_code: node.family_code || node.familyCode || null,
pedigree_string: node.pedigree_string || node.pedigreeString || null,
parents: node.parents?.map((parent) => ({
germplasmDbId: parent.germplasmDbId,
germplasmName: parent.germplasmName,
parentType: parent.parentType,
})),
siblings: node.siblings?.map((sibling) => ({
germplasmDbId: sibling.germplasmDbId,
germplasmName: sibling.germplasmName,
})),
};
};
const pedigreeBody = (payload: PedigreePayload, requireGermplasm = false) => ({
germplasmDbId: requireGermplasm
? requiredText(payload.germplasm_id, "请选择 Germplasm")
: optionalText(payload.germplasm_id),
crossingProjectDbId: optionalText(payload.crossing_project_id),
crossingYear: optionalNumber(payload.crossing_year),
familyCode: optionalText(payload.family_code),
pedigreeString: optionalText(payload.pedigree_string),
});
export function normalizePedigreeForm(record: PedigreeRecord): Record<string, unknown> {
return {
...record,
germplasm_id:
record.germplasm_id && record.germplasm_id !== NONE_SELECT_VALUE ? record.germplasm_id : NONE_SELECT_VALUE,
crossing_project_id:
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
? record.crossing_project_id
: NONE_SELECT_VALUE,
crossing_year: record.crossing_year ?? "",
family_code: record.family_code ?? "",
pedigree_string: record.pedigree_string ?? "",
};
}
export function flattenPedigreeEdges(nodes: PedigreeRecord[]): PedigreeEdgeRow[] {
const rows: PedigreeEdgeRow[] = [];
const seen = new Set<string>();
for (const node of nodes) {
const thisNodeId = node.germplasm_id;
if (!thisNodeId) continue;
for (const parent of node.parents ?? []) {
const connectedNodeId = parent.germplasmDbId;
if (!connectedNodeId) continue;
const key = `parent:${thisNodeId}:${connectedNodeId}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({
id: key,
edge_type: "parent",
parent_type: parent.parentType ?? null,
this_node_id: thisNodeId,
this_node_name: node.germplasm_name,
connected_node_id: connectedNodeId,
connected_node_name: parent.germplasmName ?? null,
});
}
for (const sibling of node.siblings ?? []) {
const connectedNodeId = sibling.germplasmDbId;
if (!connectedNodeId) continue;
const [left, right] = [thisNodeId, connectedNodeId].sort();
const key = `sibling:${left}:${right}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({
id: key,
edge_type: "sibling",
parent_type: null,
this_node_id: thisNodeId,
this_node_name: node.germplasm_name,
connected_node_id: connectedNodeId,
connected_node_name: sibling.germplasmName ?? null,
read_only: true,
});
}
}
return rows;
}
const buildParentPayload = (
germplasmDbId: string,
parents: Array<{ germplasmDbId?: string; parentType?: string }>,
) => ({
germplasmDbId,
parents: parents
.filter((parent) => parent.germplasmDbId)
.map((parent) => ({
germplasmDbId: parent.germplasmDbId,
parentType: parent.parentType,
})),
});
async function updatePedigreeParents(
germplasmDbId: string,
parents: Array<{ germplasmDbId?: string; parentType?: string }>,
): Promise<void> {
await request<BrapiListResponse<PedigreeNode>>("/brapi/v2/pedigree", {
method: "PUT",
body: JSON.stringify({
[germplasmDbId]: buildParentPayload(germplasmDbId, parents),
}),
});
}
export async function fetchCrossPedigreeOptions(): Promise<{
programs: SelectOption[];
germplasm: SelectOption[];
crossingProjects: SelectOption[];
plannedCrosses: SelectOption[];
observationUnits: SelectOption[];
crosses: SelectOption[];
}> {
const snapshot = await loadCrossPedigreeSnapshot();
return {
programs: snapshot.programs,
germplasm: snapshot.germplasm,
crossingProjects: snapshot.crossingProjectOptions,
plannedCrosses: snapshot.plannedCrossOptions,
observationUnits: snapshot.observationUnits,
crosses: snapshot.crossOptions,
};
}
export async function fetchCrossingProjectRows(): Promise<CrossingProjectRecord[]> {
const snapshot = await loadCrossPedigreeSnapshot();
return snapshot.crossingProjects;
}
export async function fetchCrossingProjectDetail(id: string): Promise<CrossingProjectRecord> {
const response = await request<BrapiSingleResponse<CrossingProject>>(
`/brapi/v2/crossingprojects/${encodeURIComponent(id)}`,
);
return mapCrossingProject(response.result);
}
export async function createCrossingProjectRow(payload: CrossingProjectPayload): Promise<CrossingProjectRecord> {
const response = await request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects", {
method: "POST",
body: JSON.stringify([crossingProjectBody(payload)]),
});
invalidateAfterMutation();
return mapCrossingProject(response.result.data[0]);
}
export async function updateCrossingProjectRow(id: string, payload: CrossingProjectPayload): Promise<CrossingProjectRecord> {
const response = await request<BrapiSingleResponse<CrossingProject>>(
`/brapi/v2/crossingprojects/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(crossingProjectBody(payload)),
},
);
invalidateAfterMutation();
return mapCrossingProject(response.result);
}
export async function fetchPlannedCrossRows(): Promise<PlannedCrossRecord[]> {
const snapshot = await loadCrossPedigreeSnapshot();
return snapshot.plannedCrosses;
}
export async function createPlannedCrossRow(payload: PlannedCrossPayload): Promise<PlannedCrossRecord> {
const response = await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
method: "POST",
body: JSON.stringify([plannedCrossBody(payload)]),
});
invalidateAfterMutation();
return mapPlannedCross(response.result.data[0]);
}
export async function updatePlannedCrossRow(id: string, payload: PlannedCrossPayload): Promise<PlannedCrossRecord> {
const response = await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
method: "PUT",
body: JSON.stringify({ [id]: plannedCrossBody(payload) }),
});
invalidateAfterMutation();
return mapPlannedCross(response.result.data[0]);
}
export async function fetchCrossRows(): Promise<CrossRecord[]> {
const snapshot = await loadCrossPedigreeSnapshot();
return snapshot.actualCrosses;
}
export async function createCrossRow(payload: CrossPayload): Promise<CrossRecord> {
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
method: "POST",
body: JSON.stringify([crossBody(payload)]),
});
invalidateAfterMutation();
return mapCross(response.result.data[0]);
}
export async function updateCrossRow(id: string, payload: CrossPayload): Promise<CrossRecord> {
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
method: "PUT",
body: JSON.stringify({ [id]: crossBody(payload) }),
});
invalidateAfterMutation();
return mapCross(response.result.data[0]);
}
export async function fetchCrossParentRows(): Promise<CrossParentRow[]> {
const snapshot = await loadCrossPedigreeSnapshot();
return snapshot.parentRows;
}
export async function updateCrossParents(payload: CrossParentFormState): Promise<void> {
const crossId = requiredText(payload.cross_id, "请选择所属 Cross");
const parent1 = buildCrossParent(
payload.parent1_type,
payload.parent1_germplasm_id,
payload.parent1_observation_unit_id,
);
const parent2 = buildCrossParent(
payload.parent2_type,
payload.parent2_germplasm_id,
payload.parent2_observation_unit_id,
);
const body = {
crossingProjectDbId: payload.crossing_project_id ?? undefined,
...(parent1 ? { parent1 } : {}),
...(parent2 ? { parent2 } : {}),
};
if (payload.planned) {
await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
method: "PUT",
body: JSON.stringify({ [crossId]: body }),
});
invalidateAfterMutation();
return;
}
await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
method: "PUT",
body: JSON.stringify({ [crossId]: body }),
});
invalidateAfterMutation();
}
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=1000",
);
return response.result.data.map(mapPedigree);
}
export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=1000&includeParents=true&includeProgeny=false&includeSiblings=true",
);
return response.result.data.map(mapPedigree);
}
export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> {
const rows = await fetchPedigreeRows();
const found = rows.find((row) => row.id === id || row.germplasm_id === id);
if (!found) throw new Error("系谱节点不存在");
return found;
}
export async function createPedigreeRow(payload: PedigreePayload): Promise<PedigreeRecord> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>("/brapi/v2/pedigree", {
method: "POST",
body: JSON.stringify([pedigreeBody(payload, true)]),
});
return mapPedigree(response.result.data[0]);
}
export async function updatePedigreeRow(id: string, payload: PedigreePayload): Promise<PedigreeRecord> {
const germplasmDbId = optionalText(payload.germplasm_id) || id;
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>("/brapi/v2/pedigree", {
method: "PUT",
body: JSON.stringify({
[germplasmDbId]: {
...pedigreeBody({ ...payload, germplasm_id: germplasmDbId }),
germplasmDbId,
},
}),
});
return mapPedigree(response.result.data[0]);
}
export async function fetchPedigreeEdgeRows(): Promise<PedigreeEdgeRow[]> {
const nodes = await fetchPedigreeRowsWithRelations();
return flattenPedigreeEdges(nodes);
}
export function buildPedigreeEdgeFormState(row?: PedigreeEdgeRow): PedigreeEdgeFormState {
return {
edge_type: row?.edge_type ?? "parent",
parent_type: row?.parent_type && row.parent_type !== NONE_SELECT_VALUE ? row.parent_type : NONE_SELECT_VALUE,
this_node_id: row?.this_node_id ?? NONE_SELECT_VALUE,
connected_node_id: row?.connected_node_id ?? NONE_SELECT_VALUE,
};
}
export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, originalId?: string): Promise<void> {
const edgeType = requiredText(payload.edge_type, "请选择关系类型");
const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料");
const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料");
if (thisNodeId === connectedNodeId) {
throw new Error("当前材料与关联材料不能相同");
}
if (edgeType === "sibling") {
throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护");
}
const nodes = await fetchPedigreeRowsWithRelations();
const childGermplasmId = edgeType === "parent" ? thisNodeId : connectedNodeId;
const parentGermplasmId = edgeType === "parent" ? connectedNodeId : thisNodeId;
const parentType = requiredText(payload.parent_type, "parent/child 关系请选择 parent_type");
const childNode = nodes.find((node) => node.germplasm_id === childGermplasmId);
const existingParents = childNode?.parents ?? [];
let nextParents = existingParents.map((parent) => ({
germplasmDbId: parent.germplasmDbId,
parentType: parent.parentType,
}));
if (originalId) {
const [, , oldConnectedNodeId] = originalId.split(":");
if (oldConnectedNodeId) {
nextParents = nextParents.filter((parent) => parent.germplasmDbId !== oldConnectedNodeId);
}
}
nextParents = nextParents.filter((parent) => parent.germplasmDbId !== parentGermplasmId);
nextParents.push({ germplasmDbId: parentGermplasmId, parentType });
await updatePedigreeParents(childGermplasmId, nextParents);
}
export async function removePedigreeEdge(edgeId: string): Promise<void> {
const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":");
if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) {
throw new Error("仅支持删除 parent 关系");
}
const nodes = await fetchPedigreeRowsWithRelations();
const childNode = nodes.find((node) => node.germplasm_id === thisNodeId);
const nextParents = (childNode?.parents ?? [])
.filter((parent) => parent.germplasmDbId !== connectedNodeId)
.map((parent) => ({
germplasmDbId: parent.germplasmDbId,
parentType: parent.parentType,
}));
await updatePedigreeParents(thisNodeId, nextParents);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { GitFork } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CROSS_TYPE_OPTIONS,
PLANNED_CROSS_STATUS_OPTIONS,
crossTypeLabel,
plannedStatusLabel,
} from "../constants";
import {
createCrossRow,
createPlannedCrossRow,
normalizeCrossForm,
normalizePlannedCrossForm,
updateCrossRow,
updatePlannedCrossRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
export function CrossEntityTab() {
const { snapshot, refresh } = useCrossPedigree();
const [subTab, setSubTab] = useState("planned");
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
const plannedCrossOptions = snapshot?.plannedCrossOptions ?? [];
const loadPlannedRows = useCallback(async () => {
const data = await refresh(false);
return data.plannedCrosses as unknown as Record<string, unknown>[];
}, [refresh]);
const loadActualRows = useCallback(async () => {
const data = await refresh(false);
return data.actualCrosses as unknown as Record<string, unknown>[];
}, [refresh]);
const fetchPlannedRecord = useCallback(async (id: string) => {
const data = await refresh(false);
const row = data.plannedCrosses.find((item) => item.id === id);
if (!row) throw new Error("计划杂交不存在");
return normalizePlannedCrossForm(row);
}, [refresh]);
const fetchActualRecord = useCallback(async (id: string) => {
const data = await refresh(false);
const row = data.actualCrosses.find((item) => item.id === id);
if (!row) throw new Error("实际杂交不存在");
return normalizeCrossForm(row);
}, [refresh]);
const plannedFields = useMemo<BrapiFormField[]>(() => [
{
key: "name",
label: "计划杂交名称",
type: "text",
required: true,
placeholder: "如 B73 x Mo17 / Cross-2026-001",
},
{
key: "crossing_project_id",
label: "杂交项目",
type: "select",
required: true,
options: [{ value: NONE_SELECT_VALUE, label: "请选择杂交项目" }, ...crossingProjectOptions],
},
{
key: "cross_type",
label: "杂交类型",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不指定类型" }, ...CROSS_TYPE_OPTIONS],
},
{
key: "status",
label: "状态",
type: "select",
options: PLANNED_CROSS_STATUS_OPTIONS,
},
], [crossingProjectOptions]);
const actualFields = useMemo<BrapiFormField[]>(() => [
{
key: "name",
label: "实际杂交名称",
type: "text",
required: true,
placeholder: "如 B73 x Mo17 实际杂交",
},
{
key: "crossing_project_id",
label: "杂交项目",
type: "select",
required: true,
options: [{ value: NONE_SELECT_VALUE, label: "请选择杂交项目" }, ...crossingProjectOptions],
},
{
key: "cross_type",
label: "杂交类型",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不指定类型" }, ...CROSS_TYPE_OPTIONS],
},
{
key: "planned_cross_id",
label: "来源计划杂交",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联计划杂交" }, ...plannedCrossOptions],
},
], [crossingProjectOptions, plannedCrossOptions]);
return (
<Tabs value={subTab} onValueChange={setSubTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="planned"> (planned=true)</TabsTrigger>
<TabsTrigger value="actual"> (planned=false)</TabsTrigger>
</TabsList>
{subTab === "planned" ? (
<TabsContent value="planned" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
icon={GitFork}
iconBg="bg-gradient-to-br from-emerald-500 to-green-600"
title="计划杂交"
description="cross_entity(planned=true)录入杂交计划亲本请在「杂交亲本」Tab 维护"
addLabel="新增计划杂交"
useEnhancedDialog
columns={[
{ key: "plannedCrossDbId", label: "Cross ID" },
{ key: "name", label: "名称" },
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "cross_type", label: "类型", render: crossTypeLabel },
{ key: "status", label: "状态", render: plannedStatusLabel },
]}
fields={plannedFields}
data={[]}
stats={[
{
label: "/brapi/v2/plannedcrosses",
value: "BrAPI",
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200",
},
]}
loadData={loadPlannedRows}
fetchRecord={fetchPlannedRecord}
createRecord={(payload) => createPlannedCrossRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updatePlannedCrossRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
</TabsContent>
) : null}
{subTab === "actual" ? (
<TabsContent value="actual" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
icon={GitFork}
iconBg="bg-gradient-to-br from-green-600 to-emerald-700"
title="实际杂交"
description="cross_entity(planned=false)完成实际杂交后可关联来源计划杂交亲本请在「杂交亲本」Tab 维护"
addLabel="新增实际杂交"
useEnhancedDialog
columns={[
{ key: "crossDbId", label: "Cross ID" },
{ key: "name", label: "名称" },
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "plannedCrossName", label: "来源计划杂交" },
{ key: "cross_type", label: "类型", render: crossTypeLabel },
]}
fields={actualFields}
data={[]}
stats={[
{
label: "/brapi/v2/crosses",
value: "BrAPI",
className: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200",
},
]}
loadData={loadActualRows}
fetchRecord={fetchActualRecord}
createRecord={(payload) => createCrossRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateCrossRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
</TabsContent>
) : null}
</Tabs>
);
}

View File

@@ -0,0 +1,366 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pencil, Users } from "lucide-react";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { PARENT_TYPE_OPTIONS, parentTypeLabel } from "../constants";
import {
buildCrossParentFormState,
updateCrossParents,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE, type CrossParentFormState, type CrossParentRow, type SelectOption } from "../types";
function ParentSlotEditor({
title,
slot,
parentType,
germplasmId,
observationUnitId,
germplasmOptions,
observationUnitOptions,
onChange,
}: {
title: string;
slot: "parent1" | "parent2";
parentType: string;
germplasmId: string;
observationUnitId: string;
germplasmOptions: SelectOption[];
observationUnitOptions: SelectOption[];
onChange: (patch: Partial<CrossParentFormState>) => void;
}) {
const prefix = slot;
return (
<div className="rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<h4 className="mb-3 text-sm font-medium text-slate-800 dark:text-slate-100">{title}</h4>
<div className="grid gap-3 md:grid-cols-3">
<div>
<Label className="mb-1.5 block text-xs">parent_type</Label>
<Select
value={parentType || NONE_SELECT_VALUE}
onValueChange={(value) => onChange({ [`${prefix}_type`]: value } as Partial<CrossParentFormState>)}
>
<SelectTrigger><SelectValue placeholder="选择亲本角色" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{PARENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs">germplasm_id</Label>
<Select
value={germplasmId || NONE_SELECT_VALUE}
onValueChange={(value) => onChange({ [`${prefix}_germplasm_id`]: value } as Partial<CrossParentFormState>)}
>
<SelectTrigger><SelectValue placeholder="选择种质" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{germplasmOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs">observation_unit_id</Label>
<Select
value={observationUnitId || NONE_SELECT_VALUE}
onValueChange={(value) => onChange({ [`${prefix}_observation_unit_id`]: value } as Partial<CrossParentFormState>)}
>
<SelectTrigger><SelectValue placeholder="选择观测单元" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{observationUnitOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
export function CrossParentTab() {
const { snapshot, loading: pageLoading, refresh } = useCrossPedigree();
const [rows, setRows] = useState<CrossParentRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<CrossParentFormState | null>(null);
const germplasmOptions = snapshot?.germplasm ?? [];
const observationUnitOptions = snapshot?.observationUnits ?? [];
const crossOptions = snapshot?.crossOptions ?? [];
const syncRowsFromSnapshot = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await refresh(false);
setRows(data.parentRows);
} catch (event) {
setError(event instanceof Error ? event.message : "加载失败");
} finally {
setLoading(false);
}
}, [refresh]);
useEffect(() => {
syncRowsFromSnapshot();
}, [syncRowsFromSnapshot]);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
[row.cross_name, row.germplasm_name, row.observation_unit_name, row.crossing_project_name, row.parent_type]
.some((value) => String(value ?? "").toLowerCase().includes(keyword)),
);
}, [rows, search]);
const openEditorForCross = useCallback((crossId: string) => {
if (!snapshot) throw new Error("数据尚未加载");
const plannedCross = snapshot.plannedCrosses.find((item) => item.id === crossId);
if (plannedCross) {
setForm(buildCrossParentFormState(
plannedCross.id,
true,
plannedCross.crossing_project_id,
plannedCross.crossing_project_name,
plannedCross.parent1,
plannedCross.parent2,
));
setDialogOpen(true);
return;
}
const actualCross = snapshot.actualCrosses.find((item) => item.id === crossId);
if (!actualCross) throw new Error("Cross 不存在");
setForm(buildCrossParentFormState(
actualCross.id,
false,
actualCross.crossing_project_id,
actualCross.crossing_project_name,
actualCross.parent1,
actualCross.parent2,
));
setDialogOpen(true);
}, [snapshot]);
const openCreate = () => {
if (crossOptions.length === 0) {
setError("请先在「Cross 杂交」Tab 创建计划或实际杂交");
return;
}
setForm({
cross_id: NONE_SELECT_VALUE,
planned: false,
crossing_project_id: null,
crossing_project_name: null,
parent1_type: NONE_SELECT_VALUE,
parent1_germplasm_id: NONE_SELECT_VALUE,
parent1_observation_unit_id: NONE_SELECT_VALUE,
parent2_type: NONE_SELECT_VALUE,
parent2_germplasm_id: NONE_SELECT_VALUE,
parent2_observation_unit_id: NONE_SELECT_VALUE,
});
setDialogOpen(true);
};
const handleCrossChange = (crossId: string) => {
if (crossId === NONE_SELECT_VALUE || !form) return;
try {
openEditorForCross(crossId);
} catch (event) {
setError(event instanceof Error ? event.message : "加载 Cross 失败");
}
};
const handleSave = async () => {
if (!form) return;
setSaving(true);
setError(null);
try {
await updateCrossParents(form);
setDialogOpen(false);
await syncRowsFromSnapshot();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
setSaving(false);
}
};
const crossIds = useMemo(() => Array.from(new Set(rows.map((row) => row.cross_id))), [rows]);
const showLoading = loading || pageLoading;
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className={cn("rounded-xl p-2.5", "bg-gradient-to-br from-violet-500 to-purple-600")}>
<Users className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">Cross Parent </h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
cross_parent Cross parent1/parent2 parent_type + germplasm / observation_unit
</p>
</div>
</div>
<Button onClick={openCreate} className="shrink-0"></Button>
</div>
<div className="mb-1">
<span className="rounded-full bg-violet-50 px-3 py-1 text-xs text-violet-700 dark:bg-violet-400/10 dark:text-violet-200">
BrAPI /brapi/v2/crosses · /brapi/v2/plannedcrosses (PUT parent1/parent2)
</span>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索 Cross / 种质 / 观测单元 / 项目"
className="max-w-sm"
/>
</div>
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
{showLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead>Cross</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>parent_type</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-sm text-slate-400">
Cross Tab parent1/parent2
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.cross_name || row.cross_id}</TableCell>
<TableCell>{row.planned ? "计划杂交" : "实际杂交"}</TableCell>
<TableCell>{row.crossing_project_name || "—"}</TableCell>
<TableCell>{row.parent_slot === "parent1" ? "Parent 1" : "Parent 2"}</TableCell>
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
<TableCell>{row.germplasm_name || row.germplasm_id || "—"}</TableCell>
<TableCell>{row.observation_unit_name || row.observation_unit_id || "—"}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEditorForCross(row.cross_id)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
{!showLoading && crossIds.length > 0 ? (
<p className="mt-3 text-xs text-slate-400">
{crossIds.length} Cross BrAPI Cross parent1 / parent2
</p>
) : null}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-3xl" title="维护杂交亲本 (cross_parent)">
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm"> Cross</Label>
<Select
value={form?.cross_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, cross_id: value });
handleCrossChange(value);
}}
>
<SelectTrigger><SelectValue placeholder="选择 Cross" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}> Cross</SelectItem>
{crossOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm">crossing_project_id</Label>
<Input readOnly value={form?.crossing_project_name || form?.crossing_project_id || "—"} />
</div>
{form ? (
<>
<ParentSlotEditor
title="Parent 1"
slot="parent1"
parentType={form.parent1_type}
germplasmId={form.parent1_germplasm_id}
observationUnitId={form.parent1_observation_unit_id}
germplasmOptions={germplasmOptions}
observationUnitOptions={observationUnitOptions}
onChange={(patch) => setForm({ ...form, ...patch })}
/>
<ParentSlotEditor
title="Parent 2"
slot="parent2"
parentType={form.parent2_type}
germplasmId={form.parent2_germplasm_id}
observationUnitId={form.parent2_observation_unit_id}
germplasmOptions={germplasmOptions}
observationUnitOptions={observationUnitOptions}
onChange={(patch) => setForm({ ...form, ...patch })}
/>
</>
) : null}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving || !form || form.cross_id === NONE_SELECT_VALUE}>
{saving ? "保存中..." : "保存亲本"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo } from "react";
import { Network } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createCrossingProjectRow,
fetchCrossingProjectDetail,
normalizeCrossingProjectForm,
updateCrossingProjectRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
export function CrossingProjectTab() {
const { snapshot, refresh } = useCrossPedigree();
const programOptions = snapshot?.programs ?? [];
const loadRows = useCallback(async () => {
const data = await refresh(false);
return data.crossingProjects as unknown as Record<string, unknown>[];
}, [refresh]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchCrossingProjectDetail(id);
return normalizeCrossingProjectForm(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "name",
label: "杂交项目名称",
type: "text",
required: true,
placeholder: "如 2026 抗倒伏杂交项目",
},
{
key: "program_id",
label: "所属 Program",
type: "select",
required: true,
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Program" }, ...programOptions],
},
{
key: "description",
label: "项目说明",
type: "textarea",
placeholder: "杂交项目目标、范围、批次安排等",
colSpan: 2,
},
], [programOptions]);
return (
<BrapiEntityPage
icon={Network}
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
title="CrossingProject 杂交项目"
description="crossing_project某 Program 下的一组杂交任务集合杂交工作台。ID 由系统自动生成。"
addLabel="新增杂交项目"
useEnhancedDialog
fetchRecord={fetchRecord}
columns={[
{ key: "crossingProjectDbId", label: "项目 ID" },
{ key: "name", label: "项目名称" },
{ key: "program_name", label: "Program" },
{
key: "description",
label: "说明",
render: (value) => {
const text = String(value ?? "").trim();
if (!text) return "—";
return text.length > 48 ? `${text.slice(0, 48)}` : text;
},
},
]}
fields={fields}
data={[]}
stats={[
{
label: "/brapi/v2/crossingprojects",
value: "BrAPI",
className: "bg-lime-50 text-lime-700 dark:bg-lime-400/10 dark:text-lime-200",
},
]}
loadData={loadRows}
createRecord={(payload) => createCrossingProjectRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateCrossingProjectRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -0,0 +1,369 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { GitBranch, Pencil, Plus, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { EDGE_TYPE_OPTIONS, PARENT_TYPE_OPTIONS, edgeTypeLabel, parentTypeLabel } from "../constants";
import {
buildPedigreeEdgeFormState,
fetchPedigreeEdgeRows,
fetchPedigreeRows,
removePedigreeEdge,
upsertPedigreeEdge,
} from "../api";
import { NONE_SELECT_VALUE, type PedigreeEdgeFormState, type PedigreeEdgeRow, type SelectOption } from "../types";
export function PedigreeEdgeTab() {
const [rows, setRows] = useState<PedigreeEdgeRow[]>([]);
const [nodeOptions, setNodeOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<PedigreeEdgeFormState | null>(null);
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
const [deletingEdge, setDeletingEdge] = useState<PedigreeEdgeRow | null>(null);
const [deleting, setDeleting] = useState(false);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [edgeRows, nodes] = await Promise.all([fetchPedigreeEdgeRows(), fetchPedigreeRows()]);
setRows(edgeRows);
setNodeOptions(
nodes
.filter((node) => node.germplasm_id)
.map((node) => ({
value: node.germplasm_id as string,
label: node.germplasm_name || node.germplasm_id || "",
})),
);
} catch (event) {
setError(event instanceof Error ? event.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadRows();
}, [loadRows]);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
[
row.this_node_name,
row.connected_node_name,
row.edge_type,
row.parent_type,
row.this_node_id,
row.connected_node_id,
].some((value) => String(value ?? "").toLowerCase().includes(keyword)),
);
}, [rows, search]);
const openCreate = () => {
if (nodeOptions.length < 2) {
setError("请先在「Pedigree Node 系谱节点」Tab 创建至少两个节点");
return;
}
setEditingEdgeId(null);
setForm(buildPedigreeEdgeFormState());
setDialogOpen(true);
};
const openEdit = (row: PedigreeEdgeRow) => {
if (row.read_only || row.edge_type === "sibling") {
setError("同胞关系为只读展示,请通过 parent 关系维护");
return;
}
setEditingEdgeId(row.id);
setForm(buildPedigreeEdgeFormState(row));
setDialogOpen(true);
};
const handleSave = async () => {
if (!form) return;
setSaving(true);
setError(null);
try {
await upsertPedigreeEdge(form, editingEdgeId ?? undefined);
setDialogOpen(false);
await loadRows();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deletingEdge) return;
setDeleting(true);
setError(null);
try {
await removePedigreeEdge(deletingEdge.id);
setDeletingEdge(null);
await loadRows();
} catch (event) {
setError(event instanceof Error ? event.message : "删除失败");
} finally {
setDeleting(false);
}
};
const showParentType = form?.edge_type === "parent" || form?.edge_type === "child";
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className={cn("rounded-xl p-2.5", "bg-gradient-to-br from-cyan-500 to-teal-600")}>
<GitBranch className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">Pedigree Edge </h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
pedigree_edge parent / child sibling BrAPI
</p>
</div>
</div>
<Button onClick={openCreate} className="shrink-0 gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="mb-1">
<span className="rounded-full bg-cyan-50 px-3 py-1 text-xs text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-200">
BrAPI PUT /brapi/v2/pedigreeparents
</span>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索当前材料 / 关联材料 / 关系类型"
className="max-w-sm"
/>
</div>
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
{loading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>parent_type</TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-sm text-slate-400">
parent / child
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>
{edgeTypeLabel(row.edge_type)}
{row.read_only ? (
<span className="ml-2 text-xs text-slate-400"></span>
) : null}
</TableCell>
<TableCell>{row.this_node_name || row.this_node_id}</TableCell>
<TableCell>{row.connected_node_name || row.connected_node_id}</TableCell>
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
<TableCell className="text-right">
{!row.read_only ? (
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
className="gap-1 text-red-600 hover:text-red-600"
onClick={() => setDeletingEdge(row)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<span className="text-xs text-slate-400"></span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl" title={editingEdgeId ? "编辑系谱边" : "新增系谱边"}>
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm">edge_type </Label>
<Select
value={form?.edge_type || "parent"}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, edge_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关系类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{EDGE_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.this_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, this_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择当前材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.connected_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, connected_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关联材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showParentType ? (
<div>
<Label className="mb-1.5 block text-sm">parent_type </Label>
<Select
value={form?.parent_type || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, parent_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择亲本类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
<SelectItem value={NONE_SELECT_VALUE}> parent_type</SelectItem>
{PARENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<p className="text-xs text-slate-500 dark:text-slate-400">
parentchild
</p>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button
onClick={handleSave}
disabled={
saving
|| !form
|| form.this_node_id === NONE_SELECT_VALUE
|| form.connected_node_id === NONE_SELECT_VALUE
|| (showParentType && form.parent_type === NONE_SELECT_VALUE)
}
>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deletingEdge)} onOpenChange={(open) => !open && setDeletingEdge(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{" "}
{deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""}
{" "}
{deletingEdge?.this_node_name || deletingEdge?.this_node_id}
{" → "}
{deletingEdge?.connected_node_name || deletingEdge?.connected_node_id}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { useCallback, useMemo } from "react";
import { Share2 } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createPedigreeRow,
fetchPedigreeDetail,
fetchPedigreeRows,
normalizePedigreeForm,
updatePedigreeRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
export function PedigreeNodeTab() {
const { snapshot } = useCrossPedigree();
const germplasmOptions = snapshot?.germplasm ?? [];
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
const loadRows = useCallback(async () => {
const rows = await fetchPedigreeRows();
const projectNameById = new Map(
(snapshot?.crossingProjects ?? []).map((project) => [project.id, project.name || project.id]),
);
return rows.map((row) => ({
...row,
crossing_project_name:
row.crossing_project_name || (row.crossing_project_id ? projectNameById.get(row.crossing_project_id) : null),
})) as unknown as Record<string, unknown>[];
}, [snapshot?.crossingProjects]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchPedigreeDetail(id);
return normalizePedigreeForm(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "germplasm_id",
label: "Germplasm 材料",
type: "select",
required: true,
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Germplasm" }, ...germplasmOptions],
},
{
key: "crossing_project_id",
label: "CrossingProject 杂交项目",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不指定杂交项目" }, ...crossingProjectOptions],
},
{
key: "crossing_year",
label: "crossing_year 杂交年份",
type: "year",
placeholder: "四位年份",
},
{
key: "family_code",
label: "family_code 家系编号",
type: "text",
placeholder: "同一 crossing_project 下建议唯一",
},
{
key: "pedigree_string",
label: "pedigree_string 系谱字符串",
type: "text",
placeholder: "如 A/B//C支持 Purdy notation",
colSpan: 2,
},
], [crossingProjectOptions, germplasmOptions]);
return (
<BrapiEntityPage
icon={Share2}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title="Pedigree Node 系谱节点"
description="pedigree_node系谱树中的节点通常对应一个 germplasm。BrAPI 以 germplasmDbId 作为更新主键。"
addLabel="新增系谱节点"
useEnhancedDialog
fetchRecord={fetchRecord}
columns={[
{ key: "germplasm_name", label: "材料" },
{ key: "germplasm_id", label: "Germplasm ID" },
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "crossing_year", label: "杂交年份" },
{ key: "family_code", label: "家系编号" },
{
key: "pedigree_string",
label: "系谱字符串",
render: (value) => {
const text = String(value ?? "").trim();
if (!text) return "—";
return text.length > 40 ? `${text.slice(0, 40)}` : text;
},
},
]}
fields={fields}
data={[]}
stats={[
{
label: "/brapi/v2/pedigree",
value: "BrAPI",
className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200",
},
]}
loadData={loadRows}
createRecord={(payload) => createPedigreeRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updatePedigreeRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -0,0 +1,47 @@
import type { SelectOption } from "./types";
export const CROSS_TYPE_OPTIONS: SelectOption[] = [
{ value: "BIPARENTAL", label: "BIPARENTAL 双亲杂交" },
{ value: "SELF", label: "SELF 自交" },
{ value: "OPEN_POLLINATED", label: "OPEN_POLLINATED 开放授粉" },
{ value: "BULK", label: "BULK bulk" },
{ value: "BULK_SELFED", label: "BULK_SELFED" },
{ value: "BULK_OPEN_POLLINATED", label: "BULK_OPEN_POLLINATED" },
{ value: "DOUBLE_HAPLOID", label: "DOUBLE_HAPLOID 双单倍体" },
];
export const PLANNED_CROSS_STATUS_OPTIONS: SelectOption[] = [
{ value: "TODO", label: "TODO 待执行" },
{ value: "DONE", label: "DONE 已完成" },
{ value: "SKIPPED", label: "SKIPPED 已跳过" },
];
export const PARENT_TYPE_OPTIONS: SelectOption[] = [
{ value: "FEMALE", label: "FEMALE 母本" },
{ value: "MALE", label: "MALE 父本" },
{ value: "SELF", label: "SELF 自交" },
{ value: "POPULATION", label: "POPULATION 群体" },
{ value: "CLONAL", label: "CLONAL 克隆" },
];
export const crossTypeLabel = (value: unknown) =>
CROSS_TYPE_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
export const plannedStatusLabel = (value: unknown) =>
PLANNED_CROSS_STATUS_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
export const parentTypeLabel = (value: unknown) =>
PARENT_TYPE_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
export const EDGE_TYPE_OPTIONS: SelectOption[] = [
{ value: "parent", label: "parent 亲本" },
{ value: "child", label: "child 后代" },
];
export const edgeTypeLabel = (value: unknown) => {
const text = String(value ?? "");
if (text === "parent") return "parent 亲本";
if (text === "child") return "child 后代";
if (text === "sibling") return "sibling 同胞";
return text || "—";
};

View File

@@ -0,0 +1,182 @@
import type { Cross, CrossParent, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
import {
loadGermplasmOptions,
loadObservationUnitOptions,
loadProgramOptions,
} from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import {
mapCross,
mapCrossingProject,
mapPlannedCross,
} from "./mappers";
import type {
CrossParentRow,
CrossRecord,
CrossingProjectRecord,
PlannedCrossRecord,
SelectOption,
} from "./types";
interface BrapiListResponse<T> {
result: { data: T[] };
}
export interface CrossPedigreeSnapshot {
programs: SelectOption[];
germplasm: SelectOption[];
observationUnits: SelectOption[];
crossingProjectOptions: SelectOption[];
plannedCrossOptions: SelectOption[];
crossOptions: SelectOption[];
crossingProjects: CrossingProjectRecord[];
plannedCrosses: PlannedCrossRecord[];
actualCrosses: CrossRecord[];
parentRows: CrossParentRow[];
}
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`);
}
return response.json() as Promise<T>;
}
const flattenCrossParents = (
crossId: string,
crossName: string | null,
planned: boolean,
crossingProjectId: string | null,
crossingProjectName: string | null,
parent: CrossParent | null | undefined,
slot: "parent1" | "parent2",
): CrossParentRow | null => {
if (!parent) return null;
const hasData = parent.parentType || parent.germplasmDbId || parent.observationUnitDbId;
if (!hasData) return null;
return {
id: `${crossId}:${slot}`,
cross_id: crossId,
cross_name: crossName,
planned,
parent_slot: slot,
parent_type: parent.parentType ?? null,
germplasm_id: parent.germplasmDbId ?? null,
germplasm_name: parent.germplasmName ?? null,
observation_unit_id: parent.observationUnitDbId ?? null,
observation_unit_name: parent.observationUnitName ?? null,
crossing_project_id: crossingProjectId,
crossing_project_name: crossingProjectName,
};
};
function buildParentRows(planned: PlannedCrossRecord[], actual: CrossRecord[]): CrossParentRow[] {
const rows: CrossParentRow[] = [];
for (const cross of planned) {
const p1 = flattenCrossParents(
cross.id, cross.name, true, cross.crossing_project_id, cross.crossing_project_name, cross.parent1, "parent1",
);
const p2 = flattenCrossParents(
cross.id, cross.name, true, cross.crossing_project_id, cross.crossing_project_name, cross.parent2, "parent2",
);
if (p1) rows.push(p1);
if (p2) rows.push(p2);
}
for (const cross of actual) {
const p1 = flattenCrossParents(
cross.id, cross.name, false, cross.crossing_project_id, cross.crossing_project_name, cross.parent1, "parent1",
);
const p2 = flattenCrossParents(
cross.id, cross.name, false, cross.crossing_project_id, cross.crossing_project_name, cross.parent2, "parent2",
);
if (p1) rows.push(p1);
if (p2) rows.push(p2);
}
return rows;
}
async function fetchSnapshotFromNetwork(): Promise<CrossPedigreeSnapshot> {
const [programs, germplasm, crossingProjects, plannedCrosses, actualCrosses, observationUnits] = await Promise.all([
loadProgramOptions(),
loadGermplasmOptions(),
request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects?page=0&pageSize=1000"),
request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses?page=0&pageSize=1000"),
request<BrapiListResponse<Cross>>("/brapi/v2/crosses?page=0&pageSize=1000"),
loadObservationUnitOptions().catch(() => [] as SelectOption[]),
]);
const crossingProjectRows = (crossingProjects.result?.data ?? []).map(mapCrossingProject);
const plannedRows = (plannedCrosses.result?.data ?? []).map(mapPlannedCross);
const actualRows = (actualCrosses.result?.data ?? []).map(mapCross);
return {
programs,
germplasm,
observationUnits,
crossingProjectOptions: crossingProjectRows.map((project) => ({
value: project.id,
label: project.name || project.id,
})),
plannedCrossOptions: plannedRows.map((cross) => ({
value: cross.id,
label: cross.name || cross.id,
})),
crossOptions: [...plannedRows, ...actualRows].map((cross) => ({
value: cross.id,
label: `${cross.planned ? "[计划] " : "[实际] "}${cross.name || cross.id}`,
})),
crossingProjects: crossingProjectRows,
plannedCrosses: plannedRows,
actualCrosses: actualRows,
parentRows: buildParentRows(plannedRows, actualRows),
};
}
let cachedSnapshot: CrossPedigreeSnapshot | null = null;
let inflightSnapshot: Promise<CrossPedigreeSnapshot> | null = null;
export function invalidateCrossPedigreeCache() {
cachedSnapshot = null;
inflightSnapshot = null;
}
export async function loadCrossPedigreeSnapshot(force = false): Promise<CrossPedigreeSnapshot> {
if (!force && cachedSnapshot) return cachedSnapshot;
if (!force && inflightSnapshot) return inflightSnapshot;
inflightSnapshot = fetchSnapshotFromNetwork()
.then((snapshot) => {
cachedSnapshot = snapshot;
inflightSnapshot = null;
return snapshot;
})
.catch((error) => {
inflightSnapshot = null;
throw error;
});
return inflightSnapshot;
}
export function getCrossPedigreeSnapshot(): CrossPedigreeSnapshot | null {
return cachedSnapshot;
}

View File

@@ -0,0 +1,51 @@
import type { Cross, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
import type { CrossRecord, CrossingProjectRecord, PlannedCrossRecord } from "./types";
export const mapCrossingProject = (project: CrossingProject): CrossingProjectRecord => ({
id: project.crossingProjectDbId || "",
crossingProjectDbId: project.crossingProjectDbId || "",
crossingProjectName: project.crossingProjectName ?? null,
name: project.crossingProjectName ?? null,
crossingProjectDescription: project.crossingProjectDescription ?? null,
description: project.crossingProjectDescription ?? null,
programDbId: project.programDbId ?? null,
program_id: project.programDbId ?? null,
programName: project.programName ?? null,
program_name: project.programName ?? null,
});
export const mapPlannedCross = (cross: PlannedCross): PlannedCrossRecord => ({
id: cross.plannedCrossDbId || "",
plannedCrossDbId: cross.plannedCrossDbId || "",
plannedCrossName: cross.plannedCrossName ?? null,
name: cross.plannedCrossName ?? null,
crossType: cross.crossType ?? null,
cross_type: cross.crossType ?? null,
status: cross.status ?? null,
crossingProjectDbId: cross.crossingProjectDbId ?? null,
crossing_project_id: cross.crossingProjectDbId ?? null,
crossingProjectName: cross.crossingProjectName ?? null,
crossing_project_name: cross.crossingProjectName ?? null,
planned: true,
parent1: cross.parent1 ?? null,
parent2: cross.parent2 ?? null,
});
export const mapCross = (cross: Cross): CrossRecord => ({
id: cross.crossDbId || "",
crossDbId: cross.crossDbId || "",
crossName: cross.crossName ?? null,
name: cross.crossName ?? null,
crossType: cross.crossType ?? null,
cross_type: cross.crossType ?? null,
crossingProjectDbId: cross.crossingProjectDbId ?? null,
crossing_project_id: cross.crossingProjectDbId ?? null,
crossingProjectName: cross.crossingProjectName ?? null,
crossing_project_name: cross.crossingProjectName ?? null,
plannedCrossDbId: cross.plannedCrossDbId ?? null,
planned_cross_id: cross.plannedCrossDbId ?? null,
plannedCrossName: cross.plannedCrossName ?? null,
planned: false,
parent1: cross.parent1 ?? null,
parent2: cross.parent2 ?? null,
});

View File

@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { GitBranch, GitFork, Network, Share2, Users } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CrossPedigreeProvider } from "./CrossPedigreeContext";
import { CrossingProjectTab } from "./components/CrossingProjectTab";
import { CrossEntityTab } from "./components/CrossEntityTab";
import { CrossParentTab } from "./components/CrossParentTab";
import { PedigreeEdgeTab } from "./components/PedigreeEdgeTab";
import { PedigreeNodeTab } from "./components/PedigreeNodeTab";
function CrossPedigreePageContent() {
const [tab, setTab] = useState("projects");
return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="projects" className="gap-2">
<Network className="h-4 w-4" />
CrossingProject
</TabsTrigger>
<TabsTrigger value="crosses" className="gap-2">
<GitFork className="h-4 w-4" />
Cross
</TabsTrigger>
<TabsTrigger value="parents" className="gap-2">
<Users className="h-4 w-4" />
Cross Parent
</TabsTrigger>
<TabsTrigger value="pedigree-nodes" className="gap-2">
<Share2 className="h-4 w-4" />
Pedigree Node
</TabsTrigger>
<TabsTrigger value="pedigree-edges" className="gap-2">
<GitBranch className="h-4 w-4" />
Pedigree Edge
</TabsTrigger>
</TabsList>
{tab === "projects" ? (
<TabsContent value="projects" className="mt-0 min-h-0 flex-1">
<CrossingProjectTab />
</TabsContent>
) : null}
{tab === "crosses" ? (
<TabsContent value="crosses" className="mt-0 min-h-0 flex-1">
<CrossEntityTab />
</TabsContent>
) : null}
{tab === "parents" ? (
<TabsContent value="parents" className="mt-0 min-h-0 flex-1">
<CrossParentTab />
</TabsContent>
) : null}
{tab === "pedigree-nodes" ? (
<TabsContent value="pedigree-nodes" className="mt-0 min-h-0 flex-1">
<PedigreeNodeTab />
</TabsContent>
) : null}
{tab === "pedigree-edges" ? (
<TabsContent value="pedigree-edges" className="mt-0 min-h-0 flex-1">
<PedigreeEdgeTab />
</TabsContent>
) : null}
</Tabs>
);
}
export default function CrossPedigreePage() {
return (
<CrossPedigreeProvider>
<CrossPedigreePageContent />
</CrossPedigreeProvider>
);
}

View File

@@ -0,0 +1,137 @@
import type { CrossParent } from "@/lib/api/types.gen";
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface CrossingProjectRecord {
id: string;
crossingProjectDbId: string;
crossingProjectName: string | null;
name: string | null;
crossingProjectDescription: string | null;
description: string | null;
programDbId: string | null;
program_id: string | null;
programName: string | null;
program_name: string | null;
}
export interface PlannedCrossRecord {
id: string;
plannedCrossDbId: string;
plannedCrossName: string | null;
name: string | null;
crossType: string | null;
cross_type: string | null;
status: string | null;
crossingProjectDbId: string | null;
crossing_project_id: string | null;
crossingProjectName: string | null;
crossing_project_name: string | null;
planned: true;
parent1: CrossParent | null;
parent2: CrossParent | null;
}
export interface CrossRecord {
id: string;
crossDbId: string;
crossName: string | null;
name: string | null;
crossType: string | null;
cross_type: string | null;
crossingProjectDbId: string | null;
crossing_project_id: string | null;
crossingProjectName: string | null;
crossing_project_name: string | null;
plannedCrossDbId: string | null;
planned_cross_id: string | null;
plannedCrossName: string | null;
planned: false;
parent1: CrossParent | null;
parent2: CrossParent | null;
}
export interface CrossParentRow {
id: string;
cross_id: string;
cross_name: string | null;
planned: boolean;
parent_slot: "parent1" | "parent2";
parent_type: string | null;
germplasm_id: string | null;
germplasm_name: string | null;
observation_unit_id: string | null;
observation_unit_name: string | null;
crossing_project_id: string | null;
crossing_project_name: string | null;
}
export interface CrossParentFormState {
cross_id: string;
planned: boolean;
crossing_project_id: string | null;
crossing_project_name: string | null;
parent1_type: string;
parent1_germplasm_id: string;
parent1_observation_unit_id: string;
parent2_type: string;
parent2_germplasm_id: string;
parent2_observation_unit_id: string;
}
export interface PedigreeNodeParentRef {
germplasmDbId?: string;
germplasmName?: string;
parentType?: string;
}
export interface PedigreeNodeSiblingRef {
germplasmDbId?: string;
germplasmName?: string;
}
export interface PedigreeRecord {
id: string;
pedigreeNodeDbId?: string;
germplasmDbId: string | null;
germplasm_id: string | null;
germplasmName: string | null;
germplasm_name: string | null;
crossingProjectDbId: string | null;
crossing_project_id: string | null;
crossingProjectName: string | null;
crossing_project_name: string | null;
crossingYear: number | null;
crossing_year: number | null;
familyCode: string | null;
family_code: string | null;
pedigreeString: string | null;
pedigree_string: string | null;
parents?: PedigreeNodeParentRef[];
siblings?: PedigreeNodeSiblingRef[];
}
export type PedigreeEdgeType = "parent" | "child" | "sibling";
export interface PedigreeEdgeRow {
id: string;
edge_type: PedigreeEdgeType;
parent_type: string | null;
this_node_id: string;
this_node_name: string | null;
connected_node_id: string;
connected_node_name: string | null;
read_only?: boolean;
}
export interface PedigreeEdgeFormState {
edge_type: string;
parent_type: string;
this_node_id: string;
connected_node_id: string;
}