Skip to content

Commit 79d6361

Browse files
dougborgclaude
andcommitted
feat(mcp): redesign build_item_detail_ui per #537 four-tier framework
Today's ``build_item_detail_ui`` is bare (~40 LOC) and surfaces almost nothing the agent needs: header + ID/UoM/category, a couple of state badges, plus SKU-gated footer buttons that never render (items don't carry a top-level SKU — SKUs live on variants). The response model (``ItemDetailsResponse``) carries variants, configs, and supplier, but none of them appear on the card. Apply the four-tier framework (Tier 1 Identity → Tier 2 Decision metrics → Tier 3 Reference → Tier 4 Actions) with sub-type variance in Tiers 2–4 since the relevant fields differ between Product, Material, and Service. Tier 1 — Identity Title wraps in a Link to ``katana_url`` (items have direct Katana pages, unlike variants). Type badge + status pills (sellable, producible for Product, batch / serial tracked for Product/Material, archived for all). Tier 2 — Decision metrics Text rows (not Metric components — too few numeric facts to warrant the visual weight): variant count (always), lead time + MOQ (Product only when set). Tier 3 — Reference UoM, category, purchase UoM + conversion rate (Product/Material), default supplier as Link to ``/contacts/suppliers/{id}`` (mirrors the variant card's supplier link from #696), configs as ``"Axis: val1, val2, val3"`` text rows, additional info, and the nested variants DataTable with per-row ``CallTool`` invoking ``get_variant_details`` directly — same pattern as ``build_search_results_ui``. No agent round-trip needed since the action is fully deterministic. Tier 4 — Actions (SendMessage rail — agent composes context) Material: Create Purchase Order + List MOs Using This + Modify Item. Producible Product: Create Manufacturing Order + Modify Item. Service: Modify Item only. No "View in Katana" button — the title link replaces it. The previous "ID: <id>" raw-text row was dropped per the module convention (IDs ride on ``structured_content`` for tooling). The SKU-gated footer buttons were dropped — they never rendered because items don't carry SKU at the top level. Variant drill-down is now the DataTable row click in Tier 3. Tests: 13 new behaviors covering title Link, dropped ID row, sub-type status pills, supplier Link with correct href, configs as text rows, variants DataTable with ``CallTool`` drill-down, per-sub-type footer button matrix (Material vs. Product producible vs. non-producible vs. Service). Closes #548. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 86d8881 commit 79d6361

4 files changed

Lines changed: 619 additions & 46 deletions

File tree

katana_mcp_server/src/katana_mcp/tools/foundation/items.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -605,12 +605,11 @@ class ItemVariantSummary(BaseModel):
605605

606606
id: int
607607
sku: str | None = None
608-
"""Variant SKU. ``None``-able to match Katana's wire contract — the
609-
platform allows variants without a SKU (legacy NetSuite imports are
610-
a common source, and there's no DB-level constraint that forces it
611-
non-null). Display-side consumers should coalesce to ``""`` when
612-
rendering; ``display_name`` below already provides a non-empty
613-
title in the rare SKU-less case.
608+
"""Variant SKU. ``None``-able to match Katana's wire contract —
609+
the platform has no DB-level constraint forcing SKU non-null, so
610+
consumers must tolerate ``None``. Display-side consumers should
611+
coalesce to ``""`` when rendering; ``display_name`` below already
612+
provides a non-empty title in the rare SKU-less case.
614613
"""
615614
sales_price: float | None = None
616615
purchase_price: float | None = None

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py

Lines changed: 313 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -778,51 +778,325 @@ def _price_display(p: float | None) -> str:
778778
return app
779779

780780

781+
def _item_header_section(item: dict[str, Any]) -> None:
782+
"""Render item card header: title (linked to Katana page), type badge,
783+
and status pills.
784+
785+
Title wraps in a real ``Link`` to ``katana_url`` so clicking opens
786+
the Katana product / material / service page directly — same
787+
convention as the variant card (see module docstring on linking
788+
Katana entities).
789+
790+
Status badges vary by sub-type:
791+
- Sellable / Not Sellable (all types)
792+
- Producible / Not Producible (Product only)
793+
- Batch tracked (Product / Material)
794+
- Serial tracked (Product / Material)
795+
- Archived (all types when ``is_archived`` is True)
796+
"""
797+
katana_url = item.get("katana_url")
798+
title_content = item.get("name", "Unknown")
799+
item_type = item.get("type", "")
800+
801+
with Row(gap=2):
802+
with CardTitle():
803+
if katana_url:
804+
Link(content=title_content, href=katana_url, target="_blank")
805+
else:
806+
Text(content=title_content)
807+
if item_type:
808+
Badge(label=str(item_type), variant="secondary")
809+
if item.get("is_archived"):
810+
Badge(label="Archived", variant="secondary")
811+
812+
# Status pills row. Order chosen to match the agent's typical
813+
# decision sequence — sellable first (can this be sold?), then
814+
# producible (can this be made?), then tracking flags (will I
815+
# need to specify a batch / serial when transacting?).
816+
with Row(gap=2):
817+
if item.get("is_sellable") is not None:
818+
Badge(
819+
label="Sellable" if item["is_sellable"] else "Not Sellable",
820+
variant="default" if item["is_sellable"] else "secondary",
821+
)
822+
if item.get("is_producible") is not None:
823+
Badge(
824+
label="Producible" if item["is_producible"] else "Not Producible",
825+
variant="default" if item["is_producible"] else "secondary",
826+
)
827+
if item.get("batch_tracked"):
828+
Badge(label="Batch tracked", variant="secondary")
829+
if item.get("serial_tracked"):
830+
Badge(label="Serial tracked", variant="secondary")
831+
832+
833+
def _item_metrics_section(item: dict[str, Any]) -> None:
834+
"""Render Tier 2 — decision metrics as text rows (not Metric components).
835+
836+
Items typically have ≤3 numeric facts (variant count, lead time, MOQ),
837+
so the Metric layout would be visually heavy for a sparse row. Plain
838+
text rows still give the agent the facts without competing visually
839+
with the more important variants table below.
840+
"""
841+
variants = item.get("variants") or []
842+
Text(content=f"Variants: {len(variants)}")
843+
if item.get("lead_time") is not None:
844+
Text(content=f"Lead Time: {item['lead_time']} days")
845+
if item.get("minimum_order_quantity") is not None:
846+
Text(content=f"Min Order Qty: {item['minimum_order_quantity']}")
847+
848+
849+
def _item_supplier_line(item: dict[str, Any]) -> None:
850+
"""Render the default supplier — preferring the nested ``supplier``
851+
dict (carries name + id), falling back to the flat
852+
``default_supplier_id`` field when the nested record is absent.
853+
854+
Real materials commonly have ``supplier=None`` while
855+
``default_supplier_id`` is set (see ``_FULL_MATERIAL_DICT`` in the
856+
test fixtures) — Katana only embeds the nested object when the
857+
relationship is fully populated. Without this fallback the card
858+
would silently omit any supplier reference for a common shape.
859+
860+
Render hierarchy:
861+
862+
1. Nested ``supplier`` with both ``name`` and ``id`` → ``Link``
863+
(name as visible text, href to ``/contacts/suppliers/{id}``).
864+
2. Nested ``supplier`` with only ``name`` → plain text.
865+
3. Flat ``default_supplier_id`` only → ``Link`` using ``#<id>`` as
866+
the visible text (no name available, but the ID is the
867+
authoritative identifier and the link still works).
868+
869+
Supplier appears on Products and Materials (not Services).
870+
"""
871+
supplier = item.get("supplier")
872+
nested_name = supplier.get("name") if isinstance(supplier, dict) else None
873+
nested_id = supplier.get("id") if isinstance(supplier, dict) else None
874+
875+
if nested_name and nested_id:
876+
supplier_url = katana_web_url("supplier", nested_id)
877+
if supplier_url:
878+
with Row(gap=1):
879+
Text(content="Default Supplier:")
880+
Link(content=nested_name, href=supplier_url, target="_blank")
881+
else:
882+
Text(content=f"Default Supplier: {nested_name}")
883+
return
884+
if nested_name:
885+
Text(content=f"Default Supplier: {nested_name}")
886+
return
887+
888+
# No nested supplier dict (or it lacks both fields) — fall back to
889+
# the flat top-level default_supplier_id. Common for materials
890+
# where Katana doesn't embed the supplier object even though the
891+
# FK is set.
892+
fallback_sid = item.get("default_supplier_id")
893+
if fallback_sid:
894+
supplier_url = katana_web_url("supplier", fallback_sid)
895+
if supplier_url:
896+
with Row(gap=1):
897+
Text(content="Default Supplier:")
898+
Link(
899+
content=f"#{fallback_sid}",
900+
href=supplier_url,
901+
target="_blank",
902+
)
903+
else:
904+
Text(content=f"Default Supplier ID: {fallback_sid}")
905+
906+
907+
def _item_configs_section(item: dict[str, Any]) -> None:
908+
"""Render configuration axis definitions as ``"Axis: val1, val2, val3"``
909+
text rows — one per axis. Only Product / Material items have
910+
configs; Services skip silently. Drops when the list is empty.
911+
"""
912+
configs = item.get("configs") or []
913+
for cfg in configs:
914+
if not isinstance(cfg, dict):
915+
continue
916+
name = cfg.get("name") or ""
917+
values = cfg.get("values") or []
918+
if name and values:
919+
joined = ", ".join(str(v) for v in values)
920+
Text(content=f"{name}: {joined}")
921+
922+
923+
def _item_variants_table(item: dict[str, Any]) -> None:
924+
"""Render the nested-variants DataTable.
925+
926+
Per-row click invokes ``get_variant_details`` directly via
927+
``CallTool`` (mirrors ``build_search_results_ui``) — Katana has no
928+
per-variant page so a Link isn't an option, but ``CallTool`` is
929+
cleaner than ``SendMessage`` here because the action is fully
930+
deterministic ("show me variant Y") and doesn't need agent
931+
composition. The variant card it triggers will show the same
932+
canonical ``display_name`` rendering, with its own Link back to
933+
the parent.
934+
935+
``ItemVariantSummary`` carries id / sku / sales_price / purchase_price
936+
/ type. The DataTable renders cells as plain strings — custom
937+
per-column formatting (monospace SKUs, currency-prefixed prices)
938+
is a follow-up if the Prefab component grows a per-column renderer
939+
hook. Hidden when the item has no variants (defensive — Katana
940+
always returns at least one variant per item in practice).
941+
"""
942+
variants = item.get("variants") or []
943+
if not variants:
944+
return
945+
DataTable(
946+
columns=[
947+
DataTableColumn(key="sku", header="SKU", sortable=True),
948+
DataTableColumn(
949+
key="sales_price",
950+
header="Sales Price",
951+
sortable=True,
952+
),
953+
DataTableColumn(
954+
key="purchase_price",
955+
header="Purchase Price",
956+
sortable=True,
957+
),
958+
],
959+
rows="{{ item.variants }}",
960+
search=True,
961+
paginated=True,
962+
pageSize=20,
963+
# Per-row click invokes get_variant_details using the row's
964+
# variant id, not its SKU. ``ItemVariantSummary.sku`` is
965+
# nullable (Katana allows variants without a SKU on the wire),
966+
# so a click on a SKU-less row would otherwise produce
967+
# ``arguments={"sku": null}`` and the tool would reject the
968+
# call with "must provide at least one of: sku, variant_id,
969+
# skus, variant_ids". ``id`` is always present on the
970+
# ``ItemVariantSummary`` shape, so this path stays clickable
971+
# for every row. DataTable's per-row ``{{ id }}`` binding
972+
# expands client-side from row data (not iframe state) so it
973+
# doesn't hit the #491 state-binding failure mode.
974+
onRowClick=CallTool(
975+
"get_variant_details",
976+
arguments={"variant_id": "{{ id }}"},
977+
on_success=SetState("detail", RESULT),
978+
on_error=ShowToast("{{ $error }}", variant="error"),
979+
),
980+
)
981+
with Slot(name="detail"):
982+
Muted(content="Click a row to see variant details")
983+
984+
985+
def _item_reference_section(item: dict[str, Any]) -> None:
986+
"""Render Tier 3 reference data: UoM, category, purchase UoM,
987+
default supplier (Linked), configs, additional info, and the
988+
nested variants table.
989+
"""
990+
if item.get("uom"):
991+
Text(content=f"UoM: {item['uom']}")
992+
if item.get("category_name"):
993+
Text(content=f"Category: {item['category_name']}")
994+
_variant_purchase_uom_line(item)
995+
_item_supplier_line(item)
996+
_item_configs_section(item)
997+
additional_info = item.get("additional_info")
998+
if additional_info:
999+
Text(content=f"Notes: {additional_info}")
1000+
_item_variants_table(item)
1001+
1002+
1003+
def _item_footer_section(item: dict[str, Any]) -> None:
1004+
"""Render Tier 4 action buttons keyed off item type.
1005+
1006+
All buttons emit ``SendMessage`` invocations of other tools —
1007+
correct use of the agent-prompt rail per the module docstring
1008+
convention (composes context the agent fills in, vs. a deterministic
1009+
URL which would be a Link). The title's external Link already covers
1010+
"open in Katana", so no footer button for that.
1011+
"""
1012+
item_id = item.get("id")
1013+
item_type = item.get("type") or "item"
1014+
if item_id is None:
1015+
return
1016+
1017+
if item_type == "material":
1018+
Button(
1019+
label="Create Purchase Order",
1020+
variant="outline",
1021+
on_click=SendMessage(f"Draft a purchase order for material_id {item_id}"),
1022+
)
1023+
Button(
1024+
label="List MOs Using This",
1025+
variant="outline",
1026+
on_click=SendMessage(
1027+
f"List manufacturing orders that use material_id {item_id}"
1028+
),
1029+
)
1030+
elif item_type == "product" and item.get("is_producible"):
1031+
Button(
1032+
label="Create Manufacturing Order",
1033+
variant="outline",
1034+
on_click=SendMessage(
1035+
f"Draft a manufacturing order for product_id {item_id}"
1036+
),
1037+
)
1038+
1039+
Button(
1040+
label="Modify Item",
1041+
variant="outline",
1042+
on_click=SendMessage(
1043+
f"I want to modify {item_type} {item_id} — what should I change?"
1044+
),
1045+
)
1046+
1047+
7811048
def build_item_detail_ui(
7821049
item: dict[str, Any],
7831050
) -> PrefabApp:
784-
"""Build a detail card for an item (product/material/service)."""
785-
with PrefabApp(state={"item": item}, css_class="p-4") as app, Card():
786-
with CardHeader(), Row(gap=2):
787-
CardTitle(content=item.get("name", "Unknown"))
788-
Badge(label=item.get("type", ""), variant="secondary")
789-
790-
with CardContent(), Column(gap=2):
791-
Text(content=f"ID: {item.get('id', 'N/A')}")
792-
if item.get("uom"):
793-
Text(content=f"Unit of Measure: {item['uom']}")
794-
_variant_purchase_uom_line(item)
795-
if item.get("category_name"):
796-
Text(content=f"Category: {item['category_name']}")
1051+
"""Build a detail card for an item (product / material / service).
1052+
1053+
Implements the four-tier framework from #537 with sub-type variance
1054+
in the metrics, reference, and footer sections:
1055+
1056+
- **Tier 1 — Identity**: title as external ``Link`` to the Katana
1057+
product / material / service page (no per-variant page in
1058+
Katana's web app — items DO have one); type badge; status pills
1059+
that vary by sub-type (sellable / producible / batch /
1060+
serial / archived).
1061+
- **Tier 2 — Decision metrics**: variant count (always), lead time
1062+
and MOQ (Product only). Text rows, not Metric components —
1063+
items have too few numeric facts to warrant the visual weight.
1064+
- **Tier 3 — Reference**: UoM, category, purchase UoM (P/M),
1065+
default supplier (P/M, rendered as Link to the Katana supplier
1066+
page), config-axis definitions (P/M), additional info, and the
1067+
nested variants table — a DataTable with per-row CallTool
1068+
drilling into ``get_variant_details``.
1069+
- **Tier 4 — Actions**: sub-type-specific SendMessage buttons:
1070+
``Create Purchase Order`` + ``List MOs Using This`` (materials),
1071+
``Create Manufacturing Order`` (producible products),
1072+
``Modify Item`` (all). No "View in Katana" footer button —
1073+
the title link replaces it.
1074+
1075+
Reference example: the variant card (#542 / #696) established the
1076+
same shape on a single-row entity; this card extends the pattern
1077+
to a parent entity with embedded children.
1078+
"""
1079+
# ``detail: None`` is seeded for the variants DataTable's
1080+
# ``Slot(name="detail")`` + ``on_success=SetState("detail", RESULT)``
1081+
# pattern. Without an explicit None seed the slot binds to an
1082+
# undefined key, which works in current Prefab renderers but matches
1083+
# the explicit-is-better-than-implicit contract from
1084+
# ``build_search_results_ui``'s state init and avoids edge cases
1085+
# around missing keys.
1086+
with (
1087+
PrefabApp(state={"item": item, "detail": None}, css_class="p-4") as app,
1088+
Card(),
1089+
):
1090+
with CardHeader(), Column(gap=2):
1091+
_item_header_section(item)
7971092

798-
with Row(gap=2):
799-
if item.get("is_sellable") is not None:
800-
Badge(
801-
label="Sellable" if item["is_sellable"] else "Not Sellable",
802-
variant="default" if item["is_sellable"] else "secondary",
803-
)
804-
if item.get("is_producible") is not None:
805-
Badge(
806-
label="Producible"
807-
if item["is_producible"]
808-
else "Not Producible",
809-
variant="default" if item["is_producible"] else "secondary",
810-
)
811-
if item.get("is_archived"):
812-
Badge(label="Archived", variant="secondary")
1093+
with CardContent(), Column(gap=3):
1094+
_item_metrics_section(item)
1095+
Separator()
1096+
_item_reference_section(item)
8131097

8141098
with CardFooter(), Row(gap=2):
815-
if item.get("sku"):
816-
Button(
817-
label="Get Variant Details",
818-
variant="outline",
819-
on_click=SendMessage(f"Get variant details for SKU {item['sku']}"),
820-
)
821-
Button(
822-
label="Check Inventory",
823-
variant="outline",
824-
on_click=SendMessage(f"Check inventory for SKU {item['sku']}"),
825-
)
1099+
_item_footer_section(item)
8261100
return app
8271101

8281102

0 commit comments

Comments
 (0)