@@ -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+
7811048def 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