From c5d4d7a7e1b599ab859676dd4e607596b59c2de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E5=B8=85?= <616120679@qq.com> Date: Thu, 28 May 2026 17:25:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=BC=80=E5=8F=91=E5=AE=8C=E6=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 14 +- .../12-seed_lot_transaction.md | 2 + docs/dev/04-germplasm/01-breeding_method.md | 4 + docs/dev/04-germplasm/02-germplasm.md | 2 + docs/dev/04-germplasm/03-germplasm_donor.md | 2 + .../04-germplasm/04-germplasm_institute.md | 2 + docs/dev/04-germplasm/05-germplasm_origin.md | 2 + docs/dev/04-germplasm/06-germplasm_synonym.md | 2 + docs/dev/04-germplasm/07-germplasm_taxon.md | 2 + .../08-germplasm_attribute_definition.md | 4 + .../09-germplasm_attribute_value.md | 4 + docs/dev/04-germplasm/10-crossing_project.md | 4 + docs/dev/04-germplasm/11-cross_entity.md | 4 + docs/dev/04-germplasm/12-cross_parent.md | 4 + .../13-cross_pollination_event.md | 4 + docs/dev/04-germplasm/14-pedigree_node.md | 4 + docs/dev/04-germplasm/15-pedigree_edge.md | 4 + docs/dev/04-germplasm/16-seed_lot.md | 2 + .../17-seed_lot_content_mixture.md | 2 + .../04-germplasm/18-seed_lot_transaction.md | 4 + .../genome-map/components/GenomeMapTab.tsx | 2 +- .../app/(app)/germplasm/cross-pedigree/api.ts | 170 +++++++- .../components/CrossEntityTab.tsx | 75 +++- .../components/CrossParentTab.tsx | 12 +- .../components/CrossParentsPanel.tsx | 231 ++++++++++ .../components/CrossPollinationEventPanel.tsx | 304 +++++++++++++ .../components/CrossingProjectTab.tsx | 91 +++- .../components/PedigreeEdgePanel.tsx | 399 +++++++++++++++++ .../components/PedigreeEdgeTab.tsx | 366 +--------------- .../components/PedigreeNodeFormPanel.tsx | 185 ++++++++ .../components/PedigreeNodeTab.tsx | 109 +---- .../crosses/[crossDbId]/page.tsx | 153 +++++++ .../(app)/germplasm/cross-pedigree/mappers.ts | 20 +- .../(app)/germplasm/cross-pedigree/page.tsx | 21 +- .../pedigree-nodes/[germplasmDbId]/page.tsx | 150 +++++++ .../[plannedCrossDbId]/page.tsx | 112 +++++ .../projects/[crossingProjectDbId]/page.tsx | 227 ++++++++++ .../(app)/germplasm/cross-pedigree/types.ts | 19 + .../germplasm/[germplasmDbId]/page.tsx | 203 +++++++++ .../src/app/(app)/germplasm/germplasm/api.ts | 58 ++- .../components/GermplasmAttributeValueTab.tsx | 77 ++-- .../GermplasmDetailRelatedPanels.tsx | 121 ++++++ .../components/GermplasmPedigreePanel.tsx | 61 +++ .../components/GermplasmProfilePanels.tsx | 252 +++++++++++ .../app/(app)/germplasm/germplasm/page.tsx | 119 +++++- .../(app)/germplasm/germplasm/profileApi.ts | 402 ++++++++++++++++++ .../(app)/germplasm/germplasm/profileTypes.ts | 53 +++ .../app/(app)/germplasm/germplasm/types.ts | 8 + .../germplasm/seed-lot/[seedLotDbId]/page.tsx | 167 ++++++++ .../src/app/(app)/germplasm/seed-lot/api.ts | 140 +++++- .../components/SeedLotContentMixturePanel.tsx | 149 +++++++ .../seed-lot/components/SeedLotTab.tsx | 222 ++++++++-- .../components/SeedLotTransactionPanel.tsx | 376 ++++++++++++++++ .../components/SeedLotTransactionTab.tsx | 247 +---------- .../components/TransactionActionDialog.tsx | 6 +- .../src/app/(app)/germplasm/seed-lot/page.tsx | 21 +- .../src/app/(app)/germplasm/seed-lot/types.ts | 18 + .../germ/GermplasmProfileWriteController.java | 115 +++++ .../dto/germ/GermplasmInstituteRecord.java | 51 +++ .../germ/GermplasmInstituteWriteRequest.java | 51 +++ .../germ/GermplasmInstituteRepository.java | 8 + .../service/germ/GermplasmService.java | 123 +++++- .../service/germ/SeedLotService.java | 45 ++ 63 files changed, 4960 insertions(+), 851 deletions(-) create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentsPanel.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossPollinationEventPanel.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgePanel.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeFormPanel.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/crosses/[crossDbId]/page.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/pedigree-nodes/[germplasmDbId]/page.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/planned-crosses/[plannedCrossDbId]/page.tsx create mode 100644 frontend/src/app/(app)/germplasm/cross-pedigree/projects/[crossingProjectDbId]/page.tsx create mode 100644 frontend/src/app/(app)/germplasm/germplasm/[germplasmDbId]/page.tsx create mode 100644 frontend/src/app/(app)/germplasm/germplasm/components/GermplasmDetailRelatedPanels.tsx create mode 100644 frontend/src/app/(app)/germplasm/germplasm/components/GermplasmPedigreePanel.tsx create mode 100644 frontend/src/app/(app)/germplasm/germplasm/components/GermplasmProfilePanels.tsx create mode 100644 frontend/src/app/(app)/germplasm/germplasm/profileApi.ts create mode 100644 frontend/src/app/(app)/germplasm/germplasm/profileTypes.ts create mode 100644 frontend/src/app/(app)/germplasm/seed-lot/[seedLotDbId]/page.tsx create mode 100644 frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotContentMixturePanel.tsx create mode 100644 frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionPanel.tsx create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/controller/germ/GermplasmProfileWriteController.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteRecord.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/repository/germ/GermplasmInstituteRepository.java diff --git a/AGENTS.md b/AGENTS.md index 668ca6e..ca0053e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,4 +13,16 @@ - 仅在该文档描述的功能全部落地时标注;部分实现(如仅后端、缺页面或缺校验)不要标注。 - 若文档末尾已有「状态:已完成」,不要重复追加。 -- `docs/dev/**/README.md` 等索引/说明类文档无需标注。 \ No newline at end of file +- `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 章」称呼,需按具体路径理解。 +- **第二、四章重叠表**:若第二章已实现,第四章对应文档可标注「与第二章共用实现」,无需重复开发。 \ No newline at end of file diff --git a/docs/dev/02-germplasm-seed/12-seed_lot_transaction.md b/docs/dev/02-germplasm-seed/12-seed_lot_transaction.md index fd6d74e..442484c 100644 --- a/docs/dev/02-germplasm-seed/12-seed_lot_transaction.md +++ b/docs/dev/02-germplasm-seed/12-seed_lot_transaction.md @@ -40,3 +40,5 @@ --- +**状态:已完成**(页面:`germplasm/seed-lot` → 库存交易 Tab;`germplasm/seed-lot/[seedLotDbId]` → Transactions Tab;BrAPI `POST /seedlots/transactions`) + diff --git a/docs/dev/04-germplasm/01-breeding_method.md b/docs/dev/04-germplasm/01-breeding_method.md index c45a1f1..d506e84 100644 --- a/docs/dev/04-germplasm/01-breeding_method.md +++ b/docs/dev/04-germplasm/01-breeding_method.md @@ -35,3 +35,7 @@ 1. `name` 必填。 2. 已被 `germplasm` 引用时不允许物理删除,只允许停用或提示引用关系。 + +--- + +**状态:已完成**(与第二章 `02-germplasm-seed/01-breeding_method.md` 共用实现,页面:`germplasm/breeding-method`) diff --git a/docs/dev/04-germplasm/02-germplasm.md b/docs/dev/04-germplasm/02-germplasm.md index b967147..ac46937 100644 --- a/docs/dev/04-germplasm/02-germplasm.md +++ b/docs/dev/04-germplasm/02-germplasm.md @@ -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) diff --git a/docs/dev/04-germplasm/03-germplasm_donor.md b/docs/dev/04-germplasm/03-germplasm_donor.md index fa97165..c6d5445 100644 --- a/docs/dev/04-germplasm/03-germplasm_donor.md +++ b/docs/dev/04-germplasm/03-germplasm_donor.md @@ -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 整表替换) diff --git a/docs/dev/04-germplasm/04-germplasm_institute.md b/docs/dev/04-germplasm/04-germplasm_institute.md index 484d152..498248a 100644 --- a/docs/dev/04-germplasm/04-germplasm_institute.md +++ b/docs/dev/04-germplasm/04-germplasm_institute.md @@ -36,3 +36,5 @@ 1. `germplasm_id` 必须存在。 2. 同一 germplasm 下同类型、同 code 的机构不建议重复。 3. 删除 institute 记录不应删除 germplasm 主数据。 + +**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Institute Tab;扩展接口 `/brapi/v2/germplasm/{id}/institutes` CRUD) diff --git a/docs/dev/04-germplasm/05-germplasm_origin.md b/docs/dev/04-germplasm/05-germplasm_origin.md index 6b4d029..a88b10e 100644 --- a/docs/dev/04-germplasm/05-germplasm_origin.md +++ b/docs/dev/04-germplasm/05-germplasm_origin.md @@ -34,3 +34,5 @@ 1. `germplasm_id` 必须存在。 2. 坐标格式需要合法。 3. 删除 origin 记录不应删除 germplasm 主数据。 + +**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Origin Tab;BrAPI `PUT /germplasm/{id}` germplasmOrigin 整表替换) diff --git a/docs/dev/04-germplasm/06-germplasm_synonym.md b/docs/dev/04-germplasm/06-germplasm_synonym.md index b4128f9..5a988be 100644 --- a/docs/dev/04-germplasm/06-germplasm_synonym.md +++ b/docs/dev/04-germplasm/06-germplasm_synonym.md @@ -34,3 +34,5 @@ 1. `germplasm_id` 必须存在。 2. 同一 germplasm 下同一个 synonym 不应重复。 3. 删除 synonym 不应删除 germplasm 主数据。 + +**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Synonym Tab;BrAPI `PUT /germplasm/{id}` synonyms 整表替换) diff --git a/docs/dev/04-germplasm/07-germplasm_taxon.md b/docs/dev/04-germplasm/07-germplasm_taxon.md index 4d17b44..ddd6ff3 100644 --- a/docs/dev/04-germplasm/07-germplasm_taxon.md +++ b/docs/dev/04-germplasm/07-germplasm_taxon.md @@ -34,3 +34,5 @@ 1. `germplasm_id` 必须存在。 2. 同一 source 下 taxon_id 不建议重复。 3. 删除 taxon 前检查 sample 引用。 + +**状态:已完成**(页面:`germplasm/germplasm/[germplasmDbId]` → Taxon Tab;BrAPI `PUT /germplasm/{id}` taxonIds 整表替换) diff --git a/docs/dev/04-germplasm/08-germplasm_attribute_definition.md b/docs/dev/04-germplasm/08-germplasm_attribute_definition.md index 19aac29..0d3eb6c 100644 --- a/docs/dev/04-germplasm/08-germplasm_attribute_definition.md +++ b/docs/dev/04-germplasm/08-germplasm_attribute_definition.md @@ -53,3 +53,7 @@ 1. `name` 必填。 2. 已被 `germplasm_attribute_value` 引用时不允许物理删除。 3. `datatype` 要与 value 输入控件联动。 + +--- + +**状态:已完成**(与第二章 `02-germplasm-seed/03-germplasm_attribute_definition.md` 共用实现,页面:`germplasm` → Attributes Tab) diff --git a/docs/dev/04-germplasm/09-germplasm_attribute_value.md b/docs/dev/04-germplasm/09-germplasm_attribute_value.md index c277d02..193ad11 100644 --- a/docs/dev/04-germplasm/09-germplasm_attribute_value.md +++ b/docs/dev/04-germplasm/09-germplasm_attribute_value.md @@ -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) diff --git a/docs/dev/04-germplasm/10-crossing_project.md b/docs/dev/04-germplasm/10-crossing_project.md index b88adf2..26815c7 100644 --- a/docs/dev/04-germplasm/10-crossing_project.md +++ b/docs/dev/04-germplasm/10-crossing_project.md @@ -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]`) diff --git a/docs/dev/04-germplasm/11-cross_entity.md b/docs/dev/04-germplasm/11-cross_entity.md index 7fdbb50..f382eed 100644 --- a/docs/dev/04-germplasm/11-cross_entity.md +++ b/docs/dev/04-germplasm/11-cross_entity.md @@ -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]`;创建实际杂交时继承计划杂交亲本) diff --git a/docs/dev/04-germplasm/12-cross_parent.md b/docs/dev/04-germplasm/12-cross_parent.md index acdb6a5..f378e56 100644 --- a/docs/dev/04-germplasm/12-cross_parent.md +++ b/docs/dev/04-germplasm/12-cross_parent.md @@ -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) diff --git a/docs/dev/04-germplasm/13-cross_pollination_event.md b/docs/dev/04-germplasm/13-cross_pollination_event.md index 14f7409..23f680a 100644 --- a/docs/dev/04-germplasm/13-cross_pollination_event.md +++ b/docs/dev/04-germplasm/13-cross_pollination_event.md @@ -35,3 +35,7 @@ 1. `cross_id` 必须存在。 2. 同一 cross 下 `pollination_number` 不建议重复。 3. 删除授粉事件不应删除 cross 主数据。 + +--- + +**状态:已完成**(页面:实际 Cross 详情 → Pollination Events Tab;BrAPI `PUT /crosses` pollinationEvents) diff --git a/docs/dev/04-germplasm/14-pedigree_node.md b/docs/dev/04-germplasm/14-pedigree_node.md index 07cf02d..e5b8d83 100644 --- a/docs/dev/04-germplasm/14-pedigree_node.md +++ b/docs/dev/04-germplasm/14-pedigree_node.md @@ -38,3 +38,7 @@ 1. 同一 germplasm 通常只应有一个 pedigree node。 2. 删除 pedigree_node 前检查 `pedigree_edge` 中 this_node 和 connceted_node 引用。 3. 导入 pedigree 时需要先创建所有节点,再创建边。 + +--- + +**状态:已完成** diff --git a/docs/dev/04-germplasm/15-pedigree_edge.md b/docs/dev/04-germplasm/15-pedigree_edge.md index f09ffda..7176c97 100644 --- a/docs/dev/04-germplasm/15-pedigree_edge.md +++ b/docs/dev/04-germplasm/15-pedigree_edge.md @@ -41,3 +41,7 @@ 1. `this_node_id` 和 `connceted_node_id` 必须存在。 2. 两个节点不能相同。 3. 同一节点之间同一种 edge_type 不应重复。 + +--- + +**状态:已完成** diff --git a/docs/dev/04-germplasm/16-seed_lot.md b/docs/dev/04-germplasm/16-seed_lot.md index ea43d4d..24f1d63 100644 --- a/docs/dev/04-germplasm/16-seed_lot.md +++ b/docs/dev/04-germplasm/16-seed_lot.md @@ -44,3 +44,5 @@ 1. `amount` 不允许为负。 2. 普通用户不应直接编辑 amount,库存变化应通过 `seed_lot_transaction`。 3. 删除 seed lot 前检查组成明细和交易引用。 + +**状态:已完成**(页面:`germplasm/seed-lot` 列表 CRUD + 查询;`germplasm/seed-lot/[seedLotDbId]` 详情摘要;库存 amount 编辑时只读,通过交易更新) diff --git a/docs/dev/04-germplasm/17-seed_lot_content_mixture.md b/docs/dev/04-germplasm/17-seed_lot_content_mixture.md index f20e83b..cabe6a8 100644 --- a/docs/dev/04-germplasm/17-seed_lot_content_mixture.md +++ b/docs/dev/04-germplasm/17-seed_lot_content_mixture.md @@ -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) diff --git a/docs/dev/04-germplasm/18-seed_lot_transaction.md b/docs/dev/04-germplasm/18-seed_lot_transaction.md index f37d1c4..31d87cd 100644 --- a/docs/dev/04-germplasm/18-seed_lot_transaction.md +++ b/docs/dev/04-germplasm/18-seed_lot_transaction.md @@ -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) diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx index dfca006..c65712f 100644 --- a/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx +++ b/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx @@ -46,7 +46,7 @@ export function GenomeMapTab() { }, [appliedQuery]); const fields = useMemo(() => [ - { 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", diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts b/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts index 34aea8b..e631434 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts @@ -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 �?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, "请选择所�?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 { +export async function fetchCrossingProjectRows(query?: CrossingProjectQuery): Promise { 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 { + 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 { + const response = await request>( + `/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 { + const response = await request>( + `/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 { + 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>("/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 { @@ -412,7 +516,24 @@ export async function createCrossRow(payload: CrossPayload): Promise 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 { @@ -430,7 +551,7 @@ export async function fetchCrossParentRows(): Promise { } export async function updateCrossParents(payload: CrossParentFormState): Promise { - const crossId = requiredText(payload.cross_id, "请选择所�?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 { +const PEDIGREE_LIST_PAGE_SIZE = 500; + +async function fetchAllPedigreeRows(query = ""): Promise { const response = await request>( - "/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 { + return fetchAllPedigreeRows(); +} + export async function fetchPedigreeRowsWithRelations(): Promise { + return fetchAllPedigreeRows("&includeParents=true&includeProgeny=false&includeSiblings=true"); +} + +export async function fetchPedigreeNodeByGermplasm(germplasmDbId: string): Promise { const response = await request>( - "/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 { + 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 { +export async function fetchPedigreeEdgeRows(scopeGermplasmDbId?: string): Promise { 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 { const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":"); if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) { - throw new Error("仅支持删�?parent 关系"); + throw new Error("仅支持删除 parent 关系"); } const nodes = await fetchPedigreeRowsWithRelations(); diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossEntityTab.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossEntityTab.tsx index 6035b2b..66d0578 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossEntityTab.tsx +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossEntityTab.tsx @@ -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 ( + + {id} + + ); + }, + }, + { + key: "name", + label: "名称", + render: (value, row) => { + const id = String(row.id ?? row.plannedCrossDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, { 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 ( + + {id} + + ); + }, + }, + { + key: "name", + label: "名称", + render: (value, row) => { + const id = String(row.id ?? row.crossDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, { key: "crossing_project_name", label: "杂交项目" }, { key: "plannedCrossName", label: "来源计划杂交" }, { key: "cross_type", label: "类型", render: crossTypeLabel }, diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentTab.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentTab.tsx index 83e87eb..785369f 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentTab.tsx +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentTab.tsx @@ -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) => ( - {row.cross_name || row.cross_id} + + + {row.cross_name || row.cross_id} + + {row.planned ? "计划杂交" : "实际杂交"} {row.crossing_project_name || "—"} {row.parent_slot === "parent1" ? "Parent 1" : "Parent 2"} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentsPanel.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentsPanel.tsx new file mode 100644 index 0000000..d2d0921 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossParentsPanel.tsx @@ -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) => void; +}) { + const prefix = slot; + return ( +
+

{title}

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} + +export function CrossParentsPanel({ + crossId, + planned, + crossName, + crossingProjectId, + crossingProjectName, + parent1, + parent2, + onChanged, +}: CrossParentsPanelProps) { + const [germplasmOptions, setGermplasmOptions] = useState([]); + const [observationUnitOptions, setObservationUnitOptions] = useState([]); + const [loadingOptions, setLoadingOptions] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const savingRef = useRef(false); + const [form, setForm] = useState(() => 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 ; + } + + return ( + + + + + 杂交亲本 (cross_parent) + + + +

+ {crossName || crossId}:维护 parent1 / parent2;germplasm 与 observation_unit 至少填写一项。 +

+ +
+ + +
+ + setForm((current) => ({ ...current, ...patch }))} + /> + setForm((current) => ({ ...current, ...patch }))} + /> + +
+ 当前摘要: + Parent1 {parentTypeLabel(form.parent1_type)} / + Parent2 {parentTypeLabel(form.parent2_type)} +
+ + {error ?

{error}

: null} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossPollinationEventPanel.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossPollinationEventPanel.tsx new file mode 100644 index 0000000..e224594 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossPollinationEventPanel.tsx @@ -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, 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(() => sortPollinationEvents(events)); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + + + + + 授粉事件 (cross_pollination_event) + + + + +

+ {crossName || crossId}:按授粉时间倒序展示;删除授粉事件不会删除 Cross 主数据。 +

+ + {error ?

{error}

: null} + + + + + 授粉编号 + 授粉时间 + 是否成功 + 操作 + + + + {sortedRows.length === 0 ? ( + + + 暂无授粉事件 + + + ) : ( + sortedRows.map((row) => ( + + {row.pollination_number || "—"} + {row.pollination_time_stamp || "—"} + + {row.pollination_successful === null ? ( + "—" + ) : ( + + {row.pollination_successful ? "成功" : "失败"} + + )} + + +
+ + +
+
+
+ )) + )} +
+
+ + + + +
+ + setForm((current) => ({ ...current, pollination_number: event.target.value }))} + placeholder="同一 Cross 下建议唯一" + /> +
+
+ + setForm((current) => ({ ...current, pollination_time_stamp: event.target.value }))} + /> +
+
+ + +
+
+ + + + +
+
+ + !open && setDeleteTarget(null)}> + + + 删除授粉事件? + + 将删除授粉编号「{deleteTarget?.pollination_number || "未命名"}」;Cross 主数据不会被删除。 + + + + 取消 + 确认删除 + + + +
+
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossingProjectTab.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossingProjectTab.tsx index 3130be5..c7adce4 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossingProjectTab.tsx +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/CrossingProjectTab.tsx @@ -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(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); const loadRows = useCallback(async () => { - const data = await refresh(false); - return data.crossingProjects as unknown as Record[]; - }, [refresh]); + await refresh(false); + const rows = await fetchCrossingProjectRows(appliedQuery); + return rows as unknown as Record[]; + }, [appliedQuery, refresh]); const fetchRecord = useCallback(async (id: string) => { const detail = await fetchCrossingProjectDetail(id); @@ -50,18 +64,79 @@ export function CrossingProjectTab() { }, ], [programOptions]); + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, keyword: event.target.value }))} + placeholder="项目名称 / 说明模糊匹配" + /> +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, programOptions]); + return ( { + const id = String(row.id ?? row.crossingProjectDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, { key: "program_name", label: "Program" }, { key: "description", diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgePanel.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgePanel.tsx new file mode 100644 index 0000000..e38d209 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgePanel.tsx @@ -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([]); + const [nodeOptions, setNodeOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(null); + const [editingEdgeId, setEditingEdgeId] = useState(null); + const [deletingEdge, setDeletingEdge] = useState(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 ( +
+
+
+
+ +
+
+

+ {compact ? "系谱边" : "Pedigree Edge 系谱边"} +

+

+ {scopeGermplasmDbId + ? "维护当前种质节点的 parent / child 关系;sibling 由 BrAPI 自动推断(只读)。" + : "pedigree_edge:维护节点之间的 parent / child 关系;sibling 由 BrAPI 根据共享亲本自动推断(只读展示)"} +

+
+
+ +
+ + {!compact ? ( +
+ + BrAPI PUT /brapi/v2/pedigree(parents 字段) + +
+ ) : null} + +
+
+ setSearch(event.target.value)} + placeholder="搜索当前材料 / 关联材料 / 关系类型" + className="max-w-sm" + /> +
+ + {error ?

{error}

: null} + + {loading ? ( + + ) : ( + + + + 关系类型 + 当前材料 + 关联材料 + parent_type + 操作 + + + + {filteredRows.length === 0 ? ( + + + 暂无系谱边。请先创建系谱节点,再维护 parent / child 关系。 + + + ) : ( + filteredRows.map((row) => ( + + + {edgeTypeLabel(row.edge_type)} + {row.read_only ? ( + 只读 + ) : null} + + {row.this_node_name || row.this_node_id} + {row.connected_node_name || row.connected_node_id} + {parentTypeLabel(row.parent_type)} + + {!row.read_only ? ( +
+ + +
+ ) : ( + + )} +
+
+ )) + )} +
+
+ )} +
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + {showParentType ? ( +
+ + +
+ ) : null} + +

+ parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。 +

+
+ + + + +
+
+ + !open && setDeletingEdge(null)}> + + + 确认删除系谱边? + + 将移除 + {" "} + {deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""} + {" "} + 关系: + {deletingEdge?.this_node_name || deletingEdge?.this_node_id} + {" → "} + {deletingEdge?.connected_node_name || deletingEdge?.connected_node_id} + + + + 取消 + + {deleting ? "删除中..." : "确认删除"} + + + + +
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgeTab.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgeTab.tsx index a8ab3f7..c070ee1 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgeTab.tsx +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeEdgeTab.tsx @@ -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([]); - const [nodeOptions, setNodeOptions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - const [dialogOpen, setDialogOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [form, setForm] = useState(null); - const [editingEdgeId, setEditingEdgeId] = useState(null); - const [deletingEdge, setDeletingEdge] = useState(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 ( -
-
-
-
- -
-
-

Pedigree Edge 系谱边

-

- pedigree_edge:维护节点之间的 parent / child 关系;sibling 由 BrAPI 根据共享亲本自动推断(只读展示) -

-
-
- -
- -
- - BrAPI PUT /brapi/v2/pedigree(parents 字段) - -
- -
-
- setSearch(event.target.value)} - placeholder="搜索当前材料 / 关联材料 / 关系类型" - className="max-w-sm" - /> -
- - {error ?

{error}

: null} - - {loading ? ( - - ) : ( - - - - 关系类型 - 当前材料 - 关联材料 - parent_type - 操作 - - - - {filteredRows.length === 0 ? ( - - - 暂无系谱边。请先创建系谱节点,再维护 parent / child 关系。 - - - ) : ( - filteredRows.map((row) => ( - - - {edgeTypeLabel(row.edge_type)} - {row.read_only ? ( - 只读 - ) : null} - - {row.this_node_name || row.this_node_id} - {row.connected_node_name || row.connected_node_id} - {parentTypeLabel(row.parent_type)} - - {!row.read_only ? ( -
- - -
- ) : ( - - )} -
-
- )) - )} -
-
- )} -
- - - - -
- - -
- -
- - -
- -
- - -
- - {showParentType ? ( -
- - -
- ) : null} - -

- parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。 -

-
- - - - -
-
- - !open && setDeletingEdge(null)}> - - - 确认删除系谱边? - - 将移除 - {" "} - {deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""} - {" "} - 关系: - {deletingEdge?.this_node_name || deletingEdge?.this_node_id} - {" → "} - {deletingEdge?.connected_node_name || deletingEdge?.connected_node_id} - - - - 取消 - - {deleting ? "删除中..." : "确认删除"} - - - - -
- ); + return ; } diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeFormPanel.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeFormPanel.tsx new file mode 100644 index 0000000..0536d7a --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeFormPanel.tsx @@ -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[]; + } + + 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[]; + }, [scopeGermplasmDbId, scopeGermplasmName, snapshot?.crossingProjects]); + + const fetchRecord = useCallback(async (id: string) => { + const detail = await fetchPedigreeDetail(id); + return normalizePedigreeForm(detail); + }, []); + + const wrapMutation = useCallback((action: () => Promise) => 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(() => [ + { + 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) => { + const id = String(row.germplasm_id ?? row.id ?? ""); + const name = String(value ?? row.germplasm_id ?? "—"); + if (!id || scopeGermplasmDbId) return name; + return ( + + {name} + + ); + }, + }, + ...(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 ( + wrapMutation(() => { + const body = scopeGermplasmDbId + ? { ...payload, germplasm_id: scopeGermplasmDbId } + : payload; + return createPedigreeRow(body); + })() as Promise>} + updateRecord={(id, payload) => wrapMutation(() => updatePedigreeRow(id, payload))() as Promise>} + /> + ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeTab.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeTab.tsx index cdf97d3..ce83913 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeTab.tsx +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/components/PedigreeNodeTab.tsx @@ -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[]; - }, [snapshot?.crossingProjects]); - - const fetchRecord = useCallback(async (id: string) => { - const detail = await fetchPedigreeDetail(id); - return normalizePedigreeForm(detail); - }, []); - - const fields = useMemo(() => [ - { - 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 ( - { - 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>} - updateRecord={(id, payload) => updatePedigreeRow(id, payload) as unknown as Promise>} - /> - ); + return ; } diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/crosses/[crossDbId]/page.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/crosses/[crossDbId]/page.tsx new file mode 100644 index 0000000..6b48069 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/crosses/[crossDbId]/page.tsx @@ -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(initialTab); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(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 ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "实际杂交不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {detail.name || detail.id} + + + +
Cross ID:{detail.id}
+
杂交项目:{detail.crossing_project_name || detail.crossing_project_id || "—"}
+
来源计划杂交:{detail.plannedCrossName || "—"}
+
类型:{crossTypeLabel(detail.cross_type)}
+
授粉事件数:{detail.pollination_events.length}
+
+
+ + {detail.crossing_project_id ? ( + + ) : null} + + setActiveTab(value as CrossDetailTab)} className="flex min-h-full flex-col gap-4"> + + Parents 亲本 + Pollination Events + + + {activeTab === "parents" ? ( + + + + ) : null} + + {activeTab === "pollination" ? ( + + + + ) : null} + +
+ ); +} + +export default function CrossDetailPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts b/frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts index 9108561..08ea8c2 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts @@ -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), }); diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx index 1790adf..dcfac55 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx @@ -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 ( @@ -74,7 +89,9 @@ function CrossPedigreePageContent() { export default function CrossPedigreePage() { return ( - + + + ); } diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/pedigree-nodes/[germplasmDbId]/page.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/pedigree-nodes/[germplasmDbId]/page.tsx new file mode 100644 index 0000000..3825d47 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/pedigree-nodes/[germplasmDbId]/page.tsx @@ -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(null); + const [node, setNode] = useState(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 ( +
+ + + +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ +
+
+ ); + } + + return ( +
+
+ + +
+ + + + + + {node?.germplasm_name || germplasmDbId} + + + +
Germplasm ID:{germplasmDbId}
+
杂交项目:{node?.crossing_project_name || "—"}
+
杂交年份:{node?.crossing_year ?? "—"}
+
家系编号:{node?.family_code || "—"}
+
系谱字符串:{node?.pedigree_string || "—"}
+
亲本数:{node?.parents?.length ?? 0}
+
同胞数:{node?.siblings?.length ?? 0}
+
+
+ + + + + + 节点信息 + + + + 系谱边 + + + + + { + bumpRefresh(); + loadDetail().catch(() => undefined); + }} + /> + + + + + + +
+ ); +} + +export default function PedigreeNodeDetailPage() { + return ( + }> + + + + + ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/planned-crosses/[plannedCrossDbId]/page.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/planned-crosses/[plannedCrossDbId]/page.tsx new file mode 100644 index 0000000..b89df48 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/planned-crosses/[plannedCrossDbId]/page.tsx @@ -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(null); + const [detail, setDetail] = useState(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 ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "计划杂交不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {detail.name || detail.id} + + + +
Cross ID:{detail.id}
+
杂交项目:{detail.crossing_project_name || detail.crossing_project_id || "—"}
+
类型:{crossTypeLabel(detail.cross_type)}
+
状态:{plannedStatusLabel(detail.status)}
+
+
+ + {detail.crossing_project_id ? ( + + ) : null} + +
+ + Parents Tab +
+ + +
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/projects/[crossingProjectDbId]/page.tsx b/frontend/src/app/(app)/germplasm/cross-pedigree/projects/[crossingProjectDbId]/page.tsx new file mode 100644 index 0000000..d1c44fd --- /dev/null +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/projects/[crossingProjectDbId]/page.tsx @@ -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(null); + const [detail, setDetail] = useState(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 ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "杂交项目不存在"} +
+ +
+
+ ); + } + + const hasDependencies = detail.plannedCrosses.length > 0 + || detail.actualCrosses.length > 0 + || detail.pedigreeNodes.length > 0; + + return ( +
+ + + + + + + {detail.name || detail.id} + + + +
项目 ID:{detail.id}
+
Program:{detail.program_name || detail.program_id || "—"}
+
计划杂交:{detail.plannedCrosses.length}
+
实际杂交:{detail.actualCrosses.length}
+
说明:{detail.description || "—"}
+
+
+ + {hasDependencies ? ( +

+ 删除杂交项目前请先清理下属 Cross、Cross Parent 与 Pedigree Node 引用。 +

+ ) : null} + + + + 计划杂交 + + + + + + 名称 + 类型 + 状态 + + + + {detail.plannedCrosses.length === 0 ? ( + + 暂无计划杂交 + + ) : detail.plannedCrosses.map((row) => ( + + + + {row.name || row.id} + + + {crossTypeLabel(row.cross_type)} + {plannedStatusLabel(row.status)} + + ))} + +
+
+
+ + + + 实际杂交 + + + + + + 名称 + 来源计划杂交 + 类型 + + + + {detail.actualCrosses.length === 0 ? ( + + 暂无实际杂交 + + ) : detail.actualCrosses.map((row) => ( + + + + {row.name || row.id} + + + {row.plannedCrossName || "—"} + {crossTypeLabel(row.cross_type)} + + ))} + +
+
+
+ + + + + + 关联 Pedigree Node + + + + + + + 材料 + 杂交年份 + 家系编号 + + + + {detail.pedigreeNodes.length === 0 ? ( + + 暂无关联系谱节点 + + ) : detail.pedigreeNodes.map((row) => { + const germplasmId = String(row.germplasm_id ?? row.id ?? ""); + return ( + + + {germplasmId ? ( + + {row.germplasm_name || germplasmId} + + ) : (row.germplasm_name || "—")} + + {row.crossing_year ?? "—"} + {row.family_code || "—"} + + ); + })} + +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/types.ts b/frontend/src/app/(app)/germplasm/cross-pedigree/types.ts index ea5f2c5..4f0a851 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/types.ts +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/types.ts @@ -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 { diff --git a/frontend/src/app/(app)/germplasm/germplasm/[germplasmDbId]/page.tsx b/frontend/src/app/(app)/germplasm/germplasm/[germplasmDbId]/page.tsx new file mode 100644 index 0000000..376d3ab --- /dev/null +++ b/frontend/src/app/(app)/germplasm/germplasm/[germplasmDbId]/page.tsx @@ -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(initialTab); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState> | 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 ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "种质不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {detail.germplasm_name || detail.default_display_name || detail.id} + + + +
Germplasm ID:{detail.id}
+
登录号:{detail.accession_number || "—"}
+
PUI:{detail.germplasmpui || "—"}
+
作物:{detail.crop_name || detail.crop_id || "—"}
+
育种方法:{detail.breeding_method_name || "—"}
+
属 / 种:{[detail.genus, detail.species].filter(Boolean).join(" ") || "—"}
+
来源国家:{detail.country_of_origin_code || "—"}
+
集合:{detail.collection || "—"}
+
+
+ + + + setActiveTab(value as GermplasmProfileTab)} + className="flex min-h-full flex-col gap-4" + > + + Attributes + Donor + Institute + Origin + Synonym + Taxon + Pedigree + Seed Lots + Cross Parent + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export default function GermplasmDetailPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/app/(app)/germplasm/germplasm/api.ts b/frontend/src/app/(app)/germplasm/germplasm/api.ts index 9c1f062..55248df 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/api.ts +++ b/frontend/src/app/(app)/germplasm/germplasm/api.ts @@ -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 { - const response = await request>("/brapi/v2/germplasm?page=0&pageSize=10"); - return response.result.data.map(mapGermplasm); +const buildGermplasmSearchBody = (query?: GermplasmQuery) => { + const body: Record = { ...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 { + 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>("/brapi/v2/search/germplasm", { + method: "POST", + body: JSON.stringify(buildGermplasmSearchBody(query)), + }); + rows = response.result.data.map(mapGermplasm); + } else { + const response = await request>("/brapi/v2/germplasm?page=0&pageSize=1000"); + rows = response.result.data.map(mapGermplasm); + } + + return filterGermplasmRows(rows, query); } export async function fetchGermplasmDetail(id: string): Promise { diff --git a/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmAttributeValueTab.tsx b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmAttributeValueTab.tsx index f8e69f1..bfad801 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmAttributeValueTab.tsx +++ b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmAttributeValueTab.tsx @@ -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([]); const [attributeOptions, setAttributeOptions] = useState([]); @@ -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[]; - }, [loadSelectOptions]); + }, [loadSelectOptions, scopeGermplasmDbId]); const fetchRecord = useCallback(async (id: string) => { const detail = await fetchAttributeValueDetail(id); return normalizeAttributeValueFormData(detail); }, []); - const fields = useMemo(() => [ - { - key: "germplasm_id", - label: "种质材料", - type: "select", - required: true, - placeholder: germplasmOptions.length > 0 ? "请选择种质" : "请先在「种质列表」Tab 创建材料", - options: [{ value: NONE_SELECT_VALUE, label: "请选择种质" }, ...germplasmOptions], - }, + const fields = useMemo(() => { + 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 ( createAttributeValueRow(payload) as unknown as Promise>} - updateRecord={(id, payload) => updateAttributeValueRow(id, payload) as unknown as Promise>} + createRecord={(payload) => createAttributeValueRow({ + ...payload, + germplasm_id: scopeGermplasmDbId ?? payload.germplasm_id, + }) as unknown as Promise>} + updateRecord={(id, payload) => updateAttributeValueRow(id, { + ...payload, + germplasm_id: scopeGermplasmDbId ?? payload.germplasm_id, + }) as unknown as Promise>} /> ); } diff --git a/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmDetailRelatedPanels.tsx b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmDetailRelatedPanels.tsx new file mode 100644 index 0000000..bf2519f --- /dev/null +++ b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmDetailRelatedPanels.tsx @@ -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 ; +} + +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); + }, [germplasmDbId]); + + const columns = useMemo(() => [ + { + key: "name", + label: "批次名称", + render: (value: unknown, row: Record) => { + const id = String(row.id ?? ""); + const name = String(value ?? row.seed_lot_name ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "amount", label: "数量" }, + { key: "units", label: "单位" }, + { key: "location_name", label: "库位" }, + { key: "program_name", label: "Program" }, + ], []); + + return ( +
+ + +
+ ); +} + +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[]; + }, [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 ( +
+ + +
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmPedigreePanel.tsx b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmPedigreePanel.tsx new file mode 100644 index 0000000..75c3c11 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmPedigreePanel.tsx @@ -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 ( +
+
+ + +
+ + + + +
+ ); +} + +export function GermplasmPedigreePanel(props: GermplasmPedigreePanelProps) { + return ( + + + + ); +} diff --git a/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmProfilePanels.tsx b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmProfilePanels.tsx new file mode 100644 index 0000000..f2c5da7 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/germplasm/components/GermplasmProfilePanels.tsx @@ -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( + germplasmDbId: string, + loadRows: (id: string) => Promise, + createRow: (id: string, payload: Record) => Promise, + updateRow: (id: string, rowId: string, payload: Record) => Promise, + deleteRow: (id: string, rowId: string) => Promise, +) { + const loadData = useCallback(async () => { + const rows = await loadRows(germplasmDbId); + return rows as unknown as Record[]; + }, [germplasmDbId, loadRows]); + + const wrap = useCallback((action: () => Promise) => action(), []); + + return { + loadData, + createRecord: (payload: Record) => wrap(() => createRow(germplasmDbId, payload)) as Promise>, + updateRecord: (id: string, payload: Record) => wrap(() => updateRow(germplasmDbId, id, payload)) as Promise>, + 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(() => [ + { 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 ( + fetchDonorRecord(germplasmDbId, id) as Promise>} + /> + ); +} + +export function GermplasmInstitutePanel({ germplasmDbId }: ProfilePanelProps) { + const mutations = useProfileMutations( + germplasmDbId, + fetchInstituteRows, + createInstituteRow, + updateInstituteRow, + deleteInstituteRow, + ); + + const fields = useMemo(() => [ + { 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 ( + fetchInstituteRecord(germplasmDbId, id) as Promise>} + /> + ); +} + +export function GermplasmOriginPanel({ germplasmDbId }: ProfilePanelProps) { + const mutations = useProfileMutations( + germplasmDbId, + fetchOriginRows, + createOriginRow, + updateOriginRow, + deleteOriginRow, + ); + + const fields = useMemo(() => [ + { key: "longitude", label: "经度", type: "number", placeholder: "WGS84 经度" }, + { key: "latitude", label: "纬度", type: "number", placeholder: "WGS84 纬度" }, + { key: "coordinate_uncertainty", label: "坐标不确定性 (m)", type: "text", placeholder: "可选" }, + ], []); + + return ( + fetchOriginRecord(germplasmDbId, id) as Promise>} + /> + ); +} + +export function GermplasmSynonymPanel({ germplasmDbId }: ProfilePanelProps) { + const mutations = useProfileMutations( + germplasmDbId, + fetchSynonymRows, + createSynonymRow, + updateSynonymRow, + deleteSynonymRow, + ); + + const fields = useMemo(() => [ + { key: "synonym", label: "别名", type: "text", required: true, placeholder: "别名 / 旧名 / 本地名" }, + { key: "type", label: "类型", type: "text", placeholder: "如 local / commercial / old name" }, + ], []); + + return ( + fetchSynonymRecord(germplasmDbId, id) as Promise>} + /> + ); +} + +export function GermplasmTaxonPanel({ germplasmDbId }: ProfilePanelProps) { + const mutations = useProfileMutations( + germplasmDbId, + fetchTaxonRows, + createTaxonRow, + updateTaxonRow, + deleteTaxonRow, + ); + + const fields = useMemo(() => [ + { key: "source_name", label: "来源体系", type: "text", placeholder: "如 NCBI Taxonomy" }, + { key: "taxon_id", label: "Taxon ID", type: "text", required: true, placeholder: "外部 taxon 标识" }, + ], []); + + return ( + fetchTaxonRecord(germplasmDbId, id) as Promise>} + /> + ); +} + +export function GermplasmProfileSummaryHint() { + return ( +
+ Donor / Origin / Synonym / Taxon 通过 BrAPI `PUT /germplasm/{id}` 整表替换保存;Institute 使用扩展接口单独维护。 +
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/germplasm/page.tsx b/frontend/src/app/(app)/germplasm/germplasm/page.tsx index 6d0e5d4..2acf01f 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/page.tsx +++ b/frontend/src/app/(app)/germplasm/germplasm/page.tsx @@ -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([]); const [breedingMethodOptions, setBreedingMethodOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); const applyGermplasmOptions = useCallback((options: Awaited>) => { 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[]; - }, [applyGermplasmOptions]); + }, [applyGermplasmOptions, appliedQuery]); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + +
+
+ + setDraftQuery((current) => ({ ...current, germplasm_name: event.target.value }))} + placeholder="子串匹配" + /> +
+
+ + setDraftQuery((current) => ({ ...current, accession_number: event.target.value }))} + placeholder="Accession 子串匹配" + /> +
+
+ + setDraftQuery((current) => ({ ...current, germplasmpui: event.target.value }))} + placeholder="PUI 子串匹配" + /> +
+
+ + setDraftQuery((current) => ({ ...current, synonym: event.target.value }))} + placeholder="走后端 search/germplasm 精确匹配" + /> +
+
+
+ + +
+
+ ), [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 ( + + {name} + + ); + }, + }, { 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>} updateRecord={(id, payload) => updateGermplasmRow(id, payload) as unknown as Promise>} deleteRecord={deleteGermplasmRow} diff --git a/frontend/src/app/(app)/germplasm/germplasm/profileApi.ts b/frontend/src/app/(app)/germplasm/germplasm/profileApi.ts new file mode 100644 index 0000000..f43c030 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/germplasm/profileApi.ts @@ -0,0 +1,402 @@ +import { getAuthToken } from "@/utils/token"; +import type { + GermplasmDonorRecord, + GermplasmInstituteRecord, + GermplasmOriginRecord, + GermplasmSynonymRecord, + GermplasmTaxonRecord, +} from "./profileTypes"; +import { fetchGermplasmDetail } from "./api"; + +interface BrapiSingleResponse { + result: T; +} + +interface BrapiListResult { + result: { data: T[] }; +} + +type RawGermplasmDetail = Record & { + donors?: Array>; + germplasmOrigin?: Array>; + synonyms?: Array>; + taxonIds?: Array>; +}; + +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(path: string, init?: RequestInit): Promise { + 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; +} + +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) => { + const coordinates = item.coordinates as Record | undefined; + const geometry = coordinates?.geometry as Record | 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 { + return fetchGermplasmDetail(germplasmDbId) as unknown as RawGermplasmDetail; +} + +export async function fetchDonorRows(germplasmDbId: string): Promise { + 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 { + 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 { + 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 { + 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): 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 { + const response = await request>>( + `/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( + germplasmDbId: string, + loadRows: (id: string) => Promise, + saveRows: (id: string, rows: T[]) => Promise, + mutate: (rows: T[]) => T[], +) { + const rows = await loadRows(germplasmDbId); + await saveRows(germplasmDbId, mutate(rows)); +} + +export async function createDonorRow(germplasmDbId: string, payload: Record) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) => ({ + 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) { + const response = await request>>( + `/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) { + const response = await request>>( + `/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; +} diff --git a/frontend/src/app/(app)/germplasm/germplasm/profileTypes.ts b/frontend/src/app/(app)/germplasm/germplasm/profileTypes.ts new file mode 100644 index 0000000..4f4e894 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/germplasm/profileTypes.ts @@ -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"; diff --git a/frontend/src/app/(app)/germplasm/germplasm/types.ts b/frontend/src/app/(app)/germplasm/germplasm/types.ts index 8ec8f74..685e6cc 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/types.ts +++ b/frontend/src/app/(app)/germplasm/germplasm/types.ts @@ -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; diff --git a/frontend/src/app/(app)/germplasm/seed-lot/[seedLotDbId]/page.tsx b/frontend/src/app/(app)/germplasm/seed-lot/[seedLotDbId]/page.tsx new file mode 100644 index 0000000..4563b54 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/seed-lot/[seedLotDbId]/page.tsx @@ -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 {STOCK_STATUS_LABEL[status]}; +} + +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(initialTab); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState> | 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 ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "批次不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {detail.name || detail.id} + + + +
批次 ID:{detail.id}
+
+ 库存: + {detail.amount ?? "—"}{detail.units ? ` ${detail.units}` : ""} + +
+
项目:{detail.program_name || "—"}
+
地点:{detail.location_name || "—"}
+
库位:{detail.storage_location || "—"}
+
来源集合:{detail.source_collection || "—"}
+
创建:{detail.created_date || "—"}
+
最后更新:{detail.last_updated || "—"}
+
+ 说明:{detail.seed_lot_description || "—"} +
+
+
+ + setActiveTab(value as SeedLotDetailTab)} + className="flex min-h-full flex-col gap-4" + > + + + + Content Mixture + + + + Transactions + + + + + + + + + + + +
+ ); +} + +export default function SeedLotDetailPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/app/(app)/germplasm/seed-lot/api.ts b/frontend/src/app/(app)/germplasm/seed-lot/api.ts index 2ddaae7..ba57b26 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/api.ts +++ b/frontend/src/app/(app)/germplasm/seed-lot/api.ts @@ -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("批次组成占比需�?0 �?100 之间"); + throw new Error("批次组成占比需在 0 到 100 之间"); } return { @@ -267,8 +271,25 @@ export function normalizeSeedLotFormData(record: SeedLotRecord): Record { + 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>("/brapi/v2/seedlots?page=0&pageSize=10"); + const response = await request>("/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 { - return seedLotRowsLoader.load(force); +export async function fetchSeedLotRows(query?: SeedLotQuery, force = false): Promise { + const rows = await seedLotRowsLoader.load(force); + return filterSeedLotRows(rows, query); } export async function fetchSeedLotDetail(id: string): Promise { @@ -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 { + 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 { + 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 { const units = optionalText(payload.units); if (!units) throw new Error("请选择数量单位"); @@ -345,20 +394,70 @@ export async function deleteSeedLotRow(id: string): Promise { invalidateSeedLotRowsCache(); } -export async function fetchSeedLotTransactions(seedLotDbId?: string): Promise { +export async function fetchSeedLotTransactions(query?: SeedLotTransactionQuery): Promise { const [transactionsResponse, seedLots] = await Promise.all([ request>( - 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, +) => { + 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("出库/消�?报废建议填写流转说明"); + 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("消") - ? `消�?报废:${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]), ); diff --git a/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotContentMixturePanel.tsx b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotContentMixturePanel.tsx new file mode 100644 index 0000000..a7fff2a --- /dev/null +++ b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotContentMixturePanel.tsx @@ -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(null); + const [success, setSuccess] = useState(null); + const [rows, setRows] = useState([]); + const [germplasmOptions, setGermplasmOptions] = useState([]); + const [crossOptions, setCrossOptions] = useState([]); + 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 ; + } + + return ( +
+ + + + + Content Mixture 批次组成 + + + +

+ 维护 `seed_lot_content_mixture`:每行需指定 germplasm 或 cross 来源,占比合计建议为 100%。 +

+ +
+
+ 占比进度 + + {totalPercentage}% / 100% + +
+
+
+
+
+ + + + {error ? ( +

{error}

+ ) : null} + {success ? ( +

{success}

+ ) : null} + +
+ +
+ + + +

+ 组成中的 germplasm 可跳转至 + {" "} + 种质详情 + ;cross 可跳转至 + {" "} + 杂交列表 + 。 +

+
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTab.tsx b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTab.tsx index cd13088..feb2763 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTab.tsx +++ b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTab.tsx @@ -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([]); const [germplasmOptions, setGermplasmOptions] = useState([]); const [crossOptions, setCrossOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); const applyOptions = useCallback((options: Awaited>) => { 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[]; - }, [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(() => ( +
+
+
+ + +
+
+ + +
+
+ + setDraftQuery((current) => ({ ...current, name: event.target.value }))} + placeholder="名称子串匹配" + /> +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, locationOptions, programOptions]); + const renderFormExtra = useCallback((props: { formData: Record; updateForm: (key: string, value: string) => void; @@ -104,6 +214,7 @@ export function SeedLotTab() { editingRow: Record | 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 ? (
- +
) : null} {!isEditing ? (
- + -

新建时选择主材料将自动生成一条 100% 批次组成。

+

新建时选择主材料将自动生成一条 100% 批次组成;也可创建后在详情页维护。

) : null} @@ -157,11 +262,7 @@ export function SeedLotTab() { {isEditing ? "当前库存(只读)" : "初始库存数量"} {isEditing ? ( - + ) : ( )}

- {isEditing ? "库存数量请通过「库存交易」Tab 的入库/出库/转移等动作更新。" : "也可创建后在「库存交易」中执行入库。"} + {isEditing ? "库存数量请通过「库存交易」更新。" : "也可创建后在「库存交易」中执行入库。"}

{isEditing ? (
- +
) : null} - props.updateFormBatch({ content_mixture: rows })} - /> + {isEditing && seedLotId ? ( +
+ 批次组成请在 + {" "} + + 批次详情 → Content Mixture + + {" "} + Tab 中维护。 +
+ ) : ( + 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 ( + + {name} + + ); + }, + }, { key: "stock_status", label: "库存状态", @@ -230,11 +359,26 @@ export function SeedLotTab() { render: (_, row) => { const mixtures = (row.content_mixture ?? row.contentMixture) as Array> | 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 ( + + {index > 0 ? ";" : ""} + + {text} + + + ); + } + return `${index > 0 ? ";" : ""}${text}`; + }); }, }, { key: "program_name", label: "项目" }, @@ -257,13 +401,7 @@ export function SeedLotTab() { }; return createSeedLotRow(normalized) as unknown as Promise>; }} - updateRecord={async (id, payload) => { - const normalized = { - ...payload, - content_mixture: payload.content_mixture ?? mapContentMixtureToForm([]), - }; - return updateSeedLotRow(id, normalized) as unknown as Promise>; - }} + updateRecord={async (id, payload) => updateSeedLotMetadata(id, payload) as unknown as Promise>} deleteRecord={deleteSeedLotRow} /> ); diff --git a/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionPanel.tsx b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionPanel.tsx new file mode 100644 index 0000000..7e29df5 --- /dev/null +++ b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionPanel.tsx @@ -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 其他; + } + 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 {TRANSACTION_ACTION_LABEL[action]}; +} + +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([]); + const [seedLots, setSeedLots] = useState([]); + const [seedLotOptions, setSeedLotOptions] = useState([]); + const [programOptions, setProgramOptions] = useState([]); + const [locationOptions, setLocationOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [draftQuery, setDraftQuery] = useState(() => ({ + ...emptyQuery(), + seed_lot_id: seedLotDbId ?? NONE_SELECT_VALUE, + })); + const [appliedQuery, setAppliedQuery] = useState(() => ({ + ...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 ( +
+ {!embedded ? ( +
+
+
+ +
+
+

库存交易

+

+ 通过入库、出库、转移、分装、消耗/报废等业务动作记录 seed_lot_transaction +

+
+
+ +
+ ) : ( +
+

+ 本批次相关流转记录(含来源/目标);交易创建后会自动更新批次库存。 +

+ +
+ )} + + {!embedded ? ( +
+ + GET /brapi/v2/seedlots/transactions + + + POST /brapi/v2/seedlots/transactions + +
+ ) : null} + +
+
+ {!seedLotDbId ? ( +
+ + +
+ ) : null} +
+ + +
+
+ + +
+
+ + setDraftQuery((current) => ({ ...current, date_from: event.target.value }))} + /> +
+
+ + setDraftQuery((current) => ({ ...current, date_to: event.target.value }))} + /> +
+
+ + setDraftQuery((current) => ({ ...current, keyword: event.target.value }))} + placeholder="交易 ID、说明、批次..." + /> +
+
+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + + + # + 动作 + 来源批次 + 目标批次 + 数量 + 时间 + 说明 + + + + {loading ? ( + Array.from({ length: 6 }).map((_, index) => ( + + + {Array.from({ length: 6 }).map((__, cellIndex) => ( + + ))} + + )) + ) : pagedRows.length === 0 ? ( + + + 暂无交易记录,点击「新建交易」开始录入 + + + ) : ( + pagedRows.map((row, index) => { + const action = inferTransactionAction(row); + return ( + + {(page - 1) * pageSize + index + 1} + {actionBadge(action)} + {row.from_seed_lot_name || row.from_seed_lot_id || "—"} + {row.to_seed_lot_name || row.to_seed_lot_id || "—"} + {row.amount ?? "—"}{row.units ? ` ${row.units}` : ""} + {row.timestamp ? String(row.timestamp).slice(0, 19).replace("T", " ") : "—"} + {row.description || "—"} + + ); + }) + )} + +
+
+ +
+

共 {rows.length} 条记录,第 {page}/{totalPages} 页

+
+ + + + + setPage((prev) => Math.max(1, prev - 1))} disabled={page <= 1} /> + + {Array.from({ length: totalPages }).slice(0, 5).map((_, idx) => { + const pageNumber = idx + 1; + return ( + + setPage(pageNumber)}>{pageNumber} + + ); + })} + + setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page >= totalPages} /> + + + +
+
+ + +
+ ); +} + +function optionalQueryValue(value: string | undefined) { + if (!value || value === NONE_SELECT_VALUE) return undefined; + return value; +} diff --git a/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionTab.tsx b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionTab.tsx index 2b191dd..83a7552 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionTab.tsx +++ b/frontend/src/app/(app)/germplasm/seed-lot/components/SeedLotTransactionTab.tsx @@ -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 其他; - } - 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 {TRANSACTION_ACTION_LABEL[action]}; -} +import { SeedLotTransactionPanel } from "./SeedLotTransactionPanel"; export function SeedLotTransactionTab() { - const [rows, setRows] = useState([]); - const [seedLots, setSeedLots] = useState([]); - const [seedLotOptions, setSeedLotOptions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
-
-
-
- -
-
-

库存交易

-

- 通过入库、出库、转移、分装、消耗/报废等业务动作记录 seed_lot_transaction -

-
-
- -
- -
- - GET /brapi/v2/seedlots/transactions - - - POST /brapi/v2/seedlots/transactions - -
- -
- -
- - setSearch(event.target.value)} - className="bg-white pl-9 dark:bg-slate-950" - /> -
-
- - {error ? ( -
- {error} -
- ) : null} - -
- - - - # - 动作 - 来源批次 - 目标批次 - 数量 - 时间 - 说明 - - - - {loading ? ( - Array.from({ length: 6 }).map((_, index) => ( - - - {Array.from({ length: 6 }).map((__, cellIndex) => ( - - ))} - - )) - ) : pagedRows.length === 0 ? ( - - - 暂无交易记录,点击右上角「新建交易」开始录入 - - - ) : ( - pagedRows.map((row, index) => { - const action = inferTransactionAction(row); - return ( - - {(page - 1) * pageSize + index + 1} - {actionBadge(action)} - {row.from_seed_lot_name || row.from_seed_lot_id || "—"} - {row.to_seed_lot_name || row.to_seed_lot_id || "—"} - {row.amount ?? "—"}{row.units ? ` ${row.units}` : ""} - {row.timestamp ? String(row.timestamp).slice(0, 19).replace("T", " ") : "—"} - {row.description || "—"} - - ); - }) - )} - -
-
- -
-

共 {filteredRows.length} 条记录,第 {page}/{totalPages} 页

-
- - - - - setPage((prev) => Math.max(1, prev - 1))} disabled={page <= 1} /> - - {Array.from({ length: totalPages }).slice(0, 5).map((_, idx) => { - const pageNumber = idx + 1; - return ( - - setPage(pageNumber)}>{pageNumber} - - ); - })} - - setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page >= totalPages} /> - - - -
-
- - { - void loadData(true); - }} - /> -
- ); + return ; } diff --git a/frontend/src/app/(app)/germplasm/seed-lot/components/TransactionActionDialog.tsx b/frontend/src/app/(app)/germplasm/seed-lot/components/TransactionActionDialog.tsx index 47d8d09..4bf5d72 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/components/TransactionActionDialog.tsx +++ b/frontend/src/app/(app)/germplasm/seed-lot/components/TransactionActionDialog.tsx @@ -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(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); } }; diff --git a/frontend/src/app/(app)/germplasm/seed-lot/page.tsx b/frontend/src/app/(app)/germplasm/seed-lot/page.tsx index 8a33533..8c36df8 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/page.tsx +++ b/frontend/src/app/(app)/germplasm/seed-lot/page.tsx @@ -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 ( @@ -36,3 +45,11 @@ export default function SeedLotPage() { ); } + +export default function SeedLotPage() { + return ( + 加载 Seed Lot 页…}> + + + ); +} diff --git a/frontend/src/app/(app)/germplasm/seed-lot/types.ts b/frontend/src/app/(app)/germplasm/seed-lot/types.ts index 2347f22..91d2825 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/types.ts +++ b/frontend/src/app/(app)/germplasm/seed-lot/types.ts @@ -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"; diff --git a/src/main/java/org/brapi/test/BrAPITestServer/controller/germ/GermplasmProfileWriteController.java b/src/main/java/org/brapi/test/BrAPITestServer/controller/germ/GermplasmProfileWriteController.java new file mode 100644 index 0000000..b49c1ce --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/controller/germ/GermplasmProfileWriteController.java @@ -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> 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 data = germplasmService.findInstitutes(germplasmDbId); + Map 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> 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 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> 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 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> 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 result = new HashMap<>(); + result.put("data", List.of(data)); + return ResponseEntity.ok(wrapOk(result)); + } + + private Map wrapOk(Map result) { + Map response = new HashMap<>(); + response.put("metadata", generateEmptyMetadata()); + response.put("result", result); + return response; + } + +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteRecord.java b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteRecord.java new file mode 100644 index 0000000..9d2607b --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteRecord.java @@ -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; + } + +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteWriteRequest.java b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteWriteRequest.java new file mode 100644 index 0000000..c781806 --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/germ/GermplasmInstituteWriteRequest.java @@ -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; + } + +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/repository/germ/GermplasmInstituteRepository.java b/src/main/java/org/brapi/test/BrAPITestServer/repository/germ/GermplasmInstituteRepository.java new file mode 100644 index 0000000..f0e7079 --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/repository/germ/GermplasmInstituteRepository.java @@ -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 { + +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/service/germ/GermplasmService.java b/src/main/java/org/brapi/test/BrAPITestServer/service/germ/GermplasmService.java index 26361d4..3adfed7 100644 --- a/src/main/java/org/brapi/test/BrAPITestServer/service/germ/GermplasmService.java +++ b/src/main/java/org/brapi/test/BrAPITestServer/service/germ/GermplasmService.java @@ -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 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; + } + } diff --git a/src/main/java/org/brapi/test/BrAPITestServer/service/germ/SeedLotService.java b/src/main/java/org/brapi/test/BrAPITestServer/service/germ/SeedLotService.java index 9ab9c10..7910fec 100644 --- a/src/main/java/org/brapi/test/BrAPITestServer/service/germ/SeedLotService.java +++ b/src/main/java/org/brapi/test/BrAPITestServer/service/germ/SeedLotService.java @@ -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);