fix:开发完毕

This commit is contained in:
彭帅
2026-05-28 17:25:32 +08:00
parent 50879a71da
commit c5d4d7a7e1
63 changed files with 4960 additions and 851 deletions

View File

@@ -13,4 +13,16 @@
- 仅在该文档描述的功能全部落地时标注;部分实现(如仅后端、缺页面或缺校验)不要标注。
- 若文档末尾已有「状态:已完成」,不要重复追加。
- `docs/dev/**/README.md` 等索引/说明类文档无需标注。
- `docs/dev/**/README.md` 等索引/说明类文档无需标注。
7.`docs/dev` 目录按编号对应「章」的口语称呼,沟通时按此理解:
| 口语 | 目录 |
| --- | --- |
| 第一章 | `docs/dev/01-core` |
| 第二章 | `docs/dev/02-germplasm-seed` |
| 第三章 | `docs/dev/03-genotyping` |
| 第四章 | `docs/dev/04-germplasm` |
- 用户说「开发第一章 / 做第一章 xxx」即指 `01-core` 下对应文档与功能;第二、三、四章同理。
- `docs/dev/backend` 等其它子目录不适用「第 X 章」称呼,需按具体路径理解。
- **第二、四章重叠表**:若第二章已实现,第四章对应文档可标注「与第二章共用实现」,无需重复开发。

View File

@@ -40,3 +40,5 @@
---
**状态:已完成**(页面:`germplasm/seed-lot` → 库存交易 Tab`germplasm/seed-lot/[seedLotDbId]` → Transactions TabBrAPI `POST /seedlots/transactions`

View File

@@ -35,3 +35,7 @@
1. `name` 必填。
2. 已被 `germplasm` 引用时不允许物理删除,只允许停用或提示引用关系。
---
**状态:已完成**(与第二章 `02-germplasm-seed/01-breeding_method.md` 共用实现,页面:`germplasm/breeding-method`

View File

@@ -66,3 +66,5 @@
1. `germplasm_name` 建议必填,`accession_number``germplasmpui` 建议唯一。
2. 删除 germplasm 前必须检查属性值、seed lot 组成、cross parent、pedigree、sample/taxon 等引用。
3. 不要用 `seed_source` 表达库存;库存必须走 `seed_lot`
**状态:已完成**(页面:`germplasm/germplasm` 列表 CRUD + 查询;`germplasm/germplasm/[germplasmDbId]` 详情 TabAttributes / Donor / Institute / Origin / Synonym / Taxon / Pedigree / Seed Lots / Cross Parent

View File

@@ -37,3 +37,5 @@
1. `germplasm_id` 必须存在。
2. 同一 germplasm 下 donor accession + institute code 不建议重复。
3. 删除 donor 记录不应删除 germplasm 主数据。
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Donor TabBrAPI `PUT /germplasm/{id}` donors 整表替换)

View File

@@ -36,3 +36,5 @@
1. `germplasm_id` 必须存在。
2. 同一 germplasm 下同类型、同 code 的机构不建议重复。
3. 删除 institute 记录不应删除 germplasm 主数据。
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Institute Tab扩展接口 `/brapi/v2/germplasm/{id}/institutes` CRUD

View File

@@ -34,3 +34,5 @@
1. `germplasm_id` 必须存在。
2. 坐标格式需要合法。
3. 删除 origin 记录不应删除 germplasm 主数据。
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Origin TabBrAPI `PUT /germplasm/{id}` germplasmOrigin 整表替换)

View File

@@ -34,3 +34,5 @@
1. `germplasm_id` 必须存在。
2. 同一 germplasm 下同一个 synonym 不应重复。
3. 删除 synonym 不应删除 germplasm 主数据。
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Synonym TabBrAPI `PUT /germplasm/{id}` synonyms 整表替换)

View File

@@ -34,3 +34,5 @@
1. `germplasm_id` 必须存在。
2. 同一 source 下 taxon_id 不建议重复。
3. 删除 taxon 前检查 sample 引用。
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Taxon TabBrAPI `PUT /germplasm/{id}` taxonIds 整表替换)

View File

@@ -53,3 +53,7 @@
1. `name` 必填。
2. 已被 `germplasm_attribute_value` 引用时不允许物理删除。
3. `datatype` 要与 value 输入控件联动。
---
**状态:已完成**(与第二章 `02-germplasm-seed/03-germplasm_attribute_definition.md` 共用实现,页面:`germplasm` → Attributes Tab

View File

@@ -37,3 +37,7 @@
1. `germplasm_id``attribute_id` 必须存在。
2. 同一 germplasm 下同一 attribute 不建议重复,若允许多次测定,需要用 determined_date 区分。
3. `value` 必须符合 attribute definition 的 datatype。
---
**状态:已完成**(与第二章 `02-germplasm-seed/04-germplasm_attribute_value.md` 共用实现,页面:`germplasm` → Attribute Values Tab

View File

@@ -36,3 +36,7 @@
1. `program_id` 必须存在。
2. 删除 crossing_project 前检查 cross、cross_parent、pedigree_node 引用。
3. 如果 program 有 crop创建 cross 时亲本 germplasm 建议与 program crop 一致。
---
**状态:已完成**(页面:`germplasm/cross-pedigree` → CrossingProject Tab详情`cross-pedigree/projects/[crossingProjectDbId]`

View File

@@ -48,3 +48,7 @@
1. `plannedcross` 不新建表,所有 planned cross 走 `cross_entity`
2. `planned_cross_id` 不能指向自己。
3. 删除 cross 前检查 `cross_parent``cross_pollination_event``seed_lot_content_mixture` 引用。
---
**状态:已完成**(页面:`germplasm/cross-pedigree` → Cross Tab详情`cross-pedigree/crosses/[crossDbId]``planned-crosses/[plannedCrossDbId]`;创建实际杂交时继承计划杂交亲本)

View File

@@ -37,3 +37,7 @@
2. `germplasm_id``observation_unit_id` 至少填写一个,不建议同时为空。
3. 同一 cross 下相同 parentType + germplasm/observationUnit 不应重复。
4. 如果填 `crossing_project_id`,应与 `cross.crossing_project_id` 一致。
---
**状态:已完成**(页面:`germplasm/cross-pedigree` → Cross Parent TabCross/PlannedCross 详情页 Parents Tab

View File

@@ -35,3 +35,7 @@
1. `cross_id` 必须存在。
2. 同一 cross 下 `pollination_number` 不建议重复。
3. 删除授粉事件不应删除 cross 主数据。
---
**状态:已完成**(页面:实际 Cross 详情 → Pollination Events TabBrAPI `PUT /crosses` pollinationEvents

View File

@@ -38,3 +38,7 @@
1. 同一 germplasm 通常只应有一个 pedigree node。
2. 删除 pedigree_node 前检查 `pedigree_edge` 中 this_node 和 connceted_node 引用。
3. 导入 pedigree 时需要先创建所有节点,再创建边。
---
**状态:已完成**

View File

@@ -41,3 +41,7 @@
1. `this_node_id``connceted_node_id` 必须存在。
2. 两个节点不能相同。
3. 同一节点之间同一种 edge_type 不应重复。
---
**状态:已完成**

View File

@@ -44,3 +44,5 @@
1. `amount` 不允许为负。
2. 普通用户不应直接编辑 amount库存变化应通过 `seed_lot_transaction`
3. 删除 seed lot 前检查组成明细和交易引用。
**状态:已完成**(页面:`germplasm/seed-lot` 列表 CRUD + 查询;`germplasm/seed-lot/[seedLotDbId]` 详情摘要;库存 amount 编辑时只读,通过交易更新)

View File

@@ -36,3 +36,5 @@
2. `germplasm_id``cross_id` 至少填写一个。
3. 同一 seed lot 下 mixture_percentage 合计建议为 100。
4. 删除组成明细不应删除 seed lot、germplasm 或 cross 主数据。
**状态:已完成**(页面:`germplasm/seed-lot/[seedLotDbId]` → Content Mixture Tab占比合计进度条 + 100% 校验BrAPI `PUT /seedlots/{id}` contentMixture

View File

@@ -41,3 +41,7 @@
2. `from_seed_lot_id` 不能等于 `to_seed_lot_id`
3. 出库或转移时,来源批次数量不能被扣成负数。
4. transaction 是业务动作痕迹,原则上不允许随意物理删除。
---
**状态:已完成**(与第二章 `02-germplasm-seed/12-seed_lot_transaction.md` 共用实现,页面:`germplasm/seed-lot` → 库存交易 Tab`germplasm/seed-lot/[seedLotDbId]` → Transactions Tab

View File

@@ -46,7 +46,7 @@ export function GenomeMapTab() {
}, [appliedQuery]);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Map ID", type: "text", placeholder: "留空则系统自动生成" },
{ key: "id", label: "Map ID", type: "text", readOnly: true, placeholder: "保存后由系统自动生成" },
{ key: "map_name", label: "图谱名称", type: "text", required: true, placeholder: "如 Maize IBM2" },
{
key: "map_pui",

View File

@@ -9,7 +9,10 @@ import {
NONE_SELECT_VALUE,
type CrossParentFormState,
type CrossParentRow,
type CrossPollinationEventRecord,
type CrossRecord,
type CrossingProjectDetail,
type CrossingProjectQuery,
type CrossingProjectRecord,
type PedigreeEdgeFormState,
type PedigreeEdgeRow,
@@ -105,7 +108,7 @@ const buildCrossParent = (
const observationUnitDbId = optionalText(observationUnitId);
if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null;
if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type");
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm <EFBFBD>?observation_unit 至少一");
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm observation_unit 至少一");
return {
parentType: parentTypeValue as CrossParent["parentType"],
...(germplasmDbId ? { germplasmDbId } : {}),
@@ -181,7 +184,7 @@ export function buildCrossParentFormState(
}
const crossingProjectBody = (payload: CrossingProjectPayload) => {
const programDbId = requiredText(payload.program_id, "请选择所<EFBFBD>?Program");
const programDbId = requiredText(payload.program_id, "请选择所Program");
return {
crossingProjectName: requiredText(payload.name, "请填写杂交项目名"),
crossingProjectDescription: optionalText(payload.description),
@@ -324,6 +327,7 @@ async function updatePedigreeParents(
[germplasmDbId]: buildParentPayload(germplasmDbId, parents),
}),
});
invalidateAfterMutation();
}
export async function fetchCrossPedigreeOptions(): Promise<{
@@ -345,9 +349,109 @@ export async function fetchCrossPedigreeOptions(): Promise<{
};
}
export async function fetchCrossingProjectRows(): Promise<CrossingProjectRecord[]> {
export async function fetchCrossingProjectRows(query?: CrossingProjectQuery): Promise<CrossingProjectRecord[]> {
const snapshot = await loadCrossPedigreeSnapshot();
return snapshot.crossingProjects;
return filterCrossingProjectRows(snapshot.crossingProjects, query);
}
const filterCrossingProjectRows = (rows: CrossingProjectRecord[], query?: CrossingProjectQuery) => {
const keyword = String(query?.keyword ?? "").trim().toLowerCase();
const programId = optionalText(query?.program_id);
return rows.filter((row) => {
if (programId && row.program_id !== programId) return false;
if (!keyword) return true;
const haystack = [row.name, row.description, row.program_name]
.map((value) => String(value ?? "").toLowerCase())
.join(" ");
return haystack.includes(keyword);
});
};
export async function fetchCrossingProjectDetailExtended(id: string): Promise<CrossingProjectDetail> {
const [project, pedigreeNodes] = await Promise.all([
fetchCrossingProjectDetail(id),
fetchPedigreeRows(),
]);
const snapshot = await loadCrossPedigreeSnapshot(true);
const plannedCrosses = snapshot.plannedCrosses.filter((row) => row.crossing_project_id === id);
const actualCrosses = snapshot.actualCrosses.filter((row) => row.crossing_project_id === id);
const relatedPedigreeNodes = pedigreeNodes.filter((row) => row.crossing_project_id === id);
return {
...project,
plannedCrosses,
actualCrosses,
pedigreeNodes: relatedPedigreeNodes,
};
}
export async function fetchPlannedCrossDetail(plannedCrossDbId: string): Promise<PlannedCrossRecord> {
const response = await request<BrapiListResponse<PlannedCross>>(
`/brapi/v2/plannedcrosses?crossDbId=${encodeURIComponent(plannedCrossDbId)}&page=0&pageSize=1`,
);
const cross = response.result.data[0];
if (!cross) throw new Error("计划杂交不存在");
return mapPlannedCross(cross);
}
export async function fetchCrossDetail(crossDbId: string): Promise<CrossRecord> {
const response = await request<BrapiListResponse<Cross>>(
`/brapi/v2/crosses?crossDbId=${encodeURIComponent(crossDbId)}&page=0&pageSize=1`,
);
const cross = response.result.data[0];
if (!cross) throw new Error("实际杂交不存在");
return mapCross(cross);
}
const pollinationEventBody = (event: CrossPollinationEventRecord) => ({
...(optionalText(event.pollination_number) ? { pollinationNumber: optionalText(event.pollination_number) } : {}),
...(event.pollination_successful === null || event.pollination_successful === undefined
? {}
: { pollinationSuccessful: event.pollination_successful }),
...(optionalText(event.pollination_time_stamp)
? { pollinationTimeStamp: optionalText(event.pollination_time_stamp) }
: {}),
});
export function normalizePollinationEventForm(event: CrossPollinationEventRecord) {
return {
pollination_number: event.pollination_number ?? "",
pollination_successful: event.pollination_successful === null ? "unknown" : String(event.pollination_successful),
pollination_time_stamp: event.pollination_time_stamp ?? "",
};
}
export function sortPollinationEvents(events: CrossPollinationEventRecord[]) {
return [...events].sort((left, right) => {
const leftTime = Date.parse(String(left.pollination_time_stamp ?? ""));
const rightTime = Date.parse(String(right.pollination_time_stamp ?? ""));
if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) return 0;
if (Number.isNaN(leftTime)) return 1;
if (Number.isNaN(rightTime)) return -1;
return rightTime - leftTime;
});
}
export async function updateCrossPollinationEvents(
crossId: string,
events: CrossPollinationEventRecord[],
): Promise<CrossRecord> {
const numbers = events
.map((event) => optionalText(event.pollination_number))
.filter(Boolean) as string[];
if (new Set(numbers).size !== numbers.length) {
throw new Error("同一 Cross 下授粉编号不能重复");
}
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
method: "PUT",
body: JSON.stringify({
[crossId]: {
pollinationEvents: events.map(pollinationEventBody),
},
}),
});
invalidateAfterMutation();
return mapCross(response.result.data[0]);
}
export async function fetchCrossingProjectDetail(id: string): Promise<CrossingProjectRecord> {
@@ -412,7 +516,24 @@ export async function createCrossRow(payload: CrossPayload): Promise<CrossRecord
body: JSON.stringify([crossBody(payload)]),
});
invalidateAfterMutation();
return mapCross(response.result.data[0]);
const created = mapCross(response.result.data[0]);
const plannedCrossId = optionalText(payload.planned_cross_id);
if (plannedCrossId) {
const snapshot = await loadCrossPedigreeSnapshot(true);
const planned = snapshot.plannedCrosses.find((item) => item.id === plannedCrossId);
if (planned && (planned.parent1 || planned.parent2)) {
await updateCrossParents(buildCrossParentFormState(
created.id,
false,
created.crossing_project_id,
created.crossing_project_name,
planned.parent1,
planned.parent2,
));
return fetchCrossDetail(created.id);
}
}
return created;
}
export async function updateCrossRow(id: string, payload: CrossPayload): Promise<CrossRecord> {
@@ -430,7 +551,7 @@ export async function fetchCrossParentRows(): Promise<CrossParentRow[]> {
}
export async function updateCrossParents(payload: CrossParentFormState): Promise<void> {
const crossId = requiredText(payload.cross_id, "请选择所<EFBFBD>?Cross");
const crossId = requiredText(payload.cross_id, "请选择所Cross");
const parent1 = buildCrossParent(
payload.parent1_type,
payload.parent1_germplasm_id,
@@ -464,24 +585,37 @@ export async function updateCrossParents(payload: CrossParentFormState): Promise
invalidateAfterMutation();
}
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
const PEDIGREE_LIST_PAGE_SIZE = 500;
async function fetchAllPedigreeRows(query = ""): Promise<PedigreeRecord[]> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=10",
`/brapi/v2/pedigree?page=0&pageSize=${PEDIGREE_LIST_PAGE_SIZE}${query}`,
);
return response.result.data.map(mapPedigree);
}
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
return fetchAllPedigreeRows();
}
export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> {
return fetchAllPedigreeRows("&includeParents=true&includeProgeny=false&includeSiblings=true");
}
export async function fetchPedigreeNodeByGermplasm(germplasmDbId: string): Promise<PedigreeRecord | null> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=10&includeParents=true&includeProgeny=false&includeSiblings=true",
`/brapi/v2/pedigree?germplasmDbId=${encodeURIComponent(germplasmDbId)}&page=0&pageSize=1&includeParents=true&includeProgeny=true&includeSiblings=true`,
);
return response.result.data.map(mapPedigree);
const node = response.result.data[0];
return node ? mapPedigree(node) : null;
}
export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> {
const byGermplasm = await fetchPedigreeNodeByGermplasm(id);
if (byGermplasm) return byGermplasm;
const rows = await fetchPedigreeRows();
const found = rows.find((row) => row.id === id || row.germplasm_id === id);
if (!found) throw new Error("系谱节点不存");
if (!found) throw new Error("系谱节点不存");
return found;
}
@@ -490,6 +624,7 @@ export async function createPedigreeRow(payload: PedigreePayload): Promise<Pedig
method: "POST",
body: JSON.stringify([pedigreeBody(payload, true)]),
});
invalidateAfterMutation();
return mapPedigree(response.result.data[0]);
}
@@ -504,12 +639,17 @@ export async function updatePedigreeRow(id: string, payload: PedigreePayload): P
},
}),
});
invalidateAfterMutation();
return mapPedigree(response.result.data[0]);
}
export async function fetchPedigreeEdgeRows(): Promise<PedigreeEdgeRow[]> {
export async function fetchPedigreeEdgeRows(scopeGermplasmDbId?: string): Promise<PedigreeEdgeRow[]> {
const nodes = await fetchPedigreeRowsWithRelations();
return flattenPedigreeEdges(nodes);
const rows = flattenPedigreeEdges(nodes);
if (!scopeGermplasmDbId) return rows;
return rows.filter(
(row) => row.this_node_id === scopeGermplasmDbId || row.connected_node_id === scopeGermplasmDbId,
);
}
export function buildPedigreeEdgeFormState(row?: PedigreeEdgeRow): PedigreeEdgeFormState {
@@ -526,7 +666,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina
const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料");
const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料");
if (thisNodeId === connectedNodeId) {
throw new Error("当前材料与关联材料不能相");
throw new Error("当前材料与关联材料不能相");
}
if (edgeType === "sibling") {
throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护");
@@ -560,7 +700,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina
export async function removePedigreeEdge(edgeId: string): Promise<void> {
const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":");
if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) {
throw new Error("仅支持删<EFBFBD>?parent 关系");
throw new Error("仅支持删parent 关系");
}
const nodes = await fetchPedigreeRowsWithRelations();

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { GitFork } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
@@ -123,12 +124,43 @@ export function CrossEntityTab() {
icon={GitFork}
iconBg="bg-gradient-to-br from-emerald-500 to-green-600"
title="计划杂交"
description="cross_entity(planned=true):录入杂交计划亲本请在「杂交亲本」Tab 维护"
description="cross_entity(planned=true):录入杂交计划亲本与详情页 Parents Tab 维护"
addLabel="新增计划杂交"
useEnhancedDialog
columns={[
{ key: "plannedCrossDbId", label: "Cross ID" },
{ key: "name", label: "名称" },
{
key: "plannedCrossDbId",
label: "Cross ID",
render: (value, row) => {
const id = String(row.id ?? row.plannedCrossDbId ?? value ?? "");
if (!id) return "—";
return (
<Link
href={`/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(id)}`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{id}
</Link>
);
},
},
{
key: "name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.plannedCrossDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(id)}`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{name}
</Link>
);
},
},
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "cross_type", label: "类型", render: crossTypeLabel },
{ key: "status", label: "状态", render: plannedStatusLabel },
@@ -156,12 +188,43 @@ export function CrossEntityTab() {
icon={GitFork}
iconBg="bg-gradient-to-br from-green-600 to-emerald-700"
title="实际杂交"
description="cross_entity(planned=false):完成实际杂交后可关联来源计划杂交亲本请在「杂交亲本」Tab 维护"
description="cross_entity(planned=false):完成实际杂交后可关联来源计划杂交并继承亲本;详情页维护 Parents 与授粉事件"
addLabel="新增实际杂交"
useEnhancedDialog
columns={[
{ key: "crossDbId", label: "Cross ID" },
{ key: "name", label: "名称" },
{
key: "crossDbId",
label: "Cross ID",
render: (value, row) => {
const id = String(row.id ?? row.crossDbId ?? value ?? "");
if (!id) return "—";
return (
<Link
href={`/germplasm/cross-pedigree/crosses/${encodeURIComponent(id)}`}
className="font-medium text-green-600 hover:underline dark:text-green-400"
>
{id}
</Link>
);
},
},
{
key: "name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.crossDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/cross-pedigree/crosses/${encodeURIComponent(id)}`}
className="font-medium text-green-600 hover:underline dark:text-green-400"
>
{name}
</Link>
);
},
},
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "plannedCrossName", label: "来源计划杂交" },
{ key: "cross_type", label: "类型", render: crossTypeLabel },

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pencil, Users } from "lucide-react";
import {
@@ -273,7 +274,16 @@ export function CrossParentTab() {
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.cross_name || row.cross_id}</TableCell>
<TableCell>
<Link
href={row.planned
? `/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(row.cross_id)}`
: `/germplasm/cross-pedigree/crosses/${encodeURIComponent(row.cross_id)}?tab=parents`}
className="font-medium text-violet-600 hover:underline dark:text-violet-400"
>
{row.cross_name || row.cross_id}
</Link>
</TableCell>
<TableCell>{row.planned ? "计划杂交" : "实际杂交"}</TableCell>
<TableCell>{row.crossing_project_name || "—"}</TableCell>
<TableCell>{row.parent_slot === "parent1" ? "Parent 1" : "Parent 2"}</TableCell>

View File

@@ -0,0 +1,231 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { PARENT_TYPE_OPTIONS, parentTypeLabel } from "../constants";
import {
buildCrossParentFormState,
fetchCrossPedigreeOptions,
updateCrossParents,
} from "../api";
import { NONE_SELECT_VALUE, type CrossParentFormState, type SelectOption } from "../types";
import type { CrossParent } from "@/lib/api/types.gen";
interface CrossParentsPanelProps {
crossId: string;
planned: boolean;
crossName: string | null;
crossingProjectId: string | null;
crossingProjectName: string | null;
parent1: CrossParent | null;
parent2: CrossParent | null;
onChanged?: () => void;
}
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 CrossParentsPanel({
crossId,
planned,
crossName,
crossingProjectId,
crossingProjectName,
parent1,
parent2,
onChanged,
}: CrossParentsPanelProps) {
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [observationUnitOptions, setObservationUnitOptions] = useState<SelectOption[]>([]);
const [loadingOptions, setLoadingOptions] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const savingRef = useRef(false);
const [form, setForm] = useState<CrossParentFormState>(() => buildCrossParentFormState(
crossId,
planned,
crossingProjectId,
crossingProjectName,
parent1,
parent2,
));
useEffect(() => {
setForm(buildCrossParentFormState(
crossId,
planned,
crossingProjectId,
crossingProjectName,
parent1,
parent2,
));
}, [crossId, planned, crossingProjectId, crossingProjectName, parent1, parent2]);
useEffect(() => {
let mounted = true;
setLoadingOptions(true);
fetchCrossPedigreeOptions()
.then((options) => {
if (!mounted) return;
setGermplasmOptions(options.germplasm);
setObservationUnitOptions(options.observationUnits);
})
.finally(() => {
if (mounted) setLoadingOptions(false);
});
return () => { mounted = false; };
}, []);
const handleSave = useCallback(async () => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
await updateCrossParents(form);
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
savingRef.current = false;
setSaving(false);
}
}, [form, onChanged]);
if (loadingOptions) {
return <Skeleton className="h-64 w-full rounded-xl" />;
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4 text-violet-500" />
(cross_parent)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
{crossName || crossId} parent1 / parent2germplasm observation_unit
</p>
<div>
<Label className="mb-1.5 block text-sm">crossing_project_id</Label>
<Input readOnly value={crossingProjectName || crossingProjectId || "—"} />
</div>
<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((current) => ({ ...current, ...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((current) => ({ ...current, ...patch }))}
/>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
Parent1 {parentTypeLabel(form.parent1_type)} /
Parent2 {parentTypeLabel(form.parent2_type)}
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存亲本"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,304 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Flower2, Pencil, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
sortPollinationEvents,
updateCrossPollinationEvents,
} from "../api";
import type { CrossPollinationEventRecord } from "../types";
interface CrossPollinationEventPanelProps {
crossId: string;
crossName: string | null;
events: CrossPollinationEventRecord[];
onChanged?: () => void;
}
const SUCCESS_OPTIONS = [
{ value: "unknown", label: "未指定" },
{ value: "true", label: "成功" },
{ value: "false", label: "失败" },
];
const emptyForm = () => ({
pollination_number: "",
pollination_successful: "unknown",
pollination_time_stamp: "",
});
const toLocalDateTime = (value: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
const offset = date.getTimezoneOffset();
const local = new Date(date.getTime() - offset * 60_000);
return local.toISOString().slice(0, 16);
};
const fromLocalDateTime = (value: string) => {
const normalized = value.trim();
if (!normalized) return null;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) throw new Error("请输入有效的授粉时间");
return date.toISOString();
};
const buildEventId = (event: Omit<CrossPollinationEventRecord, "id">, index: number) => {
const number = String(event.pollination_number ?? "").trim();
return number ? `num:${number}` : `idx:${index}`;
};
export function CrossPollinationEventPanel({
crossId,
crossName,
events,
onChanged,
}: CrossPollinationEventPanelProps) {
const [rows, setRows] = useState<CrossPollinationEventRecord[]>(() => sortPollinationEvents(events));
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CrossPollinationEventRecord | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const savingRef = useRef(false);
const sortedRows = useMemo(() => sortPollinationEvents(rows), [rows]);
useEffect(() => {
setRows(sortPollinationEvents(events));
}, [events]);
const persistEvents = useCallback(async (nextEvents: CrossPollinationEventRecord[]) => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
const saved = await updateCrossPollinationEvents(crossId, nextEvents);
setRows(sortPollinationEvents(saved.pollination_events));
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
throw event;
} finally {
savingRef.current = false;
setSaving(false);
}
}, [crossId, onChanged]);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm());
setDialogOpen(true);
};
const openEdit = (row: CrossPollinationEventRecord) => {
setEditingId(row.id);
setForm({
pollination_number: row.pollination_number ?? "",
pollination_successful: row.pollination_successful === null
? "unknown"
: String(row.pollination_successful),
pollination_time_stamp: toLocalDateTime(row.pollination_time_stamp),
});
setDialogOpen(true);
};
const handleSave = async () => {
const nextEvent = {
pollination_number: form.pollination_number.trim() || null,
pollination_successful: form.pollination_successful === "unknown"
? null
: form.pollination_successful === "true",
pollination_time_stamp: fromLocalDateTime(form.pollination_time_stamp),
};
const withoutCurrent = editingId
? rows.filter((row) => row.id !== editingId)
: rows;
const duplicateNumber = nextEvent.pollination_number
&& withoutCurrent.some((row) => row.pollination_number === nextEvent.pollination_number);
if (duplicateNumber) {
setError("同一 Cross 下授粉编号不能重复");
return;
}
const nextRows = [
...withoutCurrent,
{
id: buildEventId(nextEvent, withoutCurrent.length),
...nextEvent,
},
];
try {
await persistEvents(nextRows);
setDialogOpen(false);
} catch {
// error already set
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
const nextRows = rows.filter((row) => row.id !== deleteTarget.id);
try {
await persistEvents(nextRows);
setDeleteTarget(null);
} catch {
// error already set
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Flower2 className="h-4 w-4 text-pink-500" />
(cross_pollination_event)
</CardTitle>
<Button size="sm" className="gap-1" onClick={openCreate} disabled={saving}>
<Plus className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-slate-500 dark:text-slate-400">
{crossName || crossId} Cross
</p>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-slate-400">
</TableCell>
</TableRow>
) : (
sortedRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.pollination_number || "—"}</TableCell>
<TableCell>{row.pollination_time_stamp || "—"}</TableCell>
<TableCell>
{row.pollination_successful === null ? (
"—"
) : (
<Badge variant={row.pollination_successful ? "default" : "secondary"}>
{row.pollination_successful ? "成功" : "失败"}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)} disabled={saving}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="gap-1 text-destructive" onClick={() => setDeleteTarget(row)} disabled={saving}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg" title={editingId ? "编辑授粉事件" : "新增授粉事件"}>
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Input
value={form.pollination_number}
onChange={(event) => setForm((current) => ({ ...current, pollination_number: event.target.value }))}
placeholder="同一 Cross 下建议唯一"
/>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Input
type="datetime-local"
value={form.pollination_time_stamp}
onChange={(event) => setForm((current) => ({ ...current, pollination_time_stamp: event.target.value }))}
/>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form.pollination_successful}
onValueChange={(value) => setForm((current) => ({ ...current, pollination_successful: value }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{SUCCESS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "保存中..." : "保存"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.pollination_number || "未命名"}Cross
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={saving}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -1,25 +1,39 @@
"use client";
import { useCallback, useMemo } from "react";
import { Network } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { Network, RotateCcw, Search } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
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 {
createCrossingProjectRow,
fetchCrossingProjectDetail,
fetchCrossingProjectRows,
normalizeCrossingProjectForm,
updateCrossingProjectRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
import { NONE_SELECT_VALUE, type CrossingProjectQuery } from "../types";
const emptyQuery = (): CrossingProjectQuery => ({
keyword: "",
program_id: NONE_SELECT_VALUE,
});
export function CrossingProjectTab() {
const { snapshot, refresh } = useCrossPedigree();
const programOptions = snapshot?.programs ?? [];
const [draftQuery, setDraftQuery] = useState<CrossingProjectQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<CrossingProjectQuery>(emptyQuery);
const loadRows = useCallback(async () => {
const data = await refresh(false);
return data.crossingProjects as unknown as Record<string, unknown>[];
}, [refresh]);
await refresh(false);
const rows = await fetchCrossingProjectRows(appliedQuery);
return rows as unknown as Record<string, unknown>[];
}, [appliedQuery, refresh]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchCrossingProjectDetail(id);
@@ -50,18 +64,79 @@ export function CrossingProjectTab() {
},
], [programOptions]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.keyword ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, keyword: event.target.value }))}
placeholder="项目名称 / 说明模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Program</Label>
<Select
value={draftQuery.program_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, program_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部 Program" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}> Program</SelectItem>
{programOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, programOptions]);
return (
<BrapiEntityPage
icon={Network}
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
title="CrossingProject 杂交项目"
description="crossing_project某 Program 下的一组杂交任务集合(杂交工作台)。ID 由系统自动生成。"
description="crossing_project某 Program 下的一组杂交任务集合(杂交工作台)。点击进入详情可查看下属 Cross 与 Pedigree Node。"
addLabel="新增杂交项目"
useEnhancedDialog
fetchRecord={fetchRecord}
renderQueryForm={renderQueryForm}
columns={[
{ key: "crossingProjectDbId", label: "项目 ID" },
{ key: "name", label: "项目名称" },
{
key: "name",
label: "项目名称",
render: (value, row) => {
const id = String(row.id ?? row.crossingProjectDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/cross-pedigree/projects/${encodeURIComponent(id)}`}
className="font-medium text-lime-600 hover:underline dark:text-lime-400"
>
{name}
</Link>
);
},
},
{ key: "program_name", label: "Program" },
{
key: "description",

View File

@@ -0,0 +1,399 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, 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";
interface PedigreeEdgePanelProps {
scopeGermplasmDbId?: string;
compact?: boolean;
onChanged?: () => void;
}
export function PedigreeEdgePanel({ scopeGermplasmDbId, compact = false, onChanged }: PedigreeEdgePanelProps) {
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 saveLockRef = useRef(false);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [edgeRows, nodes] = await Promise.all([
fetchPedigreeEdgeRows(scopeGermplasmDbId),
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);
}
}, [scopeGermplasmDbId]);
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 系谱节点」创建至少两个节点");
return;
}
setEditingEdgeId(null);
const nextForm = buildPedigreeEdgeFormState();
if (scopeGermplasmDbId) {
nextForm.this_node_id = scopeGermplasmDbId;
}
setForm(nextForm);
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 || saveLockRef.current) return;
saveLockRef.current = true;
setSaving(true);
setError(null);
try {
await upsertPedigreeEdge(form, editingEdgeId ?? undefined);
setDialogOpen(false);
await loadRows();
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
setSaving(false);
saveLockRef.current = false;
}
};
const handleDelete = async () => {
if (!deletingEdge || saveLockRef.current) return;
saveLockRef.current = true;
setDeleting(true);
setError(null);
try {
await removePedigreeEdge(deletingEdge.id);
setDeletingEdge(null);
await loadRows();
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "删除失败");
} finally {
setDeleting(false);
saveLockRef.current = false;
}
};
const showParentType = form?.edge_type === "parent" || form?.edge_type === "child";
const lockThisNode = Boolean(scopeGermplasmDbId);
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">
{compact ? "系谱边" : "Pedigree Edge 系谱边"}
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
{scopeGermplasmDbId
? "维护当前种质节点的 parent / child 关系sibling 由 BrAPI 自动推断(只读)。"
: "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>
{!compact ? (
<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>
) : null}
<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 || lockThisNode) return;
setForm({ ...form, this_node_id: value });
}}
disabled={lockThisNode}
>
<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
.filter((option) => option.value !== scopeGermplasmDbId)
.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

@@ -1,369 +1,7 @@
"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";
import { PedigreeEdgePanel } from "./PedigreeEdgePanel";
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>
);
return <PedigreeEdgePanel />;
}

View File

@@ -0,0 +1,185 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import { Share2 } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createPedigreeRow,
fetchPedigreeDetail,
fetchPedigreeNodeByGermplasm,
fetchPedigreeRows,
normalizePedigreeForm,
updatePedigreeRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
interface PedigreeNodeFormPanelProps {
scopeGermplasmDbId?: string;
scopeGermplasmName?: string | null;
compact?: boolean;
onChanged?: () => void;
}
export function PedigreeNodeFormPanel({
scopeGermplasmDbId,
scopeGermplasmName,
compact = false,
onChanged,
}: PedigreeNodeFormPanelProps) {
const { snapshot } = useCrossPedigree();
const germplasmOptions = snapshot?.germplasm ?? [];
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
const loadRows = useCallback(async () => {
if (scopeGermplasmDbId) {
const node = await fetchPedigreeNodeByGermplasm(scopeGermplasmDbId);
if (!node) return [];
const projectNameById = new Map(
(snapshot?.crossingProjects ?? []).map((project) => [project.id, project.name || project.id]),
);
return [{
...node,
germplasm_name: node.germplasm_name || scopeGermplasmName,
crossing_project_name:
node.crossing_project_name
|| (node.crossing_project_id ? projectNameById.get(node.crossing_project_id) : null),
}] as unknown as Record<string, unknown>[];
}
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>[];
}, [scopeGermplasmDbId, scopeGermplasmName, snapshot?.crossingProjects]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchPedigreeDetail(id);
return normalizePedigreeForm(detail);
}, []);
const wrapMutation = useCallback(<T,>(action: () => Promise<T>) => async () => {
const result = await action();
onChanged?.();
return result;
}, [onChanged]);
const scopedGermplasmOptions = useMemo(() => {
if (!scopeGermplasmDbId) return germplasmOptions;
const existing = germplasmOptions.find((item) => item.value === scopeGermplasmDbId);
if (existing) return [existing];
return [{
value: scopeGermplasmDbId,
label: scopeGermplasmName || scopeGermplasmDbId,
}];
}, [germplasmOptions, scopeGermplasmDbId, scopeGermplasmName]);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "germplasm_id",
label: "Germplasm 材料",
type: "select",
required: true,
readOnly: Boolean(scopeGermplasmDbId),
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Germplasm" }, ...scopedGermplasmOptions],
},
{
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, scopedGermplasmOptions, scopeGermplasmDbId]);
const columns = useMemo(() => [
{
key: "germplasm_name",
label: "材料",
render: (value: unknown, row: Record<string, unknown>) => {
const id = String(row.germplasm_id ?? row.id ?? "");
const name = String(value ?? row.germplasm_id ?? "—");
if (!id || scopeGermplasmDbId) return name;
return (
<Link
href={`/germplasm/cross-pedigree/pedigree-nodes/${encodeURIComponent(id)}`}
className="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{name}
</Link>
);
},
},
...(scopeGermplasmDbId ? [] : [{ 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: unknown) => {
const text = String(value ?? "").trim();
if (!text) return "—";
return text.length > 40 ? `${text.slice(0, 40)}` : text;
},
},
], [scopeGermplasmDbId]);
const defaultFormValues = useMemo(() => (
scopeGermplasmDbId
? { germplasm_id: scopeGermplasmDbId }
: undefined
), [scopeGermplasmDbId]);
return (
<BrapiEntityPage
icon={Share2}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title={compact ? "系谱节点" : "Pedigree Node 系谱节点"}
description={
scopeGermplasmDbId
? "维护当前种质对应的 pedigree_node同一 germplasm 通常仅一条节点记录。"
: "pedigree_node系谱树中的节点通常对应一个 germplasm。BrAPI 以 germplasmDbId 作为更新主键。"
}
addLabel={scopeGermplasmDbId ? "创建系谱节点" : "新增系谱节点"}
useEnhancedDialog
fetchRecord={fetchRecord}
columns={columns}
fields={fields}
data={[]}
defaultFormValues={defaultFormValues}
loadData={loadRows}
createRecord={(payload) => wrapMutation(() => {
const body = scopeGermplasmDbId
? { ...payload, germplasm_id: scopeGermplasmDbId }
: payload;
return createPedigreeRow(body);
})() as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => wrapMutation(() => updatePedigreeRow(id, payload))() as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -1,112 +1,7 @@
"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";
import { PedigreeNodeFormPanel } from "./PedigreeNodeFormPanel";
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>>}
/>
);
return <PedigreeNodeFormPanel />;
}

View File

@@ -0,0 +1,153 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { ArrowLeft, Flower2, GitFork, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { crossTypeLabel } from "../../constants";
import { fetchCrossDetail } from "../../api";
import { CrossParentsPanel } from "../../components/CrossParentsPanel";
import { CrossPollinationEventPanel } from "../../components/CrossPollinationEventPanel";
import type { CrossRecord } from "../../types";
type CrossDetailTab = "parents" | "pollination";
function isCrossDetailTab(value: string | null): value is CrossDetailTab {
return value === "parents" || value === "pollination";
}
function CrossDetailPageContent() {
const params = useParams<{ crossDbId: string }>();
const searchParams = useSearchParams();
const crossDbId = decodeURIComponent(params.crossDbId);
const initialTab = isCrossDetailTab(searchParams.get("tab")) ? searchParams.get("tab") as CrossDetailTab : "parents";
const [activeTab, setActiveTab] = useState<CrossDetailTab>(initialTab);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<CrossRecord | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchCrossDetail(crossDbId);
setDetail(record);
}, [crossDbId]);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载杂交详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "实际杂交不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /> Cross </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<GitFork className="h-5 w-5 text-green-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Cross ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.crossing_project_name || detail.crossing_project_id || "—"}</div>
<div><span className="text-slate-500"></span>{detail.plannedCrossName || "—"}</div>
<div><span className="text-slate-500"></span>{crossTypeLabel(detail.cross_type)}</div>
<div><span className="text-slate-500"></span>{detail.pollination_events.length}</div>
</CardContent>
</Card>
{detail.crossing_project_id ? (
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={`/germplasm/cross-pedigree/projects/${encodeURIComponent(detail.crossing_project_id)}`}>
</Link>
</Button>
) : null}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as CrossDetailTab)} 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="parents" className="gap-2"><Users className="h-4 w-4" />Parents </TabsTrigger>
<TabsTrigger value="pollination" className="gap-2"><Flower2 className="h-4 w-4" />Pollination Events</TabsTrigger>
</TabsList>
{activeTab === "parents" ? (
<TabsContent value="parents" className="mt-0">
<CrossParentsPanel
crossId={detail.id}
planned={false}
crossName={detail.name}
crossingProjectId={detail.crossing_project_id}
crossingProjectName={detail.crossing_project_name}
parent1={detail.parent1}
parent2={detail.parent2}
onChanged={loadDetail}
/>
</TabsContent>
) : null}
{activeTab === "pollination" ? (
<TabsContent value="pollination" className="mt-0">
<CrossPollinationEventPanel
crossId={detail.id}
crossName={detail.name}
events={detail.pollination_events}
onChanged={loadDetail}
/>
</TabsContent>
) : null}
</Tabs>
</div>
);
}
export default function CrossDetailPage() {
return (
<Suspense fallback={null}>
<CrossDetailPageContent />
</Suspense>
);
}

View File

@@ -1,5 +1,20 @@
import type { Cross, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
import type { CrossRecord, CrossingProjectRecord, PlannedCrossRecord } from "./types";
import type { Cross, CrossingProject, CrossPollinationEvents, PlannedCross } from "@/lib/api/types.gen";
import type {
CrossPollinationEventRecord,
CrossRecord,
CrossingProjectRecord,
PlannedCrossRecord,
} from "./types";
const mapPollinationEvent = (event: CrossPollinationEvents, index: number): CrossPollinationEventRecord => {
const number = event.pollinationNumber ?? null;
return {
id: number ? `num:${number}` : `idx:${index}`,
pollination_number: number,
pollination_successful: event.pollinationSuccessful ?? null,
pollination_time_stamp: event.pollinationTimeStamp ? String(event.pollinationTimeStamp).slice(0, 19) : null,
};
};
export const mapCrossingProject = (project: CrossingProject): CrossingProjectRecord => ({
id: project.crossingProjectDbId || "",
@@ -48,4 +63,5 @@ export const mapCross = (cross: Cross): CrossRecord => ({
planned: false,
parent1: cross.parent1 ?? null,
parent2: cross.parent2 ?? null,
pollination_events: (cross.pollinationEvents ?? []).map(mapPollinationEvent),
});

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { GitBranch, GitFork, Network, Share2, Users } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CrossPedigreeProvider } from "./CrossPedigreeContext";
@@ -11,8 +12,22 @@ import { PedigreeEdgeTab } from "./components/PedigreeEdgeTab";
import { PedigreeNodeTab } from "./components/PedigreeNodeTab";
function CrossPedigreePageContent() {
const searchParams = useSearchParams();
const [tab, setTab] = useState("projects");
useEffect(() => {
const nextTab = searchParams.get("tab");
if (
nextTab === "projects"
|| nextTab === "crosses"
|| nextTab === "parents"
|| nextTab === "pedigree-nodes"
|| nextTab === "pedigree-edges"
) {
setTab(nextTab);
}
}, [searchParams]);
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">
@@ -74,7 +89,9 @@ function CrossPedigreePageContent() {
export default function CrossPedigreePage() {
return (
<CrossPedigreeProvider>
<CrossPedigreePageContent />
<Suspense fallback={null}>
<CrossPedigreePageContent />
</Suspense>
</CrossPedigreeProvider>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitBranch, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CrossPedigreeProvider } from "../../CrossPedigreeContext";
import { fetchPedigreeNodeByGermplasm } from "../../api";
import { PedigreeEdgePanel } from "../../components/PedigreeEdgePanel";
import { PedigreeNodeFormPanel } from "../../components/PedigreeNodeFormPanel";
import type { PedigreeRecord } from "../../types";
function PedigreeNodeDetailContent() {
const params = useParams<{ germplasmDbId: string }>();
const germplasmDbId = decodeURIComponent(params.germplasmDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [node, setNode] = useState<PedigreeRecord | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const loadDetail = useCallback(async () => {
const record = await fetchPedigreeNodeByGermplasm(germplasmDbId);
setNode(record);
}, [germplasmDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载系谱节点失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail, refreshKey]);
const bumpRefresh = () => setRefreshKey((value) => value + 1);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree?tab=pedigree-nodes">
<ArrowLeft className="mr-2 h-4 w-4" /> Pedigree
</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/germplasm/cross-pedigree?tab=pedigree-nodes">
<ArrowLeft className="mr-2 h-4 w-4" /> Pedigree Node
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/germplasm/germplasm/${encodeURIComponent(germplasmDbId)}?tab=pedigree`}>
<Share2 className="mr-2 h-4 w-4" /> Pedigree Tab
</Link>
</Button>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Share2 className="h-5 w-5 text-sky-500" />
{node?.germplasm_name || germplasmDbId}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div><span className="text-slate-500">Germplasm ID</span>{germplasmDbId}</div>
<div><span className="text-slate-500"></span>{node?.crossing_project_name || "—"}</div>
<div><span className="text-slate-500"></span>{node?.crossing_year ?? "—"}</div>
<div><span className="text-slate-500"></span>{node?.family_code || "—"}</div>
<div className="sm:col-span-2"><span className="text-slate-500"></span>{node?.pedigree_string || "—"}</div>
<div><span className="text-slate-500"></span>{node?.parents?.length ?? 0}</div>
<div><span className="text-slate-500"></span>{node?.siblings?.length ?? 0}</div>
</CardContent>
</Card>
<Tabs defaultValue="node" className="space-y-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="node" className="gap-2">
<Share2 className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="edges" className="gap-2">
<GitBranch className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="node">
<PedigreeNodeFormPanel
key={`node-${refreshKey}`}
scopeGermplasmDbId={germplasmDbId}
scopeGermplasmName={node?.germplasm_name}
onChanged={() => {
bumpRefresh();
loadDetail().catch(() => undefined);
}}
/>
</TabsContent>
<TabsContent value="edges">
<PedigreeEdgePanel
key={`edge-${refreshKey}`}
scopeGermplasmDbId={germplasmDbId}
onChanged={bumpRefresh}
/>
</TabsContent>
</Tabs>
</div>
);
}
export default function PedigreeNodeDetailPage() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
<CrossPedigreeProvider>
<PedigreeNodeDetailContent />
</CrossPedigreeProvider>
</Suspense>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitFork, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { crossTypeLabel, plannedStatusLabel } from "../../constants";
import { fetchPlannedCrossDetail } from "../../api";
import { CrossParentsPanel } from "../../components/CrossParentsPanel";
import type { PlannedCrossRecord } from "../../types";
export default function PlannedCrossDetailPage() {
const params = useParams<{ plannedCrossDbId: string }>();
const plannedCrossDbId = decodeURIComponent(params.plannedCrossDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<PlannedCrossRecord | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchPlannedCrossDetail(plannedCrossDbId);
setDetail(record);
}, [plannedCrossDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载计划杂交详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "计划杂交不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /> Cross </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<GitFork className="h-5 w-5 text-emerald-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Cross ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.crossing_project_name || detail.crossing_project_id || "—"}</div>
<div><span className="text-slate-500"></span>{crossTypeLabel(detail.cross_type)}</div>
<div><span className="text-slate-500"></span>{plannedStatusLabel(detail.status)}</div>
</CardContent>
</Card>
{detail.crossing_project_id ? (
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={`/germplasm/cross-pedigree/projects/${encodeURIComponent(detail.crossing_project_id)}`}>
</Link>
</Button>
) : null}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Users className="h-4 w-4" />
Parents Tab
</div>
<CrossParentsPanel
crossId={detail.id}
planned
crossName={detail.name}
crossingProjectId={detail.crossing_project_id}
crossingProjectName={detail.crossing_project_name}
parent1={detail.parent1}
parent2={detail.parent2}
onChanged={loadDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,227 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitFork, Network, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { crossTypeLabel, plannedStatusLabel } from "../../constants";
import { fetchCrossingProjectDetailExtended } from "../../api";
import type { CrossingProjectDetail } from "../../types";
export default function CrossingProjectDetailPage() {
const params = useParams<{ crossingProjectDbId: string }>();
const crossingProjectDbId = decodeURIComponent(params.crossingProjectDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<CrossingProjectDetail | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchCrossingProjectDetailExtended(crossingProjectDbId);
setDetail(record);
}, [crossingProjectDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载杂交项目详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "杂交项目不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
const hasDependencies = detail.plannedCrosses.length > 0
|| detail.actualCrosses.length > 0
|| detail.pedigreeNodes.length > 0;
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree"><ArrowLeft className="mr-2 h-4 w-4" /> CrossingProject </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Network className="h-5 w-5 text-lime-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500"> ID</span>{detail.id}</div>
<div><span className="text-slate-500">Program</span>{detail.program_name || detail.program_id || "—"}</div>
<div><span className="text-slate-500"></span>{detail.plannedCrosses.length}</div>
<div><span className="text-slate-500"></span>{detail.actualCrosses.length}</div>
<div className="sm:col-span-2 lg:col-span-4"><span className="text-slate-500"></span>{detail.description || "—"}</div>
</CardContent>
</Card>
{hasDependencies ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
CrossCross Parent Pedigree Node
</p>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.plannedCrosses.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-6 text-center text-sm text-slate-400"></TableCell>
</TableRow>
) : detail.plannedCrosses.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(row.id)}`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{row.name || row.id}
</Link>
</TableCell>
<TableCell>{crossTypeLabel(row.cross_type)}</TableCell>
<TableCell>{plannedStatusLabel(row.status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.actualCrosses.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-6 text-center text-sm text-slate-400"></TableCell>
</TableRow>
) : detail.actualCrosses.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/germplasm/cross-pedigree/crosses/${encodeURIComponent(row.id)}`}
className="font-medium text-green-600 hover:underline dark:text-green-400"
>
{row.name || row.id}
</Link>
</TableCell>
<TableCell>{row.plannedCrossName || "—"}</TableCell>
<TableCell>{crossTypeLabel(row.cross_type)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Share2 className="h-4 w-4" />
Pedigree Node
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.pedigreeNodes.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-6 text-center text-sm text-slate-400"></TableCell>
</TableRow>
) : detail.pedigreeNodes.map((row) => {
const germplasmId = String(row.germplasm_id ?? row.id ?? "");
return (
<TableRow key={row.id}>
<TableCell>
{germplasmId ? (
<Link
href={`/germplasm/cross-pedigree/pedigree-nodes/${encodeURIComponent(germplasmId)}`}
className="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{row.germplasm_name || germplasmId}
</Link>
) : (row.germplasm_name || "—")}
</TableCell>
<TableCell>{row.crossing_year ?? "—"}</TableCell>
<TableCell>{row.family_code || "—"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/germplasm/cross-pedigree?tab=crosses`}>
<GitFork className="mr-2 h-4 w-4" />
Cross
</Link>
</Button>
</div>
</div>
);
}

View File

@@ -37,6 +37,13 @@ export interface PlannedCrossRecord {
parent2: CrossParent | null;
}
export interface CrossPollinationEventRecord {
id: string;
pollination_number: string | null;
pollination_successful: boolean | null;
pollination_time_stamp: string | null;
}
export interface CrossRecord {
id: string;
crossDbId: string;
@@ -54,6 +61,18 @@ export interface CrossRecord {
planned: false;
parent1: CrossParent | null;
parent2: CrossParent | null;
pollination_events: CrossPollinationEventRecord[];
}
export interface CrossingProjectQuery {
keyword?: string;
program_id?: string;
}
export interface CrossingProjectDetail extends CrossingProjectRecord {
plannedCrosses: PlannedCrossRecord[];
actualCrosses: CrossRecord[];
pedigreeNodes: PedigreeRecord[];
}
export interface CrossParentRow {

View File

@@ -0,0 +1,203 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import {
ArrowLeft,
Building2,
Dna,
GitBranch,
GitFork,
Globe2,
Hash,
ListChecks,
MapPin,
Package,
Tags,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fetchGermplasmDetail } from "../api";
import {
GermplasmAttributePanel,
GermplasmCrossParentPanel,
GermplasmPedigreePanel,
GermplasmSeedLotPanel,
} from "../components/GermplasmDetailRelatedPanels";
import {
GermplasmDonorPanel,
GermplasmInstitutePanel,
GermplasmOriginPanel,
GermplasmProfileSummaryHint,
GermplasmSynonymPanel,
GermplasmTaxonPanel,
} from "../components/GermplasmProfilePanels";
import type { GermplasmProfileTab } from "../profileTypes";
const DETAIL_TABS: GermplasmProfileTab[] = [
"attributes",
"donors",
"institutes",
"origins",
"synonyms",
"taxons",
"pedigree",
"seed-lots",
"cross-parents",
];
function isDetailTab(value: string | null): value is GermplasmProfileTab {
return DETAIL_TABS.includes(value as GermplasmProfileTab);
}
function GermplasmDetailPageContent() {
const params = useParams<{ germplasmDbId: string }>();
const searchParams = useSearchParams();
const germplasmDbId = decodeURIComponent(params.germplasmDbId);
const initialTab = useMemo(() => {
const tab = searchParams.get("tab");
return isDetailTab(tab) ? tab : "attributes";
}, [searchParams]);
const [activeTab, setActiveTab] = useState<GermplasmProfileTab>(initialTab);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<Awaited<ReturnType<typeof fetchGermplasmDetail>> | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchGermplasmDetail(germplasmDbId);
setDetail(record);
}, [germplasmDbId]);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载种质详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "种质不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/germplasm"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/germplasm"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Dna className="h-5 w-5 text-teal-500" />
{detail.germplasm_name || detail.default_display_name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Germplasm ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.accession_number || "—"}</div>
<div><span className="text-slate-500">PUI</span>{detail.germplasmpui || "—"}</div>
<div><span className="text-slate-500"></span>{detail.crop_name || detail.crop_id || "—"}</div>
<div><span className="text-slate-500"></span>{detail.breeding_method_name || "—"}</div>
<div><span className="text-slate-500"> / </span>{[detail.genus, detail.species].filter(Boolean).join(" ") || "—"}</div>
<div><span className="text-slate-500"></span>{detail.country_of_origin_code || "—"}</div>
<div><span className="text-slate-500"></span>{detail.collection || "—"}</div>
</CardContent>
</Card>
<GermplasmProfileSummaryHint />
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as GermplasmProfileTab)}
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="attributes" className="gap-2"><ListChecks className="h-4 w-4" />Attributes</TabsTrigger>
<TabsTrigger value="donors" className="gap-2"><Building2 className="h-4 w-4" />Donor</TabsTrigger>
<TabsTrigger value="institutes" className="gap-2"><Globe2 className="h-4 w-4" />Institute</TabsTrigger>
<TabsTrigger value="origins" className="gap-2"><MapPin className="h-4 w-4" />Origin</TabsTrigger>
<TabsTrigger value="synonyms" className="gap-2"><Tags className="h-4 w-4" />Synonym</TabsTrigger>
<TabsTrigger value="taxons" className="gap-2"><Hash className="h-4 w-4" />Taxon</TabsTrigger>
<TabsTrigger value="pedigree" className="gap-2"><GitBranch className="h-4 w-4" />Pedigree</TabsTrigger>
<TabsTrigger value="seed-lots" className="gap-2"><Package className="h-4 w-4" />Seed Lots</TabsTrigger>
<TabsTrigger value="cross-parents" className="gap-2"><GitFork className="h-4 w-4" />Cross Parent</TabsTrigger>
</TabsList>
<TabsContent value="attributes" className="mt-0 min-h-0 flex-1">
<GermplasmAttributePanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="donors" className="mt-0 min-h-0 flex-1">
<GermplasmDonorPanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="institutes" className="mt-0 min-h-0 flex-1">
<GermplasmInstitutePanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="origins" className="mt-0 min-h-0 flex-1">
<GermplasmOriginPanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="synonyms" className="mt-0 min-h-0 flex-1">
<GermplasmSynonymPanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="taxons" className="mt-0 min-h-0 flex-1">
<GermplasmTaxonPanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="pedigree" className="mt-0 min-h-0 flex-1">
<GermplasmPedigreePanel
germplasmDbId={germplasmDbId}
germplasmName={detail.germplasm_name || detail.default_display_name}
/>
</TabsContent>
<TabsContent value="seed-lots" className="mt-0 min-h-0 flex-1">
<GermplasmSeedLotPanel germplasmDbId={germplasmDbId} />
</TabsContent>
<TabsContent value="cross-parents" className="mt-0 min-h-0 flex-1">
<GermplasmCrossParentPanel germplasmDbId={germplasmDbId} />
</TabsContent>
</Tabs>
</div>
);
}
export default function GermplasmDetailPage() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
<GermplasmDetailPageContent />
</Suspense>
);
}

View File

@@ -1,7 +1,8 @@
import { fetchBreedingMethodOptions } from "../breeding-method/api";
import { DEFAULT_SEARCH_PAGE_BODY } from "@/constants/api";
import { loadCommonCropNameOptions } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import { NONE_SELECT_VALUE, type GermplasmRecord, type SelectOption } from "./types";
import { NONE_SELECT_VALUE, type GermplasmQuery, type GermplasmRecord, type SelectOption } from "./types";
interface BrapiPagination {
currentPage: number;
@@ -153,9 +154,58 @@ const toRequestBody = (payload: GermplasmPayload) => ({
breedingMethodDbId: optionalText(payload.breeding_method_id),
});
export async function fetchGermplasmRows(): Promise<GermplasmRecord[]> {
const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/germplasm?page=0&pageSize=10");
return response.result.data.map(mapGermplasm);
const buildGermplasmSearchBody = (query?: GermplasmQuery) => {
const body: Record<string, unknown> = { ...DEFAULT_SEARCH_PAGE_BODY };
const crop = optionalText(query?.crop_id);
const synonym = optionalText(query?.synonym);
if (crop) body.commonCropNames = [crop];
if (synonym) body.synonyms = [synonym];
return body;
};
const filterGermplasmRows = (rows: GermplasmRecord[], query?: GermplasmQuery) => {
if (!query) return rows;
const nameFilter = optionalText(query.germplasm_name)?.toLowerCase();
const accessionFilter = optionalText(query.accession_number)?.toLowerCase();
const puiFilter = optionalText(query.germplasmpui)?.toLowerCase();
const cropFilter = optionalText(query.crop_id);
return rows.filter((row) => {
if (cropFilter && row.crop_id !== cropFilter && row.crop_name !== cropFilter && row.commonCropName !== cropFilter) {
return false;
}
if (nameFilter && !String(row.germplasm_name ?? row.default_display_name ?? "").toLowerCase().includes(nameFilter)) {
return false;
}
if (accessionFilter && !String(row.accession_number ?? "").toLowerCase().includes(accessionFilter)) {
return false;
}
if (puiFilter && !String(row.germplasmpui ?? "").toLowerCase().includes(puiFilter)) {
return false;
}
return true;
});
};
export async function fetchGermplasmRows(query?: GermplasmQuery): Promise<GermplasmRecord[]> {
const crop = optionalText(query?.crop_id);
const synonym = optionalText(query?.synonym);
const useSearch = Boolean(crop || synonym);
let rows: GermplasmRecord[];
if (useSearch) {
const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/search/germplasm", {
method: "POST",
body: JSON.stringify(buildGermplasmSearchBody(query)),
});
rows = response.result.data.map(mapGermplasm);
} else {
const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/germplasm?page=0&pageSize=1000");
rows = response.result.data.map(mapGermplasm);
}
return filterGermplasmRows(rows, query);
}
export async function fetchGermplasmDetail(id: string): Promise<GermplasmRecord> {

View File

@@ -14,7 +14,11 @@ import {
import { fetchGermplasmRows } from "../api";
import { NONE_SELECT_VALUE, type SelectOption } from "../attributeTypes";
export function GermplasmAttributeValueTab() {
interface GermplasmAttributeValueTabProps {
scopeGermplasmDbId?: string;
}
export function GermplasmAttributeValueTab({ scopeGermplasmDbId }: GermplasmAttributeValueTabProps = {}) {
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [attributeOptions, setAttributeOptions] = useState<SelectOption[]>([]);
@@ -42,24 +46,29 @@ export function GermplasmAttributeValueTab() {
const loadRows = useCallback(async () => {
await loadSelectOptions();
const rows = await fetchAttributeValueRows();
const rows = await fetchAttributeValueRows(scopeGermplasmDbId);
return rows as unknown as Record<string, unknown>[];
}, [loadSelectOptions]);
}, [loadSelectOptions, scopeGermplasmDbId]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchAttributeValueDetail(id);
return normalizeAttributeValueFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "germplasm_id",
label: "种质材料",
type: "select",
required: true,
placeholder: germplasmOptions.length > 0 ? "请选择种质" : "请先在「种质列表」Tab 创建材料",
options: [{ value: NONE_SELECT_VALUE, label: "请选择种质" }, ...germplasmOptions],
},
const fields = useMemo<BrapiFormField[]>(() => {
const germplasmField: BrapiFormField = scopeGermplasmDbId
? { key: "germplasm_id", label: "种质材料", type: "text", required: true, readOnly: true }
: {
key: "germplasm_id",
label: "种质材料",
type: "select",
required: true,
placeholder: germplasmOptions.length > 0 ? "请选择种质" : "请先在「种质列表」Tab 创建材料",
options: [{ value: NONE_SELECT_VALUE, label: "请选择种质" }, ...germplasmOptions],
};
return [
germplasmField,
{
key: "attribute_id",
label: "属性定义",
@@ -76,24 +85,38 @@ export function GermplasmAttributeValueTab() {
placeholder: "按属性数据类型填写实际取值",
},
{ key: "determined_date", label: "测定日期", type: "date" },
], [attributeOptions, germplasmOptions]);
];
}, [attributeOptions, germplasmOptions, scopeGermplasmDbId]);
const defaultFormValues = useMemo(
() => (scopeGermplasmDbId ? { germplasm_id: scopeGermplasmDbId } : undefined),
[scopeGermplasmDbId],
);
const scopedColumns = useMemo(() => {
const columns = [
{ key: "attributeValueDbId", label: "属性值 ID" },
...(scopeGermplasmDbId ? [] : [{ key: "germplasmName", label: "种质" }]),
{ key: "attributeName", label: "属性" },
{ key: "value", label: "取值" },
{ key: "determined_date", label: "测定日期" },
];
return columns;
}, [scopeGermplasmDbId]);
return (
<BrapiEntityPage
icon={ListChecks}
iconBg="bg-gradient-to-br from-indigo-500 to-blue-600"
title="Germplasm Attribute Value 属性值"
description="记录某个种质在某个属性上的实际取值germplasm_attribute_value"
title={scopeGermplasmDbId ? "属性值" : "Germplasm Attribute Value 属性值"}
description={scopeGermplasmDbId
? "维护当前种质的扩展属性取值germplasm_attribute_value"
: "记录某个种质在某个属性上的实际取值germplasm_attribute_value"}
addLabel="新增属性值"
useEnhancedDialog
defaultFormValues={defaultFormValues}
fetchRecord={fetchRecord}
columns={[
{ key: "attributeValueDbId", label: "属性值 ID" },
{ key: "germplasmName", label: "种质" },
{ key: "attributeName", label: "属性" },
{ key: "value", label: "取值" },
{ key: "determined_date", label: "测定日期" },
]}
columns={scopedColumns}
fields={fields}
data={[]}
stats={[
@@ -104,8 +127,14 @@ export function GermplasmAttributeValueTab() {
},
]}
loadData={loadRows}
createRecord={(payload) => createAttributeValueRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateAttributeValueRow(id, payload) as unknown as Promise<Record<string, unknown>>}
createRecord={(payload) => createAttributeValueRow({
...payload,
germplasm_id: scopeGermplasmDbId ?? payload.germplasm_id,
}) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateAttributeValueRow(id, {
...payload,
germplasm_id: scopeGermplasmDbId ?? payload.germplasm_id,
}) as unknown as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import { GitFork, Package } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { Button } from "@/components/ui/button";
import { fetchCrossParentRows } from "../../cross-pedigree/api";
import { parentTypeLabel } from "../../cross-pedigree/constants";
import { fetchSeedLotRows } from "../../seed-lot/api";
import type { SeedLotRecord } from "../../seed-lot/types";
import { GermplasmAttributeValueTab } from "./GermplasmAttributeValueTab";
export { GermplasmPedigreePanel } from "./GermplasmPedigreePanel";
interface RelatedPanelProps {
germplasmDbId: string;
}
function seedLotContainsGermplasm(row: SeedLotRecord, germplasmDbId: string) {
const mixtures = row.content_mixture ?? row.contentMixture ?? [];
return mixtures.some((item) => (item.germplasm_id ?? item.germplasmDbId) === germplasmDbId);
}
export function GermplasmAttributePanel({ germplasmDbId }: RelatedPanelProps) {
return <GermplasmAttributeValueTab scopeGermplasmDbId={germplasmDbId} />;
}
export function GermplasmSeedLotPanel({ germplasmDbId }: RelatedPanelProps) {
const loadRows = useCallback(async () => {
const rows = await fetchSeedLotRows();
return rows
.filter((row) => seedLotContainsGermplasm(row, germplasmDbId))
.map((row) => row as unknown as Record<string, unknown>);
}, [germplasmDbId]);
const columns = useMemo(() => [
{
key: "name",
label: "批次名称",
render: (value: unknown, row: Record<string, unknown>) => {
const id = String(row.id ?? "");
const name = String(value ?? row.seed_lot_name ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/seed-lot/${encodeURIComponent(id)}?tab=mixture`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{name}
</Link>
);
},
},
{ key: "amount", label: "数量" },
{ key: "units", label: "单位" },
{ key: "location_name", label: "库位" },
{ key: "program_name", label: "Program" },
], []);
return (
<div className="space-y-3">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/seed-lot?tab=lots">
<Package className="mr-2 h-4 w-4" />
Seed Lot
</Link>
</Button>
<BrapiEntityPage
icon={Package}
iconBg="bg-gradient-to-br from-amber-500 to-orange-600"
title="Seed Lot 库存批次"
description="content_mixture 中包含当前种质的 seed_lot 批次;点击名称进入批次详情维护组成。"
columns={columns}
fields={[]}
data={[]}
loadData={loadRows}
/>
</div>
);
}
export function GermplasmCrossParentPanel({ germplasmDbId }: RelatedPanelProps) {
const loadRows = useCallback(async () => {
const rows = await fetchCrossParentRows();
return rows
.filter((row) => row.germplasm_id === germplasmDbId)
.map((row) => ({
...row,
parent_type_label: row.parent_type ? parentTypeLabel(row.parent_type) : "—",
})) as unknown as Record<string, unknown>[];
}, [germplasmDbId]);
const columns = useMemo(() => [
{ key: "cross_name", label: "Cross" },
{ key: "planned", label: "Planned", render: (value: unknown) => (value ? "是" : "否") },
{ key: "parent_slot", label: "亲本槽位" },
{ key: "parent_type_label", label: "亲本类型" },
{ key: "crossing_project_name", label: "杂交项目" },
], []);
return (
<div className="space-y-3">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree?tab=parents">
<GitFork className="mr-2 h-4 w-4" />
Cross Parent
</Link>
</Button>
<BrapiEntityPage
icon={GitFork}
iconBg="bg-gradient-to-br from-rose-500 to-pink-600"
title="Cross Parent 亲本记录"
description="当前种质作为亲本参与的 cross / planned cross 记录(只读列表)。"
columns={columns}
fields={[]}
data={[]}
loadData={loadRows}
/>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { GitBranch, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CrossPedigreeProvider } from "../../cross-pedigree/CrossPedigreeContext";
import { PedigreeEdgePanel } from "../../cross-pedigree/components/PedigreeEdgePanel";
import { PedigreeNodeFormPanel } from "../../cross-pedigree/components/PedigreeNodeFormPanel";
interface GermplasmPedigreePanelProps {
germplasmDbId: string;
germplasmName?: string | null;
}
function GermplasmPedigreePanelContent({ germplasmDbId, germplasmName }: GermplasmPedigreePanelProps) {
const [refreshKey, setRefreshKey] = useState(0);
const bumpRefresh = () => setRefreshKey((value) => value + 1);
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={`/germplasm/cross-pedigree/pedigree-nodes/${encodeURIComponent(germplasmDbId)}`}>
<Share2 className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree?tab=pedigree-nodes">
<GitBranch className="mr-2 h-4 w-4" />
Pedigree
</Link>
</Button>
</div>
<PedigreeNodeFormPanel
key={`node-${refreshKey}`}
scopeGermplasmDbId={germplasmDbId}
scopeGermplasmName={germplasmName}
compact
onChanged={bumpRefresh}
/>
<PedigreeEdgePanel
key={`edge-${refreshKey}`}
scopeGermplasmDbId={germplasmDbId}
compact
onChanged={bumpRefresh}
/>
</div>
);
}
export function GermplasmPedigreePanel(props: GermplasmPedigreePanelProps) {
return (
<CrossPedigreeProvider>
<GermplasmPedigreePanelContent {...props} />
</CrossPedigreeProvider>
);
}

View File

@@ -0,0 +1,252 @@
"use client";
import { useCallback, useMemo } from "react";
import { Building2, Hash, MapPin, Tags } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createDonorRow,
createInstituteRow,
createOriginRow,
createSynonymRow,
createTaxonRow,
deleteDonorRow,
deleteInstituteRow,
deleteOriginRow,
deleteSynonymRow,
deleteTaxonRow,
fetchDonorRecord,
fetchDonorRows,
fetchInstituteRecord,
fetchInstituteRows,
fetchOriginRecord,
fetchOriginRows,
fetchSynonymRecord,
fetchSynonymRows,
fetchTaxonRecord,
fetchTaxonRows,
updateDonorRow,
updateInstituteRow,
updateOriginRow,
updateSynonymRow,
updateTaxonRow,
} from "../profileApi";
import { INSTITUTE_TYPE_OPTIONS } from "../profileTypes";
interface ProfilePanelProps {
germplasmDbId: string;
}
function useProfileMutations<T>(
germplasmDbId: string,
loadRows: (id: string) => Promise<T[]>,
createRow: (id: string, payload: Record<string, unknown>) => Promise<unknown>,
updateRow: (id: string, rowId: string, payload: Record<string, unknown>) => Promise<unknown>,
deleteRow: (id: string, rowId: string) => Promise<unknown>,
) {
const loadData = useCallback(async () => {
const rows = await loadRows(germplasmDbId);
return rows as unknown as Record<string, unknown>[];
}, [germplasmDbId, loadRows]);
const wrap = useCallback(<R, >(action: () => Promise<R>) => action(), []);
return {
loadData,
createRecord: (payload: Record<string, unknown>) => wrap(() => createRow(germplasmDbId, payload)) as Promise<Record<string, unknown>>,
updateRecord: (id: string, payload: Record<string, unknown>) => wrap(() => updateRow(germplasmDbId, id, payload)) as Promise<Record<string, unknown>>,
deleteRecord: (id: string) => wrap(() => deleteRow(germplasmDbId, id)).then(() => undefined),
};
}
export function GermplasmDonorPanel({ germplasmDbId }: ProfilePanelProps) {
const mutations = useProfileMutations(
germplasmDbId,
fetchDonorRows,
createDonorRow,
updateDonorRow,
deleteDonorRow,
);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "donor_accession_number", label: "Donor Accession", type: "text", placeholder: "捐赠方登录号" },
{ key: "donor_institute_code", label: "Donor 机构代码", type: "text", placeholder: "如 USA999" },
{ key: "germplasmpui", label: "Germplasm PUI", type: "text", placeholder: "捐赠方 PUI" },
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Building2}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title="Donor 捐赠方"
description="维护 germplasm_donor同一材料可有多条捐赠方记录。"
addLabel="新增 Donor"
columns={[
{ key: "donor_accession_number", label: "Accession" },
{ key: "donor_institute_code", label: "机构代码" },
{ key: "germplasmpui", label: "PUI" },
]}
fields={fields}
data={[]}
{...mutations}
fetchRecord={(id) => fetchDonorRecord(germplasmDbId, id) as Promise<Record<string, unknown>>}
/>
);
}
export function GermplasmInstitutePanel({ germplasmDbId }: ProfilePanelProps) {
const mutations = useProfileMutations(
germplasmDbId,
fetchInstituteRows,
createInstituteRow,
updateInstituteRow,
deleteInstituteRow,
);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Institute ID", type: "text", placeholder: "留空则系统自动生成" },
{
key: "institute_type",
label: "机构类型",
type: "select",
required: true,
options: INSTITUTE_TYPE_OPTIONS.map((item) => ({ value: item.value, label: item.label })),
},
{ key: "institute_code", label: "机构代码", type: "text", placeholder: "如 USA999" },
{ key: "institute_name", label: "机构名称", type: "text", required: true, placeholder: "机构全称" },
{ key: "institute_address", label: "机构地址", type: "textarea", colSpan: 2 },
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Building2}
iconBg="bg-gradient-to-br from-indigo-500 to-violet-600"
title="Institute 机构"
description="维护 germplasm_institute支持 HOST / DONOR / BREEDING / COLLECTING / REDUNDANT。"
addLabel="新增机构"
columns={[
{ key: "institute_type", label: "类型" },
{ key: "institute_code", label: "代码" },
{ key: "institute_name", label: "名称" },
{ key: "institute_address", label: "地址" },
]}
fields={fields}
data={[]}
{...mutations}
fetchRecord={(id) => fetchInstituteRecord(germplasmDbId, id) as Promise<Record<string, unknown>>}
/>
);
}
export function GermplasmOriginPanel({ germplasmDbId }: ProfilePanelProps) {
const mutations = useProfileMutations(
germplasmDbId,
fetchOriginRows,
createOriginRow,
updateOriginRow,
deleteOriginRow,
);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "longitude", label: "经度", type: "number", placeholder: "WGS84 经度" },
{ key: "latitude", label: "纬度", type: "number", placeholder: "WGS84 纬度" },
{ key: "coordinate_uncertainty", label: "坐标不确定性 (m)", type: "text", placeholder: "可选" },
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={MapPin}
iconBg="bg-gradient-to-br from-emerald-500 to-teal-600"
title="Origin 来源坐标"
description="维护 germplasm_origin以 WGS84 点坐标保存 GeoJSON。"
addLabel="新增 Origin"
columns={[
{ key: "longitude", label: "经度" },
{ key: "latitude", label: "纬度" },
{ key: "coordinate_uncertainty", label: "不确定性" },
]}
fields={fields}
data={[]}
{...mutations}
fetchRecord={(id) => fetchOriginRecord(germplasmDbId, id) as Promise<Record<string, unknown>>}
/>
);
}
export function GermplasmSynonymPanel({ germplasmDbId }: ProfilePanelProps) {
const mutations = useProfileMutations(
germplasmDbId,
fetchSynonymRows,
createSynonymRow,
updateSynonymRow,
deleteSynonymRow,
);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "synonym", label: "别名", type: "text", required: true, placeholder: "别名 / 旧名 / 本地名" },
{ key: "type", label: "类型", type: "text", placeholder: "如 local / commercial / old name" },
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Tags}
iconBg="bg-gradient-to-br from-amber-500 to-orange-600"
title="Synonym 别名"
description="维护 germplasm_synonym列表搜索会命中 alias。"
addLabel="新增别名"
columns={[
{ key: "synonym", label: "别名" },
{ key: "type", label: "类型" },
]}
fields={fields}
data={[]}
{...mutations}
fetchRecord={(id) => fetchSynonymRecord(germplasmDbId, id) as Promise<Record<string, unknown>>}
/>
);
}
export function GermplasmTaxonPanel({ germplasmDbId }: ProfilePanelProps) {
const mutations = useProfileMutations(
germplasmDbId,
fetchTaxonRows,
createTaxonRow,
updateTaxonRow,
deleteTaxonRow,
);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "source_name", label: "来源体系", type: "text", placeholder: "如 NCBI Taxonomy" },
{ key: "taxon_id", label: "Taxon ID", type: "text", required: true, placeholder: "外部 taxon 标识" },
], []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Hash}
iconBg="bg-gradient-to-br from-rose-500 to-pink-600"
title="Taxon 分类标识"
description="维护 germplasm_taxonSample 可引用 taxon 记录。"
addLabel="新增 Taxon"
columns={[
{ key: "source_name", label: "来源" },
{ key: "taxon_id", label: "Taxon ID" },
]}
fields={fields}
data={[]}
{...mutations}
fetchRecord={(id) => fetchTaxonRecord(germplasmDbId, id) as Promise<Record<string, unknown>>}
/>
);
}
export function GermplasmProfileSummaryHint() {
return (
<div className="rounded-lg border border-teal-100 bg-teal-50/60 px-3 py-2 text-xs text-teal-900 dark:border-teal-900/40 dark:bg-teal-950/30 dark:text-teal-100">
Donor / Origin / Synonym / Taxon BrAPI `PUT /germplasm/{id}` Institute 使
</div>
);
}

View File

@@ -1,9 +1,14 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Dna, ListChecks, Tags } from "lucide-react";
import { Dna, ListChecks, RotateCcw, Search, Tags } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
createGermplasmRow,
@@ -16,7 +21,15 @@ import {
} from "./api";
import { GermplasmAttributeTab } from "./components/GermplasmAttributeTab";
import { GermplasmAttributeValueTab } from "./components/GermplasmAttributeValueTab";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
import { NONE_SELECT_VALUE, type GermplasmQuery, type SelectOption } from "./types";
const emptyQuery = (): GermplasmQuery => ({
crop_id: NONE_SELECT_VALUE,
germplasm_name: "",
accession_number: "",
germplasmpui: "",
synonym: "",
});
export default function GermplasmPage() {
return (
@@ -37,6 +50,8 @@ function GermplasmPageContent() {
const [activeTab, setActiveTab] = useState(initialTab);
const [cropOptions, setCropOptions] = useState<SelectOption[]>([]);
const [breedingMethodOptions, setBreedingMethodOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<GermplasmQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<GermplasmQuery>(emptyQuery);
const applyGermplasmOptions = useCallback((options: Awaited<ReturnType<typeof fetchGermplasmOptions>>) => {
setCropOptions(options.crops);
@@ -63,10 +78,87 @@ function GermplasmPageContent() {
}, [applyGermplasmOptions]);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchGermplasmOptions(), fetchGermplasmRows()]);
const query: GermplasmQuery = {
...appliedQuery,
crop_id: appliedQuery.crop_id === NONE_SELECT_VALUE ? undefined : appliedQuery.crop_id,
};
const [options, rows] = await Promise.all([fetchGermplasmOptions(), fetchGermplasmRows(query)]);
applyGermplasmOptions(options);
return rows as unknown as Record<string, unknown>[];
}, [applyGermplasmOptions]);
}, [applyGermplasmOptions, appliedQuery]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.crop_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, crop_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部作物" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{cropOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.germplasm_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, germplasm_name: event.target.value }))}
placeholder="子串匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.accession_number ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, accession_number: event.target.value }))}
placeholder="Accession 子串匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Germplasm PUI</Label>
<Input
value={draftQuery.germplasmpui ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, germplasmpui: event.target.value }))}
placeholder="PUI 子串匹配"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-slate-500">Synonym</Label>
<Input
value={draftQuery.synonym ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, synonym: event.target.value }))}
placeholder="走后端 search/germplasm 精确匹配"
/>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [cropOptions, draftQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchGermplasmDetail(id);
@@ -141,7 +233,23 @@ function GermplasmPageContent() {
fetchRecord={fetchRecord}
columns={[
{ key: "id", label: "种质 ID" },
{ key: "germplasm_name", label: "种质名称" },
{
key: "germplasm_name",
label: "种质名称",
render: (value, row) => {
const id = String(row.id ?? row.germplasmDbId ?? "");
const name = String(value ?? row.default_display_name ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/germplasm/${encodeURIComponent(id)}`}
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
>
{name}
</Link>
);
},
},
{ key: "default_display_name", label: "展示名称" },
{ key: "accession_number", label: "登录号" },
{ key: "country_of_origin_code", label: "来源国家" },
@@ -162,6 +270,7 @@ function GermplasmPageContent() {
{ label: "/brapi/v2/breedingmethods", value: "GET", className: "bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-200" },
]}
loadData={loadRows}
renderQueryForm={() => renderQueryForm()}
createRecord={(payload) => createGermplasmRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateGermplasmRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteGermplasmRow}

View File

@@ -0,0 +1,402 @@
import { getAuthToken } from "@/utils/token";
import type {
GermplasmDonorRecord,
GermplasmInstituteRecord,
GermplasmOriginRecord,
GermplasmSynonymRecord,
GermplasmTaxonRecord,
} from "./profileTypes";
import { fetchGermplasmDetail } from "./api";
interface BrapiSingleResponse<T> {
result: T;
}
interface BrapiListResult<T> {
result: { data: T[] };
}
type RawGermplasmDetail = Record<string, unknown> & {
donors?: Array<Record<string, unknown>>;
germplasmOrigin?: Array<Record<string, unknown>>;
synonyms?: Array<Record<string, unknown>>;
taxonIds?: Array<Record<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}`);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized || null;
};
const rowId = (prefix: string, index: number) => `${prefix}-${index}`;
const parseIndex = (id: string, prefix: string) => {
if (!id.startsWith(`${prefix}-`)) return -1;
return Number(id.slice(prefix.length + 1));
};
const readCoordinates = (item: Record<string, unknown>) => {
const coordinates = item.coordinates as Record<string, unknown> | undefined;
const geometry = coordinates?.geometry as Record<string, unknown> | undefined;
const coords = geometry?.coordinates as number[] | undefined;
if (!Array.isArray(coords) || coords.length < 2) {
return { longitude: null, latitude: null };
}
return { longitude: coords[0], latitude: coords[1] };
};
const buildPointCoordinates = (longitude: unknown, latitude: unknown) => {
const lon = Number(longitude);
const lat = Number(latitude);
if (Number.isNaN(lon) || Number.isNaN(lat)) return null;
return {
type: "Feature",
geometry: {
type: "Point",
coordinates: [lon, lat],
},
};
};
export async function fetchGermplasmProfileDetail(germplasmDbId: string): Promise<RawGermplasmDetail> {
return fetchGermplasmDetail(germplasmDbId) as unknown as RawGermplasmDetail;
}
export async function fetchDonorRows(germplasmDbId: string): Promise<GermplasmDonorRecord[]> {
const detail = await fetchGermplasmProfileDetail(germplasmDbId);
return (detail.donors ?? []).map((item, index) => ({
id: rowId("donor", index),
donor_accession_number: optionalText(item.donorAccessionNumber),
donor_institute_code: optionalText(item.donorInstituteCode),
germplasmpui: optionalText(item.germplasmPUI),
}));
}
export async function fetchOriginRows(germplasmDbId: string): Promise<GermplasmOriginRecord[]> {
const detail = await fetchGermplasmProfileDetail(germplasmDbId);
return (detail.germplasmOrigin ?? []).map((item, index) => {
const { longitude, latitude } = readCoordinates(item);
return {
id: rowId("origin", index),
coordinate_uncertainty: optionalText(item.coordinateUncertainty),
longitude,
latitude,
};
});
}
export async function fetchSynonymRows(germplasmDbId: string): Promise<GermplasmSynonymRecord[]> {
const detail = await fetchGermplasmProfileDetail(germplasmDbId);
return (detail.synonyms ?? []).map((item, index) => ({
id: rowId("synonym", index),
synonym: optionalText(item.synonym),
type: optionalText(item.type),
}));
}
export async function fetchTaxonRows(germplasmDbId: string): Promise<GermplasmTaxonRecord[]> {
const detail = await fetchGermplasmProfileDetail(germplasmDbId);
return (detail.taxonIds ?? []).map((item, index) => ({
id: rowId("taxon", index),
source_name: optionalText(item.sourceName),
taxon_id: optionalText(item.taxonId),
}));
}
const mapInstituteRow = (item: Record<string, unknown>): GermplasmInstituteRecord => {
const instituteDbId = String(item.instituteDbId ?? item.institute_db_id ?? item.id ?? "");
return {
id: instituteDbId,
institute_db_id: instituteDbId,
institute_type: optionalText(item.instituteType ?? item.institute_type),
institute_code: optionalText(item.instituteCode ?? item.institute_code),
institute_name: optionalText(item.instituteName ?? item.institute_name),
institute_address: optionalText(item.instituteAddress ?? item.institute_address),
};
};
export async function fetchInstituteRows(germplasmDbId: string): Promise<GermplasmInstituteRecord[]> {
const response = await request<BrapiListResult<Record<string, unknown>>>(
`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}/institutes`,
);
return response.result.data.map(mapInstituteRow);
}
async function saveDonorRows(germplasmDbId: string, rows: GermplasmDonorRecord[]) {
await request(`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}`, {
method: "PUT",
body: JSON.stringify({
donors: rows.map((row) => ({
donorAccessionNumber: optionalText(row.donor_accession_number),
donorInstituteCode: optionalText(row.donor_institute_code),
germplasmPUI: optionalText(row.germplasmpui),
})),
}),
});
}
async function saveOriginRows(germplasmDbId: string, rows: GermplasmOriginRecord[]) {
await request(`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}`, {
method: "PUT",
body: JSON.stringify({
germplasmOrigin: rows.map((row) => ({
coordinateUncertainty: optionalText(row.coordinate_uncertainty),
coordinates: buildPointCoordinates(row.longitude, row.latitude),
})),
}),
});
}
async function saveSynonymRows(germplasmDbId: string, rows: GermplasmSynonymRecord[]) {
await request(`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}`, {
method: "PUT",
body: JSON.stringify({
synonyms: rows.map((row) => ({
synonym: requiredSynonym(row.synonym),
type: optionalText(row.type),
})),
}),
});
}
async function saveTaxonRows(germplasmDbId: string, rows: GermplasmTaxonRecord[]) {
await request(`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}`, {
method: "PUT",
body: JSON.stringify({
taxonIds: rows.map((row) => ({
sourceName: optionalText(row.source_name),
taxonId: requiredTaxonId(row.taxon_id),
})),
}),
});
}
const requiredSynonym = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) throw new Error("请填写别名");
return normalized;
};
const requiredTaxonId = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) throw new Error("请填写 taxon ID");
return normalized;
};
async function mutateListRows<T extends { id: string }>(
germplasmDbId: string,
loadRows: (id: string) => Promise<T[]>,
saveRows: (id: string, rows: T[]) => Promise<void>,
mutate: (rows: T[]) => T[],
) {
const rows = await loadRows(germplasmDbId);
await saveRows(germplasmDbId, mutate(rows));
}
export async function createDonorRow(germplasmDbId: string, payload: Record<string, unknown>) {
await mutateListRows(germplasmDbId, fetchDonorRows, saveDonorRows, (rows) => [
...rows,
{
id: rowId("donor", rows.length),
donor_accession_number: optionalText(payload.donor_accession_number),
donor_institute_code: optionalText(payload.donor_institute_code),
germplasmpui: optionalText(payload.germplasmpui),
},
]);
return fetchDonorRows(germplasmDbId);
}
export async function updateDonorRow(germplasmDbId: string, id: string, payload: Record<string, unknown>) {
const index = parseIndex(id, "donor");
await mutateListRows(germplasmDbId, fetchDonorRows, saveDonorRows, (rows) => rows.map((row, idx) => (
idx === index
? {
...row,
donor_accession_number: optionalText(payload.donor_accession_number),
donor_institute_code: optionalText(payload.donor_institute_code),
germplasmpui: optionalText(payload.germplasmpui),
}
: row
)));
}
export async function deleteDonorRow(germplasmDbId: string, id: string) {
const index = parseIndex(id, "donor");
await mutateListRows(germplasmDbId, fetchDonorRows, saveDonorRows, (rows) => rows.filter((_, idx) => idx !== index));
}
export async function createOriginRow(germplasmDbId: string, payload: Record<string, unknown>) {
await mutateListRows(germplasmDbId, fetchOriginRows, saveOriginRows, (rows) => [
...rows,
{
id: rowId("origin", rows.length),
coordinate_uncertainty: optionalText(payload.coordinate_uncertainty),
longitude: payload.longitude ?? "",
latitude: payload.latitude ?? "",
},
]);
}
export async function updateOriginRow(germplasmDbId: string, id: string, payload: Record<string, unknown>) {
const index = parseIndex(id, "origin");
await mutateListRows(germplasmDbId, fetchOriginRows, saveOriginRows, (rows) => rows.map((row, idx) => (
idx === index
? {
...row,
coordinate_uncertainty: optionalText(payload.coordinate_uncertainty),
longitude: payload.longitude ?? "",
latitude: payload.latitude ?? "",
}
: row
)));
}
export async function deleteOriginRow(germplasmDbId: string, id: string) {
const index = parseIndex(id, "origin");
await mutateListRows(germplasmDbId, fetchOriginRows, saveOriginRows, (rows) => rows.filter((_, idx) => idx !== index));
}
export async function createSynonymRow(germplasmDbId: string, payload: Record<string, unknown>) {
await mutateListRows(germplasmDbId, fetchSynonymRows, saveSynonymRows, (rows) => [
...rows,
{
id: rowId("synonym", rows.length),
synonym: requiredSynonym(payload.synonym),
type: optionalText(payload.type),
},
]);
}
export async function updateSynonymRow(germplasmDbId: string, id: string, payload: Record<string, unknown>) {
const index = parseIndex(id, "synonym");
await mutateListRows(germplasmDbId, fetchSynonymRows, saveSynonymRows, (rows) => rows.map((row, idx) => (
idx === index
? { ...row, synonym: requiredSynonym(payload.synonym), type: optionalText(payload.type) }
: row
)));
}
export async function deleteSynonymRow(germplasmDbId: string, id: string) {
const index = parseIndex(id, "synonym");
await mutateListRows(germplasmDbId, fetchSynonymRows, saveSynonymRows, (rows) => rows.filter((_, idx) => idx !== index));
}
export async function createTaxonRow(germplasmDbId: string, payload: Record<string, unknown>) {
await mutateListRows(germplasmDbId, fetchTaxonRows, saveTaxonRows, (rows) => [
...rows,
{
id: rowId("taxon", rows.length),
source_name: optionalText(payload.source_name),
taxon_id: requiredTaxonId(payload.taxon_id),
},
]);
}
export async function updateTaxonRow(germplasmDbId: string, id: string, payload: Record<string, unknown>) {
const index = parseIndex(id, "taxon");
await mutateListRows(germplasmDbId, fetchTaxonRows, saveTaxonRows, (rows) => rows.map((row, idx) => (
idx === index
? { ...row, source_name: optionalText(payload.source_name), taxon_id: requiredTaxonId(payload.taxon_id) }
: row
)));
}
export async function deleteTaxonRow(germplasmDbId: string, id: string) {
const index = parseIndex(id, "taxon");
await mutateListRows(germplasmDbId, fetchTaxonRows, saveTaxonRows, (rows) => rows.filter((_, idx) => idx !== index));
}
const instituteBody = (payload: Record<string, unknown>) => ({
instituteDbId: optionalText(payload.id),
instituteType: requiredText(payload.institute_type, "请选择机构类型"),
instituteCode: optionalText(payload.institute_code),
instituteName: optionalText(payload.institute_name),
instituteAddress: optionalText(payload.institute_address),
});
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
export async function createInstituteRow(germplasmDbId: string, payload: Record<string, unknown>) {
const response = await request<BrapiListResult<Record<string, unknown>>>(
`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}/institutes`,
{ method: "POST", body: JSON.stringify(instituteBody(payload)) },
);
return mapInstituteRow(response.result.data[0]);
}
export async function updateInstituteRow(germplasmDbId: string, id: string, payload: Record<string, unknown>) {
const response = await request<BrapiListResult<Record<string, unknown>>>(
`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}/institutes/${encodeURIComponent(id)}`,
{ method: "PUT", body: JSON.stringify(instituteBody(payload)) },
);
return mapInstituteRow(response.result.data[0]);
}
export async function deleteInstituteRow(germplasmDbId: string, id: string) {
await request(
`/brapi/v2/germplasm/${encodeURIComponent(germplasmDbId)}/institutes/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
}
export async function fetchDonorRecord(germplasmDbId: string, id: string) {
const rows = await fetchDonorRows(germplasmDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Donor 记录不存在");
return row;
}
export async function fetchOriginRecord(germplasmDbId: string, id: string) {
const rows = await fetchOriginRows(germplasmDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Origin 记录不存在");
return row;
}
export async function fetchSynonymRecord(germplasmDbId: string, id: string) {
const rows = await fetchSynonymRows(germplasmDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Synonym 记录不存在");
return row;
}
export async function fetchTaxonRecord(germplasmDbId: string, id: string) {
const rows = await fetchTaxonRows(germplasmDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Taxon 记录不存在");
return row;
}
export async function fetchInstituteRecord(germplasmDbId: string, id: string) {
const rows = await fetchInstituteRows(germplasmDbId);
const row = rows.find((item) => item.id === id);
if (!row) throw new Error("Institute 记录不存在");
return row;
}

View File

@@ -0,0 +1,53 @@
export type GermplasmDonorRecord = {
id: string;
donor_accession_number?: string | null;
donor_institute_code?: string | null;
germplasmpui?: string | null;
};
export type GermplasmInstituteRecord = {
id: string;
institute_db_id?: string;
institute_type?: string | null;
institute_code?: string | null;
institute_name?: string | null;
institute_address?: string | null;
};
export type GermplasmOriginRecord = {
id: string;
coordinate_uncertainty?: string | null;
longitude?: string | number | null;
latitude?: string | number | null;
};
export type GermplasmSynonymRecord = {
id: string;
synonym?: string | null;
type?: string | null;
};
export type GermplasmTaxonRecord = {
id: string;
source_name?: string | null;
taxon_id?: string | null;
};
export const INSTITUTE_TYPE_OPTIONS = [
{ value: "HOST", label: "HOST · 保存机构" },
{ value: "DONOR", label: "DONOR · 捐赠机构" },
{ value: "BREEDING", label: "BREEDING · 育种机构" },
{ value: "COLLECTING", label: "COLLECTING · 采集机构" },
{ value: "REDUNDANT", label: "REDUNDANT · 安全备份" },
] as const;
export type GermplasmProfileTab =
| "attributes"
| "donors"
| "institutes"
| "origins"
| "synonyms"
| "taxons"
| "pedigree"
| "seed-lots"
| "cross-parents";

View File

@@ -5,6 +5,14 @@ export interface SelectOption {
label: string;
}
export interface GermplasmQuery {
crop_id?: string;
germplasm_name?: string;
accession_number?: string;
germplasmpui?: string;
synonym?: string;
}
export interface GermplasmRecord {
id: string;
germplasmDbId: string;

View File

@@ -0,0 +1,167 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowLeftRight, Layers, Package } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fetchSeedLotDetail } from "../api";
import { SeedLotContentMixturePanel } from "../components/SeedLotContentMixturePanel";
import { SeedLotTransactionPanel } from "../components/SeedLotTransactionPanel";
import {
STOCK_STATUS_LABEL,
resolveStockStatus,
type SeedLotDetailTab,
} from "../types";
function isSeedLotDetailTab(value: string | null): value is SeedLotDetailTab {
return value === "mixture" || value === "transactions";
}
function StockStatusBadge({ amount }: { amount: number | null | undefined }) {
const status = resolveStockStatus(amount);
const className = status === "depleted"
? "bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-200"
: status === "low"
? "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200"
: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200";
return <Badge variant="outline" className={className}>{STOCK_STATUS_LABEL[status]}</Badge>;
}
function SeedLotDetailPageContent() {
const params = useParams<{ seedLotDbId: string }>();
const searchParams = useSearchParams();
const seedLotDbId = decodeURIComponent(params.seedLotDbId);
const initialTab = useMemo(() => {
const tab = searchParams.get("tab");
return isSeedLotDetailTab(tab) ? tab : "mixture";
}, [searchParams]);
const [activeTab, setActiveTab] = useState<SeedLotDetailTab>(initialTab);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<Awaited<ReturnType<typeof fetchSeedLotDetail>> | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchSeedLotDetail(seedLotDbId);
setDetail(record);
}, [seedLotDbId]);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载批次详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "批次不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/seed-lot"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/seed-lot"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Package className="h-5 w-5 text-lime-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500"> ID</span>{detail.id}</div>
<div className="flex items-center gap-2">
<span className="text-slate-500"></span>
{detail.amount ?? "—"}{detail.units ? ` ${detail.units}` : ""}
<StockStatusBadge amount={detail.amount} />
</div>
<div><span className="text-slate-500"></span>{detail.program_name || "—"}</div>
<div><span className="text-slate-500"></span>{detail.location_name || "—"}</div>
<div><span className="text-slate-500"></span>{detail.storage_location || "—"}</div>
<div><span className="text-slate-500"></span>{detail.source_collection || "—"}</div>
<div><span className="text-slate-500"></span>{detail.created_date || "—"}</div>
<div><span className="text-slate-500"></span>{detail.last_updated || "—"}</div>
<div className="sm:col-span-2 lg:col-span-4">
<span className="text-slate-500"></span>{detail.seed_lot_description || "—"}
</div>
</CardContent>
</Card>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as SeedLotDetailTab)}
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="mixture" className="gap-2">
<Layers className="h-4 w-4" />
Content Mixture
</TabsTrigger>
<TabsTrigger value="transactions" className="gap-2">
<ArrowLeftRight className="h-4 w-4" />
Transactions
</TabsTrigger>
</TabsList>
<TabsContent value="mixture" className="mt-0 min-h-0 flex-1">
<SeedLotContentMixturePanel seedLotDbId={seedLotDbId} />
</TabsContent>
<TabsContent value="transactions" className="mt-0 min-h-0 flex-1">
<SeedLotTransactionPanel
seedLotDbId={seedLotDbId}
embedded
onChanged={loadDetail}
/>
</TabsContent>
</Tabs>
</div>
);
}
export default function SeedLotDetailPage() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
<SeedLotDetailPageContent />
</Suspense>
);
}

View File

@@ -9,11 +9,15 @@ import {
} from "@/services/dropdownCache";
import {
NONE_SELECT_VALUE,
resolveStockStatus,
type ContentMixtureRow,
type SeedLotContentMixtureRecord,
type SeedLotQuery,
type SeedLotRecord,
type SeedLotTransactionQuery,
type SeedLotTransactionRecord,
type SelectOption,
type StockStatus,
type TransactionActionType,
} from "./types";
@@ -154,10 +158,10 @@ export const buildContentMixturePayload = (payload: SeedLotPayload) => {
const mixturePercentage = optionalNumber(row.mixture_percentage);
if (!germplasmDbId && !crossDbId) {
throw new Error("批次组成每行需选择材料或杂交来");
throw new Error("批次组成每行需选择材料或杂交来");
}
if (mixturePercentage === null || mixturePercentage < 0 || mixturePercentage > 100) {
throw new Error("批次组成占比需<EFBFBD>?0 <20>?100 之间");
throw new Error("批次组成占比需在 0 到 100 之间");
}
return {
@@ -267,8 +271,25 @@ export function normalizeSeedLotFormData(record: SeedLotRecord): Record<string,
};
}
const filterSeedLotRows = (rows: SeedLotRecord[], query?: SeedLotQuery) => {
if (!query) return rows;
const programFilter = optionalText(query.program_id);
const locationFilter = optionalText(query.location_id);
const nameFilter = optionalText(query.name)?.toLowerCase();
const statusFilter = optionalText(query.stock_status) as StockStatus | null;
return rows.filter((row) => {
if (programFilter && row.program_id !== programFilter) return false;
if (locationFilter && row.location_id !== locationFilter) return false;
if (nameFilter && !String(row.name ?? "").toLowerCase().includes(nameFilter)) return false;
if (statusFilter && resolveStockStatus(row.amount) !== statusFilter) return false;
return true;
});
};
const seedLotRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<SeedLotRecord>>("/brapi/v2/seedlots?page=0&pageSize=10");
const response = await request<BrapiListResponse<SeedLotRecord>>("/brapi/v2/seedlots?page=0&pageSize=1000");
return response.result.data.map(mapSeedLot);
});
@@ -276,8 +297,9 @@ export function invalidateSeedLotRowsCache() {
seedLotRowsLoader.invalidate();
}
export async function fetchSeedLotRows(force = false): Promise<SeedLotRecord[]> {
return seedLotRowsLoader.load(force);
export async function fetchSeedLotRows(query?: SeedLotQuery, force = false): Promise<SeedLotRecord[]> {
const rows = await seedLotRowsLoader.load(force);
return filterSeedLotRows(rows, query);
}
export async function fetchSeedLotDetail(id: string): Promise<SeedLotRecord> {
@@ -299,7 +321,7 @@ export async function fetchSeedLotOptions(force = false): Promise<{
loadProgramOptions(force),
loadGermplasmOptions(force),
loadCrossOptions(force),
fetchSeedLotRows(force).catch(() => [] as SeedLotRecord[]),
fetchSeedLotRows(undefined, force).catch(() => [] as SeedLotRecord[]),
]);
return {
@@ -326,6 +348,33 @@ export async function createSeedLotRow(payload: SeedLotPayload): Promise<SeedLot
return mapSeedLot(response.result.data[0]);
}
export async function updateSeedLotContentMixture(
seedLotDbId: string,
contentMixture: ContentMixtureRow[],
): Promise<SeedLotRecord> {
const detail = await fetchSeedLotDetail(seedLotDbId);
return updateSeedLotRow(seedLotDbId, {
name: detail.name ?? "",
units: detail.units ?? NONE_SELECT_VALUE,
program_id: detail.program_id ?? NONE_SELECT_VALUE,
location_id: detail.location_id ?? NONE_SELECT_VALUE,
storage_location: detail.storage_location ?? "",
source_collection: detail.source_collection ?? "",
created_date: detail.created_date ?? "",
seed_lot_description: detail.seed_lot_description ?? "",
content_mixture: contentMixture,
});
}
export async function updateSeedLotMetadata(id: string, payload: SeedLotPayload): Promise<SeedLotRecord> {
const detail = await fetchSeedLotDetail(id);
const mixtures = mapContentMixtureToForm(detail.content_mixture ?? detail.contentMixture);
return updateSeedLotRow(id, {
...payload,
content_mixture: mixtures,
});
}
export async function updateSeedLotRow(id: string, payload: SeedLotPayload): Promise<SeedLotRecord> {
const units = optionalText(payload.units);
if (!units) throw new Error("请选择数量单位");
@@ -345,20 +394,70 @@ export async function deleteSeedLotRow(id: string): Promise<void> {
invalidateSeedLotRowsCache();
}
export async function fetchSeedLotTransactions(seedLotDbId?: string): Promise<SeedLotTransactionRecord[]> {
export async function fetchSeedLotTransactions(query?: SeedLotTransactionQuery): Promise<SeedLotTransactionRecord[]> {
const [transactionsResponse, seedLots] = await Promise.all([
request<BrapiListResponse<SeedLotTransactionRecord>>(
seedLotDbId
? `/brapi/v2/seedlots/${encodeURIComponent(seedLotDbId)}/transactions?page=0&pageSize=10`
: "/brapi/v2/seedlots/transactions?page=0&pageSize=10",
"/brapi/v2/seedlots/transactions?page=0&pageSize=1000",
),
fetchSeedLotRows().catch(() => [] as SeedLotRecord[]),
fetchSeedLotRows(undefined, true).catch(() => [] as SeedLotRecord[]),
]);
const seedLotNameById = new Map(seedLots.map((row) => [row.id, row.name || row.id]));
return transactionsResponse.result.data.map((transaction) => mapTransaction(transaction, seedLotNameById));
const seedLotById = new Map(seedLots.map((row) => [row.id, row]));
const rows = transactionsResponse.result.data.map((transaction) => mapTransaction(transaction, seedLotNameById));
return filterTransactionRows(rows, query, seedLotById);
}
const filterTransactionRows = (
rows: SeedLotTransactionRecord[],
query?: SeedLotTransactionQuery,
seedLotById?: Map<string, SeedLotRecord>,
) => {
const seedLotFilter = optionalText(query?.seed_lot_id);
const programFilter = optionalText(query?.program_id);
const locationFilter = optionalText(query?.location_id);
const dateFrom = optionalText(query?.date_from);
const dateTo = optionalText(query?.date_to);
const keyword = String(query?.keyword ?? "").trim().toLowerCase();
return rows.filter((row) => {
if (seedLotFilter && row.from_seed_lot_id !== seedLotFilter && row.to_seed_lot_id !== seedLotFilter) {
return false;
}
if (programFilter || locationFilter) {
const fromLot = row.from_seed_lot_id ? seedLotById?.get(row.from_seed_lot_id) : undefined;
const toLot = row.to_seed_lot_id ? seedLotById?.get(row.to_seed_lot_id) : undefined;
if (programFilter) {
const programMatches = fromLot?.program_id === programFilter || toLot?.program_id === programFilter;
if (!programMatches) return false;
}
if (locationFilter) {
const locationMatches = fromLot?.location_id === locationFilter || toLot?.location_id === locationFilter;
if (!locationMatches) return false;
}
}
const timestamp = String(row.timestamp ?? "");
if (dateFrom && timestamp.slice(0, 10) < dateFrom) return false;
if (dateTo && timestamp.slice(0, 10) > dateTo) return false;
if (keyword) {
const haystack = [
row.id,
row.description,
row.from_seed_lot_name,
row.to_seed_lot_name,
row.from_seed_lot_id,
row.to_seed_lot_id,
].map((value) => String(value ?? "").toLowerCase()).join(" ");
if (!haystack.includes(keyword)) return false;
}
return true;
});
};
export function inferTransactionAction(transaction: SeedLotTransactionRecord): TransactionActionType | "unknown" {
const fromId = transaction.from_seed_lot_id;
const toId = transaction.to_seed_lot_id;
@@ -388,7 +487,7 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed
const toId = optionalText(payload.to_seed_lot_id);
if (fromId && toId && fromId === toId) {
throw new Error("来源批次与目标批次不能相");
throw new Error("来源批次与目标批次不能相");
}
let fromSeedLotDbId: string | undefined;
@@ -405,38 +504,38 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed
case "consume":
if (!fromId) throw new Error("出库/消耗需选择来源批次");
if ((payload.action === "out" || payload.action === "consume") && !description) {
throw new Error("出库/消<EFBFBD>?报废建议填写流转说明");
throw new Error("出库/消耗/报废建议填写流转说明");
}
fromSeedLotDbId = fromId;
units = units || seedLotMap.get(fromId)?.units || null;
break;
case "transfer":
case "split":
if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批");
if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批");
fromSeedLotDbId = fromId;
toSeedLotDbId = toId;
units = units || seedLotMap.get(fromId)?.units || seedLotMap.get(toId)?.units || null;
break;
default:
throw new Error("未知的库存动");
throw new Error("未知的库存动");
}
if (!fromSeedLotDbId && !toSeedLotDbId) {
throw new Error("来源批次与目标批次至少填写一");
throw new Error("来源批次与目标批次至少填写一");
}
const sourceLot = fromSeedLotDbId ? seedLotMap.get(fromSeedLotDbId) : undefined;
if (fromSeedLotDbId && sourceLot) {
const currentAmount = Number(sourceLot.amount ?? 0);
if (amount > currentAmount) {
throw new Error(`出库数量不能超过当前库存${currentAmount}${sourceLot.units ? ` ${sourceLot.units}` : ""}`);
throw new Error(`出库数量不能超过当前库存${currentAmount}${sourceLot.units ? ` ${sourceLot.units}` : ""}`);
}
}
if (!units) throw new Error("请指定流转单");
if (!units) throw new Error("请指定流转单");
const transactionDescription = payload.action === "consume" && description && !description.includes("消")
? `<EFBFBD>?报废:${description}`
? `耗/报废:${description}`
: payload.action === "split" && description && !description.includes("分装")
? `分装:${description}`
: description;
@@ -453,6 +552,7 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed
}]),
});
invalidateSeedLotRowsCache();
const seedLotNameById = new Map(
Array.from(seedLotMap.entries()).map(([key, row]) => [key, row.name || row.id]),
);

View File

@@ -0,0 +1,149 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Layers, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
fetchSeedLotDetail,
fetchSeedLotOptions,
mapContentMixtureToForm,
updateSeedLotContentMixture,
} from "../api";
import { ContentMixtureEditor } from "./ContentMixtureEditor";
import { NONE_SELECT_VALUE, type ContentMixtureRow, type SelectOption } from "../types";
interface SeedLotContentMixturePanelProps {
seedLotDbId: string;
}
export function SeedLotContentMixturePanel({ seedLotDbId }: SeedLotContentMixturePanelProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [rows, setRows] = useState<ContentMixtureRow[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [crossOptions, setCrossOptions] = useState<SelectOption[]>([]);
const savingRef = useRef(false);
const loadPanel = useCallback(async () => {
const [detail, options] = await Promise.all([
fetchSeedLotDetail(seedLotDbId),
fetchSeedLotOptions(),
]);
setRows(mapContentMixtureToForm(detail.content_mixture ?? detail.contentMixture));
setGermplasmOptions(options.germplasms);
setCrossOptions(options.crosses);
}, [seedLotDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadPanel()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载批次组成失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadPanel]);
const totalPercentage = useMemo(
() => rows.reduce((sum, row) => {
const value = Number(row.mixture_percentage);
return sum + (Number.isNaN(value) ? 0 : value);
}, 0),
[rows],
);
const handleSave = async () => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
setSuccess(null);
try {
await updateSeedLotContentMixture(seedLotDbId, rows);
setSuccess("批次组成已保存");
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
savingRef.current = false;
setSaving(false);
}
};
if (loading) {
return <Skeleton className="h-64 w-full rounded-xl" />;
}
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Layers className="h-5 w-5 text-emerald-500" />
Content Mixture
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-600 dark:text-slate-300">
`seed_lot_content_mixture` germplasm cross 100%
</p>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-slate-500">
<span></span>
<span className={Math.abs(totalPercentage - 100) <= 0.01 ? "text-emerald-600" : "text-amber-600"}>
{totalPercentage}% / 100%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className={`h-full transition-all ${Math.abs(totalPercentage - 100) <= 0.01 ? "bg-emerald-500" : "bg-amber-500"}`}
style={{ width: `${Math.min(totalPercentage, 100)}%` }}
/>
</div>
</div>
<ContentMixtureEditor
rows={rows}
germplasmOptions={germplasmOptions}
crossOptions={crossOptions}
onChange={setRows}
/>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
{success ? (
<p className="text-sm text-emerald-600 dark:text-emerald-400">{success}</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button type="button" className="gap-2" disabled={saving} onClick={handleSave}>
<Save className="h-4 w-4" />
{saving ? "保存中…" : "保存组成"}
</Button>
</div>
</CardContent>
</Card>
<p className="text-xs text-slate-500">
germplasm
{" "}
<Link href="/germplasm/germplasm" className="text-teal-600 hover:underline dark:text-teal-400"></Link>
cross
{" "}
<Link href="/germplasm/cross-pedigree?tab=crosses" className="text-teal-600 hover:underline dark:text-teal-400"></Link>
</p>
</div>
);
}

View File

@@ -1,9 +1,11 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { Package } from "lucide-react";
import { Package, RotateCcw, Search } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
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";
@@ -15,7 +17,7 @@ import {
fetchSeedLotRows,
mapContentMixtureToForm,
normalizeSeedLotFormData,
updateSeedLotRow,
updateSeedLotMetadata,
} from "../api";
import { ContentMixtureEditor } from "./ContentMixtureEditor";
import {
@@ -23,7 +25,9 @@ import {
STOCK_STATUS_LABEL,
resolveStockStatus,
type ContentMixtureRow,
type SeedLotQuery,
type SelectOption,
type StockStatus,
} from "../types";
const unitOptions: SelectOption[] = [
@@ -35,6 +39,20 @@ const unitOptions: SelectOption[] = [
{ value: "tube", label: "tube 管" },
];
const stockStatusOptions: SelectOption[] = [
{ value: NONE_SELECT_VALUE, label: "全部状态" },
{ value: "sufficient", label: STOCK_STATUS_LABEL.sufficient },
{ value: "low", label: STOCK_STATUS_LABEL.low },
{ value: "depleted", label: STOCK_STATUS_LABEL.depleted },
];
const emptyQuery = (): SeedLotQuery => ({
program_id: NONE_SELECT_VALUE,
location_id: NONE_SELECT_VALUE,
name: "",
stock_status: NONE_SELECT_VALUE,
});
function StockStatusBadge({ amount }: { amount: unknown }) {
const status = resolveStockStatus(typeof amount === "number" ? amount : Number(amount ?? 0));
const className = status === "depleted"
@@ -50,6 +68,8 @@ export function SeedLotTab() {
const [programOptions, setProgramOptions] = useState<SelectOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [crossOptions, setCrossOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<SeedLotQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<SeedLotQuery>(emptyQuery);
const applyOptions = useCallback((options: Awaited<ReturnType<typeof fetchSeedLotOptions>>) => {
setLocationOptions(options.locations);
@@ -59,11 +79,23 @@ export function SeedLotTab() {
return options;
}, []);
const normalizedQuery = useMemo((): SeedLotQuery => ({
...appliedQuery,
program_id: appliedQuery.program_id === NONE_SELECT_VALUE ? undefined : appliedQuery.program_id,
location_id: appliedQuery.location_id === NONE_SELECT_VALUE ? undefined : appliedQuery.location_id,
stock_status: appliedQuery.stock_status === NONE_SELECT_VALUE
? undefined
: appliedQuery.stock_status as StockStatus | undefined,
}), [appliedQuery]);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchSeedLotOptions(), fetchSeedLotRows()]);
const [options, rows] = await Promise.all([
fetchSeedLotOptions(),
fetchSeedLotRows(normalizedQuery),
]);
applyOptions(options);
return rows as unknown as Record<string, unknown>[];
}, [applyOptions]);
}, [applyOptions, normalizedQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchSeedLotDetail(id);
@@ -97,6 +129,84 @@ export function SeedLotTab() {
{ key: "seed_lot_description", label: "批次说明", type: "textarea", placeholder: "材料来源、处理方式、入库备注等", colSpan: 2 },
], [locationOptions, programOptions]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.program_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, program_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部项目" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{programOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.location_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, location_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部地点" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{locationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, name: event.target.value }))}
placeholder="名称子串匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={String(draftQuery.stock_status ?? NONE_SELECT_VALUE)}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, stock_status: value as SeedLotQuery["stock_status"] }))}
>
<SelectTrigger><SelectValue placeholder="全部状态" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{stockStatusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, locationOptions, programOptions]);
const renderFormExtra = useCallback((props: {
formData: Record<string, unknown>;
updateForm: (key: string, value: string) => void;
@@ -104,6 +214,7 @@ export function SeedLotTab() {
editingRow: Record<string, unknown> | null;
}) => {
const isEditing = Boolean(props.editingRow);
const seedLotId = String(props.editingRow?.id ?? "");
const mixtureRows = Array.isArray(props.formData.content_mixture)
? props.formData.content_mixture as ContentMixtureRow[]
: mapContentMixtureToForm([]);
@@ -123,19 +234,13 @@ export function SeedLotTab() {
{isEditing ? (
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200"> ID</Label>
<Input
value={String(props.formData.id ?? "—")}
readOnly
className="bg-slate-50 dark:bg-slate-900"
/>
<Input value={seedLotId || "—"} readOnly className="bg-slate-50 dark:bg-slate-900" />
</div>
) : null}
{!isEditing ? (
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200">
</Label>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200"></Label>
<Select
value={String(props.formData.primary_germplasm_id ?? NONE_SELECT_VALUE)}
onValueChange={handlePrimaryGermplasmChange}
@@ -148,7 +253,7 @@ export function SeedLotTab() {
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500"> 100% </p>
<p className="mt-1 text-xs text-slate-500"> 100% </p>
</div>
) : null}
@@ -157,11 +262,7 @@ export function SeedLotTab() {
{isEditing ? "当前库存(只读)" : "初始库存数量"}
</Label>
{isEditing ? (
<Input
value={String(props.formData.amount ?? "0")}
readOnly
className="bg-slate-50 dark:bg-slate-900"
/>
<Input value={String(props.formData.amount ?? "0")} readOnly className="bg-slate-50 dark:bg-slate-900" />
) : (
<Input
type="number"
@@ -172,27 +273,38 @@ export function SeedLotTab() {
/>
)}
<p className="mt-1 text-xs text-slate-500">
{isEditing ? "库存数量请通过「库存交易」Tab 的入库/出库/转移等动作更新。" : "也可创建后在「库存交易」中执行入库。"}
{isEditing ? "库存数量请通过「库存交易」更新。" : "也可创建后在「库存交易」中执行入库。"}
</p>
</div>
{isEditing ? (
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200"></Label>
<Input
value={String(props.formData.last_updated ?? "—")}
readOnly
className="bg-slate-50 dark:bg-slate-900"
/>
<Input value={String(props.formData.last_updated ?? "—")} readOnly className="bg-slate-50 dark:bg-slate-900" />
</div>
) : null}
<ContentMixtureEditor
rows={mixtureRows}
germplasmOptions={germplasmOptions}
crossOptions={crossOptions}
onChange={(rows) => props.updateFormBatch({ content_mixture: rows })}
/>
{isEditing && seedLotId ? (
<div className="md:col-span-2 rounded-lg border border-lime-100 bg-lime-50/60 p-3 text-sm text-lime-900 dark:border-lime-900/40 dark:bg-lime-950/30 dark:text-lime-100">
{" "}
<Link
href={`/germplasm/seed-lot/${encodeURIComponent(seedLotId)}?tab=mixture`}
className="font-medium underline"
>
Content Mixture
</Link>
{" "}
Tab
</div>
) : (
<ContentMixtureEditor
rows={mixtureRows}
germplasmOptions={germplasmOptions}
crossOptions={crossOptions}
onChange={(rows) => props.updateFormBatch({ content_mixture: rows })}
/>
)}
</>
);
}, [crossOptions, germplasmOptions]);
@@ -202,14 +314,31 @@ export function SeedLotTab() {
icon={Package}
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
title="SeedLot 种子批次"
description="维护种子或材料库存批次,记录存放地点、项目归属与批次组成;库存数量通过交易更新"
description="维护种子或材料库存批次;组成在详情页维护,库存数量通过交易更新"
addLabel="新增批次"
useEnhancedDialog
fetchRecord={fetchRecord}
renderQueryForm={() => renderQueryForm()}
renderFormExtra={renderFormExtra}
columns={[
{ key: "id", label: "批次 ID" },
{ key: "name", label: "批次名称" },
{
key: "name",
label: "批次名称",
render: (value, row) => {
const id = String(row.id ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/seed-lot/${encodeURIComponent(id)}`}
className="font-medium text-lime-600 hover:underline dark:text-lime-400"
>
{name}
</Link>
);
},
},
{
key: "stock_status",
label: "库存状态",
@@ -230,11 +359,26 @@ export function SeedLotTab() {
render: (_, row) => {
const mixtures = (row.content_mixture ?? row.contentMixture) as Array<Record<string, unknown>> | undefined;
if (!mixtures?.length) return "—";
return mixtures.map((mixture) => {
return mixtures.map((mixture, index) => {
const germplasmId = String(mixture.germplasmDbId ?? mixture.germplasm_id ?? "");
const label = mixture.germplasmName || mixture.germplasm_name || mixture.crossName || mixture.cross_name || "未知";
const percentage = mixture.mixturePercentage ?? mixture.mixture_percentage;
return `${label}${percentage != null ? ` (${percentage}%)` : ""}`;
}).join("");
const text = `${label}${percentage != null ? ` (${percentage}%)` : ""}`;
if (germplasmId && germplasmId !== NONE_SELECT_VALUE) {
return (
<span key={`${germplasmId}-${index}`}>
{index > 0 ? "" : ""}
<Link
href={`/germplasm/germplasm/${encodeURIComponent(germplasmId)}?tab=seed-lots`}
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
>
{text}
</Link>
</span>
);
}
return `${index > 0 ? "" : ""}${text}`;
});
},
},
{ key: "program_name", label: "项目" },
@@ -257,13 +401,7 @@ export function SeedLotTab() {
};
return createSeedLotRow(normalized) as unknown as Promise<Record<string, unknown>>;
}}
updateRecord={async (id, payload) => {
const normalized = {
...payload,
content_mixture: payload.content_mixture ?? mapContentMixtureToForm([]),
};
return updateSeedLotRow(id, normalized) as unknown as Promise<Record<string, unknown>>;
}}
updateRecord={async (id, payload) => updateSeedLotMetadata(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteSeedLotRow}
/>
);

View File

@@ -0,0 +1,376 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeftRight, Plus, RotateCcw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
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 { Badge } from "@/components/ui/badge";
import {
fetchSeedLotOptions,
fetchSeedLotRows,
fetchSeedLotTransactions,
inferTransactionAction,
} from "../api";
import { TransactionActionDialog } from "./TransactionActionDialog";
import {
NONE_SELECT_VALUE,
TRANSACTION_ACTION_LABEL,
type SeedLotRecord,
type SeedLotTransactionQuery,
type SeedLotTransactionRecord,
type SelectOption,
type TransactionActionType,
} from "../types";
function actionBadge(action: TransactionActionType | "unknown") {
if (action === "unknown") {
return <Badge variant="outline"></Badge>;
}
const className = action === "in"
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200"
: action === "out" || action === "consume"
? "bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-200"
: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200";
return <Badge variant="outline" className={className}>{TRANSACTION_ACTION_LABEL[action]}</Badge>;
}
const emptyQuery = (): SeedLotTransactionQuery => ({
seed_lot_id: NONE_SELECT_VALUE,
program_id: NONE_SELECT_VALUE,
location_id: NONE_SELECT_VALUE,
date_from: "",
date_to: "",
keyword: "",
});
interface SeedLotTransactionPanelProps {
seedLotDbId?: string;
embedded?: boolean;
onChanged?: () => void;
}
export function SeedLotTransactionPanel({
seedLotDbId,
embedded = false,
onChanged,
}: SeedLotTransactionPanelProps) {
const [rows, setRows] = useState<SeedLotTransactionRecord[]>([]);
const [seedLots, setSeedLots] = useState<SeedLotRecord[]>([]);
const [seedLotOptions, setSeedLotOptions] = useState<SelectOption[]>([]);
const [programOptions, setProgramOptions] = useState<SelectOption[]>([]);
const [locationOptions, setLocationOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draftQuery, setDraftQuery] = useState<SeedLotTransactionQuery>(() => ({
...emptyQuery(),
seed_lot_id: seedLotDbId ?? NONE_SELECT_VALUE,
}));
const [appliedQuery, setAppliedQuery] = useState<SeedLotTransactionQuery>(() => ({
...emptyQuery(),
seed_lot_id: seedLotDbId ?? NONE_SELECT_VALUE,
}));
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [dialogOpen, setDialogOpen] = useState(false);
const seedLotMap = useMemo(
() => new Map(seedLots.map((row) => [row.id, row])),
[seedLots],
);
const loadData = useCallback(async (force = false) => {
setLoading(true);
setError(null);
try {
const [options, lots, transactions] = await Promise.all([
fetchSeedLotOptions(force),
fetchSeedLotRows(undefined, force),
fetchSeedLotTransactions({
seed_lot_id: optionalQueryValue(appliedQuery.seed_lot_id),
program_id: optionalQueryValue(appliedQuery.program_id),
location_id: optionalQueryValue(appliedQuery.location_id),
date_from: appliedQuery.date_from || undefined,
date_to: appliedQuery.date_to || undefined,
keyword: appliedQuery.keyword || undefined,
}),
]);
setSeedLotOptions(options.seedLots);
setProgramOptions(options.programs);
setLocationOptions(options.locations);
setSeedLots(lots);
setRows(transactions);
} catch (event) {
setError(event instanceof Error ? event.message : "交易记录加载失败");
} finally {
setLoading(false);
}
}, [appliedQuery]);
useEffect(() => {
let mounted = true;
loadData().catch(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadData]);
useEffect(() => {
if (!seedLotDbId) return;
const locked = { ...emptyQuery(), seed_lot_id: seedLotDbId };
setDraftQuery(locked);
setAppliedQuery(locked);
}, [seedLotDbId]);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const pagedRows = rows.slice((page - 1) * pageSize, page * pageSize);
useEffect(() => {
setPage(1);
}, [appliedQuery, pageSize]);
const handleSuccess = () => {
void loadData(true);
onChanged?.();
};
return (
<div className="flex min-h-full flex-col">
{!embedded ? (
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 p-2.5">
<ArrowLeftRight className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50"></h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
/ seed_lot_transaction
</p>
</div>
</div>
<Button className="shrink-0 gap-2" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
) : (
<div className="mb-4 flex items-center justify-between gap-3">
<p className="text-sm text-slate-500 dark:text-slate-400">
/
</p>
<Button size="sm" className="gap-2 shrink-0" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
{!embedded ? (
<div className="mb-4 flex flex-wrap gap-2">
<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">
GET /brapi/v2/seedlots/transactions
</span>
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-700 dark:bg-blue-400/10 dark:text-blue-200">
POST /brapi/v2/seedlots/transactions
</span>
</div>
) : null}
<div className="mb-4 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{!seedLotDbId ? (
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.seed_lot_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, seed_lot_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部批次" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{seedLotOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Program</Label>
<Select
value={draftQuery.program_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, program_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部 Program" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}> Program</SelectItem>
{programOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Location</Label>
<Select
value={draftQuery.location_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, location_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部地点" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{locationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
type="date"
value={draftQuery.date_from ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, date_from: event.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
type="date"
value={draftQuery.date_to ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, date_to: event.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.keyword ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, keyword: event.target.value }))}
placeholder="交易 ID、说明、批次..."
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = { ...emptyQuery(), seed_lot_id: seedLotDbId ?? NONE_SELECT_VALUE };
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{error ? (
<div className="mb-4 rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900">
<TableHead className="w-10 text-xs font-medium text-slate-400">#</TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 6 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell><Skeleton className="h-4 w-5" /></TableCell>
{Array.from({ length: 6 }).map((__, cellIndex) => (
<TableCell key={cellIndex}><Skeleton className="h-4 w-24" /></TableCell>
))}
</TableRow>
))
) : pagedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center text-sm text-slate-400">
</TableCell>
</TableRow>
) : (
pagedRows.map((row, index) => {
const action = inferTransactionAction(row);
return (
<TableRow key={row.id || index}>
<TableCell className="text-xs text-slate-400">{(page - 1) * pageSize + index + 1}</TableCell>
<TableCell>{actionBadge(action)}</TableCell>
<TableCell className="text-sm">{row.from_seed_lot_name || row.from_seed_lot_id || "—"}</TableCell>
<TableCell className="text-sm">{row.to_seed_lot_name || row.to_seed_lot_id || "—"}</TableCell>
<TableCell className="text-sm">{row.amount ?? "—"}{row.units ? ` ${row.units}` : ""}</TableCell>
<TableCell className="text-sm">{row.timestamp ? String(row.timestamp).slice(0, 19).replace("T", " ") : "—"}</TableCell>
<TableCell className="max-w-[20rem] whitespace-normal break-words text-sm">{row.description || "—"}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-400"> {rows.length} {page}/{totalPages} </p>
<div className="flex items-center gap-3">
<Select value={String(pageSize)} onValueChange={(value) => setPageSize(Number(value))}>
<SelectTrigger className="h-8 w-[110px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="10">10 / </SelectItem>
<SelectItem value="20">20 / </SelectItem>
<SelectItem value="50">50 / </SelectItem>
</SelectContent>
</Select>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={page <= 1} />
</PaginationItem>
{Array.from({ length: totalPages }).slice(0, 5).map((_, idx) => {
const pageNumber = idx + 1;
return (
<PaginationItem key={pageNumber}>
<PaginationLink isActive={pageNumber === page} onClick={() => setPage(pageNumber)}>{pageNumber}</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page >= totalPages} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
<TransactionActionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
seedLotOptions={seedLotOptions}
seedLotMap={seedLotMap}
defaultSeedLotId={seedLotDbId ?? (appliedQuery.seed_lot_id !== NONE_SELECT_VALUE ? appliedQuery.seed_lot_id : undefined)}
onSuccess={handleSuccess}
/>
</div>
);
}
function optionalQueryValue(value: string | undefined) {
if (!value || value === NONE_SELECT_VALUE) return undefined;
return value;
}

View File

@@ -1,250 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeftRight, Plus, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
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 { Badge } from "@/components/ui/badge";
import { fetchSeedLotOptions, fetchSeedLotRows, fetchSeedLotTransactions, inferTransactionAction } from "../api";
import { TransactionActionDialog } from "./TransactionActionDialog";
import {
NONE_SELECT_VALUE,
TRANSACTION_ACTION_LABEL,
type SeedLotRecord,
type SeedLotTransactionRecord,
type SelectOption,
type TransactionActionType,
} from "../types";
function actionBadge(action: TransactionActionType | "unknown") {
if (action === "unknown") {
return <Badge variant="outline"></Badge>;
}
const className = action === "in"
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200"
: action === "out" || action === "consume"
? "bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-200"
: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200";
return <Badge variant="outline" className={className}>{TRANSACTION_ACTION_LABEL[action]}</Badge>;
}
import { SeedLotTransactionPanel } from "./SeedLotTransactionPanel";
export function SeedLotTransactionTab() {
const [rows, setRows] = useState<SeedLotTransactionRecord[]>([]);
const [seedLots, setSeedLots] = useState<SeedLotRecord[]>([]);
const [seedLotOptions, setSeedLotOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filterSeedLotId, setFilterSeedLotId] = useState(NONE_SELECT_VALUE);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [dialogOpen, setDialogOpen] = useState(false);
const seedLotMap = useMemo(
() => new Map(seedLots.map((row) => [row.id, row])),
[seedLots],
);
const loadData = useCallback(async (force = false) => {
setLoading(true);
setError(null);
try {
const [options, lots, transactions] = await Promise.all([
fetchSeedLotOptions(force),
fetchSeedLotRows(force),
fetchSeedLotTransactions(filterSeedLotId !== NONE_SELECT_VALUE ? filterSeedLotId : undefined),
]);
setSeedLotOptions(options.seedLots);
setSeedLots(lots);
setRows(transactions);
} catch (event) {
setError(event instanceof Error ? event.message : "交易记录加载失败");
} finally {
setLoading(false);
}
}, [filterSeedLotId]);
useEffect(() => {
let mounted = true;
loadData().catch(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, [loadData]);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) => [
row.id,
row.description,
row.from_seed_lot_name,
row.to_seed_lot_name,
row.from_seed_lot_id,
row.to_seed_lot_id,
].some((value) => String(value ?? "").toLowerCase().includes(keyword)));
}, [rows, search]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const pagedRows = filteredRows.slice((page - 1) * pageSize, page * pageSize);
useEffect(() => {
setPage(1);
}, [search, filterSeedLotId, pageSize]);
return (
<div className="flex min-h-full flex-col">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 p-2.5">
<ArrowLeftRight className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50"></h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
/ seed_lot_transaction
</p>
</div>
</div>
<Button className="shrink-0 gap-2" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<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">
GET /brapi/v2/seedlots/transactions
</span>
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-700 dark:bg-blue-400/10 dark:text-blue-200">
POST /brapi/v2/seedlots/transactions
</span>
</div>
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-[240px_1fr]">
<Select value={filterSeedLotId} onValueChange={setFilterSeedLotId}>
<SelectTrigger><SelectValue placeholder="按批次筛选" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{seedLotOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="搜索交易 ID、说明、来源/目标批次..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="bg-white pl-9 dark:bg-slate-950"
/>
</div>
</div>
{error ? (
<div className="mb-4 rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900">
<TableHead className="w-10 text-xs font-medium text-slate-400">#</TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 6 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell><Skeleton className="h-4 w-5" /></TableCell>
{Array.from({ length: 6 }).map((__, cellIndex) => (
<TableCell key={cellIndex}><Skeleton className="h-4 w-24" /></TableCell>
))}
</TableRow>
))
) : pagedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center text-sm text-slate-400">
</TableCell>
</TableRow>
) : (
pagedRows.map((row, index) => {
const action = inferTransactionAction(row);
return (
<TableRow key={row.id || index}>
<TableCell className="text-xs text-slate-400">{(page - 1) * pageSize + index + 1}</TableCell>
<TableCell>{actionBadge(action)}</TableCell>
<TableCell className="text-sm">{row.from_seed_lot_name || row.from_seed_lot_id || "—"}</TableCell>
<TableCell className="text-sm">{row.to_seed_lot_name || row.to_seed_lot_id || "—"}</TableCell>
<TableCell className="text-sm">{row.amount ?? "—"}{row.units ? ` ${row.units}` : ""}</TableCell>
<TableCell className="text-sm">{row.timestamp ? String(row.timestamp).slice(0, 19).replace("T", " ") : "—"}</TableCell>
<TableCell className="max-w-[20rem] whitespace-normal break-words text-sm">{row.description || "—"}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-400"> {filteredRows.length} {page}/{totalPages} </p>
<div className="flex items-center gap-3">
<Select value={String(pageSize)} onValueChange={(value) => setPageSize(Number(value))}>
<SelectTrigger className="h-8 w-[110px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="10">10 / </SelectItem>
<SelectItem value="20">20 / </SelectItem>
<SelectItem value="50">50 / </SelectItem>
</SelectContent>
</Select>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={page <= 1} />
</PaginationItem>
{Array.from({ length: totalPages }).slice(0, 5).map((_, idx) => {
const pageNumber = idx + 1;
return (
<PaginationItem key={pageNumber}>
<PaginationLink isActive={pageNumber === page} onClick={() => setPage(pageNumber)}>{pageNumber}</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page >= totalPages} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
<TransactionActionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
seedLotOptions={seedLotOptions}
seedLotMap={seedLotMap}
defaultSeedLotId={filterSeedLotId !== NONE_SELECT_VALUE ? filterSeedLotId : undefined}
onSuccess={() => {
void loadData(true);
}}
/>
</div>
);
return <SeedLotTransactionPanel />;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Dialog as EnhancedDialog,
DialogBody,
@@ -61,6 +61,7 @@ export function TransactionActionDialog({
const [timestamp, setTimestamp] = useState(new Date().toISOString());
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const savingRef = useRef(false);
const resetForm = useCallback(() => {
setAction("in");
@@ -94,6 +95,8 @@ export function TransactionActionDialog({
const requiresDescription = action === "out" || action === "consume";
const handleSubmit = async () => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
@@ -114,6 +117,7 @@ export function TransactionActionDialog({
} catch (event) {
setError(event instanceof Error ? event.message : "创建交易失败");
} finally {
savingRef.current = false;
setSaving(false);
}
};

View File

@@ -1,14 +1,23 @@
"use client";
import { useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { ArrowLeftRight, Package } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SeedLotTab } from "./components/SeedLotTab";
import { SeedLotTransactionTab } from "./components/SeedLotTransactionTab";
export default function SeedLotPage() {
function SeedLotPageContent() {
const searchParams = useSearchParams();
const [tab, setTab] = useState("lots");
useEffect(() => {
const nextTab = searchParams.get("tab");
if (nextTab === "lots" || nextTab === "transactions") {
setTab(nextTab);
}
}, [searchParams]);
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">
@@ -36,3 +45,11 @@ export default function SeedLotPage() {
</Tabs>
);
}
export default function SeedLotPage() {
return (
<Suspense fallback={<div className="p-6 text-sm text-slate-500"> Seed Lot </div>}>
<SeedLotPageContent />
</Suspense>
);
}

View File

@@ -80,6 +80,24 @@ export interface SeedLotTransactionRecord {
export type StockStatus = "depleted" | "low" | "sufficient";
export interface SeedLotQuery {
program_id?: string;
location_id?: string;
name?: string;
stock_status?: StockStatus | typeof NONE_SELECT_VALUE;
}
export interface SeedLotTransactionQuery {
seed_lot_id?: string;
program_id?: string;
location_id?: string;
date_from?: string;
date_to?: string;
keyword?: string;
}
export type SeedLotDetailTab = "mixture" | "transactions";
export function resolveStockStatus(amount: number | null | undefined): StockStatus {
const value = Number(amount ?? 0);
if (value <= 0) return "depleted";

View File

@@ -0,0 +1,115 @@
package org.brapi.test.BrAPITestServer.controller.germ;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.brapi.test.BrAPITestServer.controller.core.BrAPIController;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.germ.GermplasmInstituteRecord;
import org.brapi.test.BrAPITestServer.model.dto.germ.GermplasmInstituteWriteRequest;
import org.brapi.test.BrAPITestServer.service.germ.GermplasmService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
@RestController
public class GermplasmProfileWriteController extends BrAPIController {
private static final Logger log = LoggerFactory.getLogger(GermplasmProfileWriteController.class);
private final GermplasmService germplasmService;
private final HttpServletRequest request;
@Autowired
public GermplasmProfileWriteController(GermplasmService germplasmService, HttpServletRequest request) {
this.germplasmService = germplasmService;
this.request = request;
}
@CrossOrigin
@RequestMapping(value = "/germplasm/{germplasmDbId}/institutes", produces = {
"application/json" }, method = RequestMethod.GET)
public ResponseEntity<Map<String, Object>> germplasmGermplasmDbIdInstitutesGet(
@PathVariable("germplasmDbId") String germplasmDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_ANONYMOUS", "ROLE_USER");
validateAcceptHeader(request);
List<GermplasmInstituteRecord> data = germplasmService.findInstitutes(germplasmDbId);
Map<String, Object> result = new HashMap<>();
result.put("data", data);
return ResponseEntity.ok(wrapOk(result));
}
@CrossOrigin
@RequestMapping(value = "/germplasm/{germplasmDbId}/institutes", produces = {
"application/json" }, consumes = { "application/json" }, method = RequestMethod.POST)
public ResponseEntity<Map<String, Object>> germplasmGermplasmDbIdInstitutesPost(
@PathVariable("germplasmDbId") String germplasmDbId,
@RequestBody GermplasmInstituteWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
GermplasmInstituteRecord data = germplasmService.saveInstitute(germplasmDbId, body);
Map<String, Object> result = new HashMap<>();
result.put("data", List.of(data));
return ResponseEntity.ok(wrapOk(result));
}
@CrossOrigin
@RequestMapping(value = "/germplasm/{germplasmDbId}/institutes/{instituteDbId}", produces = {
"application/json" }, consumes = { "application/json" }, method = RequestMethod.PUT)
public ResponseEntity<Map<String, Object>> germplasmGermplasmDbIdInstitutesInstituteDbIdPut(
@PathVariable("germplasmDbId") String germplasmDbId,
@PathVariable("instituteDbId") String instituteDbId,
@RequestBody GermplasmInstituteWriteRequest body,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
GermplasmInstituteRecord data = germplasmService.updateInstitute(germplasmDbId, instituteDbId, body);
Map<String, Object> result = new HashMap<>();
result.put("data", List.of(data));
return ResponseEntity.ok(wrapOk(result));
}
@CrossOrigin
@RequestMapping(value = "/germplasm/{germplasmDbId}/institutes/{instituteDbId}", produces = {
"application/json" }, method = RequestMethod.DELETE)
public ResponseEntity<Map<String, Object>> germplasmGermplasmDbIdInstitutesInstituteDbIdDelete(
@PathVariable("germplasmDbId") String germplasmDbId,
@PathVariable("instituteDbId") String instituteDbId,
@RequestHeader(value = "Authorization", required = false) String authorization)
throws BrAPIServerException {
log.debug("Request: " + request.getRequestURI());
validateSecurityContext(request, "ROLE_USER");
validateAcceptHeader(request);
GermplasmInstituteRecord data = germplasmService.deleteInstitute(germplasmDbId, instituteDbId);
Map<String, Object> result = new HashMap<>();
result.put("data", List.of(data));
return ResponseEntity.ok(wrapOk(result));
}
private Map<String, Object> wrapOk(Map<String, Object> result) {
Map<String, Object> response = new HashMap<>();
response.put("metadata", generateEmptyMetadata());
response.put("result", result);
return response;
}
}

View File

@@ -0,0 +1,51 @@
package org.brapi.test.BrAPITestServer.model.dto.germ;
public class GermplasmInstituteRecord {
private String instituteDbId;
private String instituteType;
private String instituteCode;
private String instituteName;
private String instituteAddress;
public String getInstituteDbId() {
return instituteDbId;
}
public void setInstituteDbId(String instituteDbId) {
this.instituteDbId = instituteDbId;
}
public String getInstituteType() {
return instituteType;
}
public void setInstituteType(String instituteType) {
this.instituteType = instituteType;
}
public String getInstituteCode() {
return instituteCode;
}
public void setInstituteCode(String instituteCode) {
this.instituteCode = instituteCode;
}
public String getInstituteName() {
return instituteName;
}
public void setInstituteName(String instituteName) {
this.instituteName = instituteName;
}
public String getInstituteAddress() {
return instituteAddress;
}
public void setInstituteAddress(String instituteAddress) {
this.instituteAddress = instituteAddress;
}
}

View File

@@ -0,0 +1,51 @@
package org.brapi.test.BrAPITestServer.model.dto.germ;
public class GermplasmInstituteWriteRequest {
private String instituteDbId;
private String instituteType;
private String instituteCode;
private String instituteName;
private String instituteAddress;
public String getInstituteDbId() {
return instituteDbId;
}
public void setInstituteDbId(String instituteDbId) {
this.instituteDbId = instituteDbId;
}
public String getInstituteType() {
return instituteType;
}
public void setInstituteType(String instituteType) {
this.instituteType = instituteType;
}
public String getInstituteCode() {
return instituteCode;
}
public void setInstituteCode(String instituteCode) {
this.instituteCode = instituteCode;
}
public String getInstituteName() {
return instituteName;
}
public void setInstituteName(String instituteName) {
this.instituteName = instituteName;
}
public String getInstituteAddress() {
return instituteAddress;
}
public void setInstituteAddress(String instituteAddress) {
this.instituteAddress = instituteAddress;
}
}

View File

@@ -0,0 +1,8 @@
package org.brapi.test.BrAPITestServer.repository.germ;
import org.brapi.test.BrAPITestServer.model.entity.germ.GermplasmInstituteEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GermplasmInstituteRepository extends JpaRepository<GermplasmInstituteEntity, String> {
}

View File

@@ -11,6 +11,8 @@ import jakarta.validation.Valid;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException;
import org.brapi.test.BrAPITestServer.model.dto.germ.GermplasmInstituteRecord;
import org.brapi.test.BrAPITestServer.model.dto.germ.GermplasmInstituteWriteRequest;
import org.brapi.test.BrAPITestServer.model.entity.core.CropEntity;
import org.brapi.test.BrAPITestServer.model.entity.germ.BreedingMethodEntity;
import org.brapi.test.BrAPITestServer.model.entity.germ.DonorEntity;
@@ -22,6 +24,7 @@ import org.brapi.test.BrAPITestServer.model.entity.germ.GermplasmSynonymEntity;
import org.brapi.test.BrAPITestServer.model.entity.germ.PedigreeNodeEntity;
import org.brapi.test.BrAPITestServer.model.entity.pheno.TaxonEntity;
import org.brapi.test.BrAPITestServer.repository.germ.GermplasmDonorRepository;
import org.brapi.test.BrAPITestServer.repository.germ.GermplasmInstituteRepository;
import org.brapi.test.BrAPITestServer.repository.germ.GermplasmRepository;
import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.brapi.test.BrAPITestServer.service.GeoJSONUtility;
@@ -61,14 +64,17 @@ public class GermplasmService {
private final GermplasmRepository germplasmRepository;
private final GermplasmDonorRepository donorRepository;
private final GermplasmInstituteRepository instituteRepository;
private final BreedingMethodService breedingMethodService;
private final CropService cropService;
@Autowired
public GermplasmService(GermplasmRepository germplasmRepository, GermplasmDonorRepository donorRepository,
BreedingMethodService breedingMethodService, CropService cropService) {
GermplasmInstituteRepository instituteRepository, BreedingMethodService breedingMethodService,
CropService cropService) {
this.germplasmRepository = germplasmRepository;
this.donorRepository = donorRepository;
this.instituteRepository = instituteRepository;
this.breedingMethodService = breedingMethodService;
this.cropService = cropService;
@@ -630,4 +636,119 @@ public class GermplasmService {
return dupInstitutes;
}
public List<GermplasmInstituteRecord> findInstitutes(String germplasmDbId) throws BrAPIServerException {
GermplasmEntity entity = getGermplasmEntity(germplasmDbId, HttpStatus.NOT_FOUND);
if (entity.getInstitutes() == null) {
return new ArrayList<>();
}
return entity.getInstitutes().stream().map(this::convertInstituteRecord).collect(Collectors.toList());
}
public GermplasmInstituteRecord saveInstitute(String germplasmDbId, GermplasmInstituteWriteRequest request)
throws BrAPIServerException {
GermplasmEntity germplasm = getGermplasmEntity(germplasmDbId, HttpStatus.NOT_FOUND);
if (request.getInstituteType() == null || request.getInstituteType().isBlank()) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "instituteType is required");
}
if (request.getInstituteDbId() != null
&& instituteRepository.findById(request.getInstituteDbId()).isPresent()) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"Institute already exists: " + request.getInstituteDbId());
}
GermplasmInstituteEntity entity = new GermplasmInstituteEntity();
if (request.getInstituteDbId() != null && !request.getInstituteDbId().isBlank()) {
entity.setId(request.getInstituteDbId().trim());
}
applyInstituteRequest(entity, germplasm, request);
assertInstituteUnique(germplasm, entity.getInstituteType(), entity.getInstituteCode(), null);
return convertInstituteRecord(instituteRepository.save(entity));
}
public GermplasmInstituteRecord updateInstitute(String germplasmDbId, String instituteDbId,
GermplasmInstituteWriteRequest request) throws BrAPIServerException {
GermplasmEntity germplasm = getGermplasmEntity(germplasmDbId, HttpStatus.NOT_FOUND);
GermplasmInstituteEntity entity = getInstituteEntity(instituteDbId);
if (entity.getGermplasm() == null || !germplasmDbId.equals(entity.getGermplasm().getId())) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "Institute does not belong to this germplasm");
}
applyInstituteRequest(entity, germplasm, request);
assertInstituteUnique(germplasm, entity.getInstituteType(), entity.getInstituteCode(), entity.getId());
return convertInstituteRecord(instituteRepository.save(entity));
}
public GermplasmInstituteRecord deleteInstitute(String germplasmDbId, String instituteDbId)
throws BrAPIServerException {
GermplasmEntity germplasm = getGermplasmEntity(germplasmDbId, HttpStatus.NOT_FOUND);
GermplasmInstituteEntity entity = getInstituteEntity(instituteDbId);
if (entity.getGermplasm() == null || !germplasmDbId.equals(entity.getGermplasm().getId())) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "Institute does not belong to this germplasm");
}
GermplasmInstituteRecord deleted = convertInstituteRecord(entity);
instituteRepository.delete(entity);
instituteRepository.flush();
if (germplasm.getInstitutes() != null) {
germplasm.getInstitutes().removeIf(item -> instituteDbId.equals(item.getId()));
}
return deleted;
}
private GermplasmInstituteEntity getInstituteEntity(String instituteDbId) throws BrAPIServerException {
return instituteRepository.findById(instituteDbId).orElseThrow(
() -> new BrAPIServerDbIdNotFoundException("institute", instituteDbId, HttpStatus.NOT_FOUND));
}
private void applyInstituteRequest(GermplasmInstituteEntity entity, GermplasmEntity germplasm,
GermplasmInstituteWriteRequest request) throws BrAPIServerException {
entity.setGermplasm(germplasm);
entity.setInstituteType(parseInstituteType(request.getInstituteType()));
entity.setInstituteCode(trimToNull(request.getInstituteCode()));
entity.setInstituteName(trimToNull(request.getInstituteName()));
entity.setInstituteAddress(trimToNull(request.getInstituteAddress()));
}
private InstituteTypeEnum parseInstituteType(String value) throws BrAPIServerException {
try {
return InstituteTypeEnum.valueOf(value.trim().toUpperCase());
} catch (Exception e) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST,
"instituteType must be one of HOST, DONOR, BREEDING, COLLECTING, REDUNDANT");
}
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private void assertInstituteUnique(GermplasmEntity germplasm, InstituteTypeEnum type, String code, String excludeId)
throws BrAPIServerException {
if (germplasm.getInstitutes() == null || code == null) {
return;
}
for (GermplasmInstituteEntity existing : germplasm.getInstitutes()) {
if (excludeId != null && excludeId.equals(existing.getId())) {
continue;
}
if (type.equals(existing.getInstituteType()) && code.equals(existing.getInstituteCode())) {
throw new BrAPIServerException(HttpStatus.CONFLICT,
"institute with same type and code already exists on this germplasm");
}
}
}
private GermplasmInstituteRecord convertInstituteRecord(GermplasmInstituteEntity entity) {
GermplasmInstituteRecord record = new GermplasmInstituteRecord();
record.setInstituteDbId(entity.getId());
if (entity.getInstituteType() != null) {
record.setInstituteType(entity.getInstituteType().name());
}
record.setInstituteCode(entity.getInstituteCode());
record.setInstituteName(entity.getInstituteName());
record.setInstituteAddress(entity.getInstituteAddress());
return record;
}
}

View File

@@ -1,7 +1,9 @@
package org.brapi.test.BrAPITestServer.service.germ;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -178,13 +180,56 @@ public class SeedLotService {
for (SeedLotNewTransactionRequest list : body) {
SeedLotTransactionEntity entity = new SeedLotTransactionEntity();
updateEntity(entity, list);
validateTransactionEntity(entity);
SeedLotTransactionEntity savedEntity = seedLotTransactionRepository.save(entity);
applyTransactionInventory(savedEntity);
savedValues.add(convertFromEntity(savedEntity));
}
return savedValues;
}
private void validateTransactionEntity(SeedLotTransactionEntity entity) throws BrAPIServerException {
SeedLotEntity fromSeedLot = entity.getFromSeedLot();
SeedLotEntity toSeedLot = entity.getToSeedLot();
if (fromSeedLot == null && toSeedLot == null) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST,
"from_seed_lot_id and to_seed_lot_id cannot both be empty");
}
if (fromSeedLot != null && toSeedLot != null && fromSeedLot.getId().equals(toSeedLot.getId())) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "from_seed_lot_id cannot equal to_seed_lot_id");
}
if (entity.getAmount() == null || entity.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "amount must be greater than 0");
}
if (fromSeedLot != null) {
BigDecimal currentAmount = fromSeedLot.getAmount() != null ? fromSeedLot.getAmount() : BigDecimal.ZERO;
if (entity.getAmount().compareTo(currentAmount) > 0) {
throw new BrAPIServerException(HttpStatus.BAD_REQUEST,
"Insufficient stock in source seed lot: " + fromSeedLot.getId());
}
}
}
private void applyTransactionInventory(SeedLotTransactionEntity entity) {
Date updatedAt = entity.getTimestamp() != null ? entity.getTimestamp() : new Date();
BigDecimal delta = entity.getAmount();
if (entity.getFromSeedLot() != null) {
SeedLotEntity fromSeedLot = entity.getFromSeedLot();
BigDecimal currentAmount = fromSeedLot.getAmount() != null ? fromSeedLot.getAmount() : BigDecimal.ZERO;
fromSeedLot.setAmount(currentAmount.subtract(delta));
fromSeedLot.setLastUpdated(updatedAt);
seedLotRepository.save(fromSeedLot);
}
if (entity.getToSeedLot() != null) {
SeedLotEntity toSeedLot = entity.getToSeedLot();
BigDecimal currentAmount = toSeedLot.getAmount() != null ? toSeedLot.getAmount() : BigDecimal.ZERO;
toSeedLot.setAmount(currentAmount.add(delta));
toSeedLot.setLastUpdated(updatedAt);
seedLotRepository.save(toSeedLot);
}
}
private SeedLot convertFromEntity(SeedLotEntity entity) {
SeedLot seedLot = new SeedLot();
UpdateUtility.convertFromEntity(entity, seedLot);