fix:开发完毕
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -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 章」称呼,需按具体路径理解。
|
||||
- **第二、四章重叠表**:若第二章已实现,第四章对应文档可标注「与第二章共用实现」,无需重复开发。
|
||||
@@ -40,3 +40,5 @@
|
||||
|
||||
---
|
||||
|
||||
**状态:已完成**(页面:`germplasm/seed-lot` → 库存交易 Tab;`germplasm/seed-lot/[seedLotDbId]` → Transactions Tab;BrAPI `POST /seedlots/transactions`)
|
||||
|
||||
|
||||
@@ -35,3 +35,7 @@
|
||||
|
||||
1. `name` 必填。
|
||||
2. 已被 `germplasm` 引用时不允许物理删除,只允许停用或提示引用关系。
|
||||
|
||||
---
|
||||
|
||||
**状态:已完成**(与第二章 `02-germplasm-seed/01-breeding_method.md` 共用实现,页面:`germplasm/breeding-method`)
|
||||
|
||||
@@ -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]` 详情 Tab:Attributes / Donor / Institute / Origin / Synonym / Taxon / Pedigree / Seed Lots / Cross Parent)
|
||||
|
||||
@@ -37,3 +37,5 @@
|
||||
1. `germplasm_id` 必须存在。
|
||||
2. 同一 germplasm 下 donor accession + institute code 不建议重复。
|
||||
3. 删除 donor 记录不应删除 germplasm 主数据。
|
||||
|
||||
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Donor Tab;BrAPI `PUT /germplasm/{id}` donors 整表替换)
|
||||
|
||||
@@ -36,3 +36,5 @@
|
||||
1. `germplasm_id` 必须存在。
|
||||
2. 同一 germplasm 下同类型、同 code 的机构不建议重复。
|
||||
3. 删除 institute 记录不应删除 germplasm 主数据。
|
||||
|
||||
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Institute Tab;扩展接口 `/brapi/v2/germplasm/{id}/institutes` CRUD)
|
||||
|
||||
@@ -34,3 +34,5 @@
|
||||
1. `germplasm_id` 必须存在。
|
||||
2. 坐标格式需要合法。
|
||||
3. 删除 origin 记录不应删除 germplasm 主数据。
|
||||
|
||||
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Origin Tab;BrAPI `PUT /germplasm/{id}` germplasmOrigin 整表替换)
|
||||
|
||||
@@ -34,3 +34,5 @@
|
||||
1. `germplasm_id` 必须存在。
|
||||
2. 同一 germplasm 下同一个 synonym 不应重复。
|
||||
3. 删除 synonym 不应删除 germplasm 主数据。
|
||||
|
||||
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Synonym Tab;BrAPI `PUT /germplasm/{id}` synonyms 整表替换)
|
||||
|
||||
@@ -34,3 +34,5 @@
|
||||
1. `germplasm_id` 必须存在。
|
||||
2. 同一 source 下 taxon_id 不建议重复。
|
||||
3. 删除 taxon 前检查 sample 引用。
|
||||
|
||||
**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Taxon Tab;BrAPI `PUT /germplasm/{id}` taxonIds 整表替换)
|
||||
|
||||
@@ -53,3 +53,7 @@
|
||||
1. `name` 必填。
|
||||
2. 已被 `germplasm_attribute_value` 引用时不允许物理删除。
|
||||
3. `datatype` 要与 value 输入控件联动。
|
||||
|
||||
---
|
||||
|
||||
**状态:已完成**(与第二章 `02-germplasm-seed/03-germplasm_attribute_definition.md` 共用实现,页面:`germplasm` → Attributes Tab)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]`)
|
||||
|
||||
@@ -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]`;创建实际杂交时继承计划杂交亲本)
|
||||
|
||||
@@ -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 Tab;Cross/PlannedCross 详情页 Parents Tab)
|
||||
|
||||
@@ -35,3 +35,7 @@
|
||||
1. `cross_id` 必须存在。
|
||||
2. 同一 cross 下 `pollination_number` 不建议重复。
|
||||
3. 删除授粉事件不应删除 cross 主数据。
|
||||
|
||||
---
|
||||
|
||||
**状态:已完成**(页面:实际 Cross 详情 → Pollination Events Tab;BrAPI `PUT /crosses` pollinationEvents)
|
||||
|
||||
@@ -38,3 +38,7 @@
|
||||
1. 同一 germplasm 通常只应有一个 pedigree node。
|
||||
2. 删除 pedigree_node 前检查 `pedigree_edge` 中 this_node 和 connceted_node 引用。
|
||||
3. 导入 pedigree 时需要先创建所有节点,再创建边。
|
||||
|
||||
---
|
||||
|
||||
**状态:已完成**
|
||||
|
||||
@@ -41,3 +41,7 @@
|
||||
1. `this_node_id` 和 `connceted_node_id` 必须存在。
|
||||
2. 两个节点不能相同。
|
||||
3. 同一节点之间同一种 edge_type 不应重复。
|
||||
|
||||
---
|
||||
|
||||
**状态:已完成**
|
||||
|
||||
@@ -44,3 +44,5 @@
|
||||
1. `amount` 不允许为负。
|
||||
2. 普通用户不应直接编辑 amount,库存变化应通过 `seed_lot_transaction`。
|
||||
3. 删除 seed lot 前检查组成明细和交易引用。
|
||||
|
||||
**状态:已完成**(页面:`germplasm/seed-lot` 列表 CRUD + 查询;`germplasm/seed-lot/[seedLotDbId]` 详情摘要;库存 amount 编辑时只读,通过交易更新)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 / parent2;germplasm 与 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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/pedigree(parents 字段)
|
||||
</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">
|
||||
parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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/pedigree(parents 字段)
|
||||
</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">
|
||||
parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。
|
||||
</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 />;
|
||||
}
|
||||
|
||||
@@ -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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
删除杂交项目前请先清理下属 Cross、Cross 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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_taxon;Sample 可引用 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
402
frontend/src/app/(app)/germplasm/germplasm/profileApi.ts
Normal file
402
frontend/src/app/(app)/germplasm/germplasm/profileApi.ts
Normal 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;
|
||||
}
|
||||
53
frontend/src/app/(app)/germplasm/germplasm/profileTypes.ts
Normal file
53
frontend/src/app/(app)/germplasm/germplasm/profileTypes.ts
Normal 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";
|
||||
@@ -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;
|
||||
|
||||
167
frontend/src/app/(app)/germplasm/seed-lot/[seedLotDbId]/page.tsx
Normal file
167
frontend/src/app/(app)/germplasm/seed-lot/[seedLotDbId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user