@@ -136,73 +136,62 @@ def merge_set_results(original_df: pd.DataFrame, set_results: Dict) -> pd.DataFr
136136 return result_df
137137
138138
139- def render_missing_parts_by_set (set_results : Dict , merged_df : pd .DataFrame ,
139+ def render_missing_parts_by_set (set_results : Dict , merged_df : pd .DataFrame ,
140140 part_images_map : Dict , ba_part_names : Dict ,
141141 color_lookup : Dict ) -> None :
142142 """
143- Render missing parts grouped by set in a separate section.
144-
145- This function displays set search results in a dedicated section,
146- showing which parts from the wanted list can be found in owned sets.
147- Parts are grouped by set for easy identification.
148-
149- Args:
150- set_results: Dictionary mapping (part_num, color_name) to list of set locations
151- merged_df: Original merged dataframe (for part details)
152- part_images_map: Dictionary mapping part numbers to image URLs
153- ba_part_names: Dictionary mapping part numbers to BrickArchitect names
154- color_lookup: Dictionary for color rendering (maps color_id to color info dict)
155-
156- Requirements: 7.2, 7.8, 8.3
143+ Render missing parts grouped by set, with color/missing/available/found columns
144+ matching the location-based layout.
157145 """
158146 import streamlit as st
159147 from core .data .colors import render_color_cell
160-
148+ from core .infrastructure .session import short_key
149+
161150 if not set_results :
162151 return
163-
152+
164153 st .markdown ("---" )
165- st .markdown ("### 📦 Missing Parts Grouped by Set " )
154+ st .markdown ("### 📦 Missing Parts Found in Owned Sets " )
166155 st .markdown ("Parts from your wanted list that can be found in your owned sets:" )
167-
156+
168157 # Build reverse color lookup: color_name -> color_id
169158 color_name_to_id = {}
170159 for color_id , color_info in color_lookup .items ():
171160 color_name = color_info .get ("name" , "" )
172161 if color_name :
173162 color_name_to_id [color_name ] = color_id
174-
175- # Reorganize results by set instead of by part
163+
164+ # Initialize set_found_counts in session state
165+ if "set_found_counts" not in st .session_state :
166+ st .session_state ["set_found_counts" ] = {}
167+
168+ # Reorganize results by set
176169 sets_dict = {}
177170 for (part_num , color_name ), locations in set_results .items ():
178171 for location_info in locations :
179172 set_number = location_info ["set_number" ]
180173 set_name = location_info ["set_name" ]
181174 set_key = f"{ set_number } - { set_name } "
182-
175+
183176 if set_key not in sets_dict :
184177 sets_dict [set_key ] = []
185-
186- # Convert color name back to color ID for matching with merged_df
178+
187179 color_id = color_name_to_id .get (color_name )
188-
189- # Find the wanted and have quantities from merged_df
180+
181+ # Find wanted and have quantities from merged_df
190182 qty_wanted = 0
191183 qty_have = 0
192-
193184 if color_id is not None :
194185 matching_rows = merged_df [
195- (merged_df ["Part" ].astype (str ) == str (part_num )) &
186+ (merged_df ["Part" ].astype (str ) == str (part_num )) &
196187 (merged_df ["Color" ].astype (str ) == str (color_id ))
197188 ]
198-
199189 if not matching_rows .empty :
200190 qty_wanted = int (matching_rows .iloc [0 ].get ("Quantity_wanted" , 0 ))
201191 qty_have = int (matching_rows .iloc [0 ].get ("Quantity_have" , 0 ))
202-
203- # Calculate missing quantity (wanted - have in collection)
192+
204193 qty_missing = max (0 , qty_wanted - qty_have )
205-
194+
206195 sets_dict [set_key ].append ({
207196 "part_num" : part_num ,
208197 "color_name" : color_name ,
@@ -211,8 +200,8 @@ def render_missing_parts_by_set(set_results: Dict, merged_df: pd.DataFrame,
211200 "qty_missing" : qty_missing ,
212201 "is_spare" : location_info .get ("is_spare" , False )
213202 })
214-
215- # Display each set with its parts
203+
204+ # Display each set with its parts (layout matching location cards)
216205 for set_key , parts_list in sorted (sets_dict .items ()):
217206 with st .expander (f"📦 { set_key } ({ len (parts_list )} part type(s))" , expanded = True ):
218207 for part_info in parts_list :
@@ -222,46 +211,76 @@ def render_missing_parts_by_set(set_results: Dict, merged_df: pd.DataFrame,
222211 quantity = part_info ["quantity" ]
223212 qty_missing = part_info ["qty_missing" ]
224213 is_spare = part_info ["is_spare" ]
225-
226- # Get part image and name
214+
227215 img_url = part_images_map .get (str (part_num ), "" )
228216 ba_name = ba_part_names .get (str (part_num ), "" )
229-
230- # Display part
217+
231218 left , right = st .columns ([1 , 4 ])
232-
219+
233220 with left :
234- st .markdown (f"**{ part_num } **" )
221+ st .markdown (f"##### **{ part_num } **" )
235222 if ba_name :
236223 st .markdown (f"{ ba_name } " )
237-
238224 if img_url :
239225 st .image (img_url , width = 100 )
240226 else :
241227 st .text ("🚫 No image" )
242-
228+
243229 with right :
244- # Render color
230+ # Header row matching location card style
231+ header = st .columns ([2.5 , 1 , 1 , 2 ])
232+ header [0 ].markdown ("**Color**" )
233+ header [1 ].markdown ("**Missing**" )
234+ header [2 ].markdown ("**Available**" )
235+ header [3 ].markdown ("**Found**" )
236+
237+ # Color cell
245238 if color_id is not None :
246239 color_html = render_color_cell (color_id , color_lookup )
247240 else :
248241 color_html = f"<span>{ color_name } </span>"
249-
250- st .markdown (f"**Color:** { color_html } " , unsafe_allow_html = True )
251- st .markdown (f"**Missing:** { qty_missing } " )
252- st .markdown (f"**Available in set:** { quantity } " )
253-
254- if is_spare :
255- st .markdown ("**(Spare part)**" )
256-
257- if quantity >= qty_missing :
258- st .markdown (f"✅ **Sufficient quantity available**" )
242+
243+ # Available display with status indicator
244+ available_in_set = min (quantity , qty_missing )
245+ if available_in_set >= qty_missing :
246+ available_display = f"✅ { available_in_set } "
259247 else :
260- st .markdown (f"⚠️ **Partial match** ({ quantity } /{ qty_missing } )" )
261-
248+ available_display = f"⚠️ { available_in_set } "
249+
250+ if is_spare :
251+ available_display += " *(spare)*"
252+
253+ # Found input
254+ found_key = (part_num , color_name , set_key )
255+ current_found = st .session_state .get ("set_found_counts" , {}).get (found_key , 0 )
256+ max_found = min (quantity , qty_missing )
257+
258+ cols = st .columns ([2.5 , 1 , 1 , 2 ])
259+ cols [0 ].markdown (color_html , unsafe_allow_html = True )
260+ cols [1 ].markdown (f"{ qty_missing } " )
261+ cols [2 ].markdown (available_display )
262+
263+ widget_key = short_key ("set_found" , part_num , color_name , set_key )
264+ new_found = cols [3 ].number_input (
265+ " " , min_value = 0 , max_value = max (max_found , 1 ), value = int (current_found ), step = 1 ,
266+ key = widget_key , label_visibility = "collapsed"
267+ )
268+ if int (new_found ) != int (current_found ):
269+ if "set_found_counts" not in st .session_state :
270+ st .session_state ["set_found_counts" ] = {}
271+ st .session_state ["set_found_counts" ][found_key ] = int (new_found )
272+
273+ complete = new_found >= qty_missing
274+ cols [3 ].markdown (
275+ f"✅ Found all ({ new_found } /{ qty_missing } )"
276+ if complete
277+ else f"**Found:** { new_found } /{ qty_missing } "
278+ )
279+
262280 st .markdown ("---" )
263281
264282
283+
265284def render_set_search_section (merged_df : pd .DataFrame , sets_manager , color_lookup : Dict ) -> None :
266285 """
267286 Render set search interface for parts not found or insufficient.
@@ -352,15 +371,19 @@ def render_set_search_section(merged_df: pd.DataFrame, sets_manager, color_looku
352371 col1 , col2 = st .columns ([1 , 1 ])
353372 with col1 :
354373 if st .button (f"Select All" , key = f"select_all_{ source_name } " ):
355- # Add all sets from this source to selected sets
356374 for set_data in fetched_sets :
357- st .session_state ["selected_sets_for_search" ].add (set_data ["set_number" ])
375+ set_num = set_data ["set_number" ]
376+ st .session_state ["selected_sets_for_search" ].add (set_num )
377+ # Sync the checkbox widget state so it doesn't override on rerun
378+ st .session_state [f"set_checkbox_{ set_num } " ] = True
358379 st .rerun ()
359380 with col2 :
360381 if st .button (f"Deselect All" , key = f"deselect_all_{ source_name } " ):
361- # Remove all sets from this source from selected sets
362382 for set_data in fetched_sets :
363- st .session_state ["selected_sets_for_search" ].discard (set_data ["set_number" ])
383+ set_num = set_data ["set_number" ]
384+ st .session_state ["selected_sets_for_search" ].discard (set_num )
385+ # Sync the checkbox widget state so it doesn't override on rerun
386+ st .session_state [f"set_checkbox_{ set_num } " ] = False
364387 st .rerun ()
365388
366389 # Display checkboxes for each set
@@ -369,22 +392,22 @@ def render_set_search_section(merged_df: pd.DataFrame, sets_manager, color_looku
369392 set_name = set_data .get ("set_name" , set_number )
370393 part_count = set_data .get ("part_count" , 0 )
371394
372- # Check if this set is selected (read from session state)
373- is_selected = set_number in st .session_state ["selected_sets_for_search" ]
374395 checkbox_label = f"{ set_number } - { set_name } ({ part_count } parts)"
375396
376- # Use on_change callback to update session state
377- def toggle_set (set_num = set_number ):
378- if set_num in st .session_state ["selected_sets_for_search" ]:
379- st .session_state ["selected_sets_for_search" ].discard (set_num )
380- else :
397+ # Use on_change callback to sync checkbox state back to selected_sets_for_search
398+ def sync_checkbox_to_set (set_num = set_number ):
399+ cb_key = f"set_checkbox_{ set_num } "
400+ if st .session_state .get (cb_key , False ):
381401 st .session_state ["selected_sets_for_search" ].add (set_num )
402+ else :
403+ st .session_state ["selected_sets_for_search" ].discard (set_num )
382404
405+ is_selected = set_number in st .session_state ["selected_sets_for_search" ]
383406 st .checkbox (
384407 checkbox_label ,
385408 value = is_selected ,
386409 key = f"set_checkbox_{ set_number } " ,
387- on_change = toggle_set ,
410+ on_change = sync_checkbox_to_set ,
388411 args = (set_number ,)
389412 )
390413
0 commit comments