1- import { useState , useCallback , useEffect , useRef } from "react" ;
1+ import { useState , useCallback , useEffect , useMemo , useRef } from "react" ;
22import { useNavigate } from "react-router-dom" ;
33import { useQuery } from "@tanstack/react-query" ;
44import { useMatch } from "./useMatch" ;
@@ -9,15 +9,33 @@ import { LoadingSpinner } from "../../components/ui/LoadingSpinner";
99import { ErrorMessage } from "../../components/ui/ErrorMessage" ;
1010import { EmptyState } from "../../components/ui/EmptyState" ;
1111import { fetchOkhDetail } from "../../api/okh" ;
12+ import type { MatchSolution } from "../../types/match" ;
1213import type { RfqNavigationState } from "../../types/rfq" ;
13-
14- const KEY_SELECTED = "ohm_v1_match_selected ";
14+ import { MATCH_SESSION } from "./matchSessionKeys" ;
15+ import { solutionRowId } from "./solutionRowId ";
1516
1617interface Props {
1718 okhId ?: string ;
1819 autoRun ?: boolean ;
1920}
2021
22+ function dedupeSolutionsByFacility ( input : MatchSolution [ ] ) : MatchSolution [ ] {
23+ const byFacility = new Map < string , MatchSolution > ( ) ;
24+ for ( const sol of input ) {
25+ const key = sol . facility_id || sol . facility_name || solutionRowId ( sol ) ;
26+ const existing = byFacility . get ( key ) ;
27+ if ( ! existing ) {
28+ byFacility . set ( key , sol ) ;
29+ continue ;
30+ }
31+ // Keep the strongest candidate; stable fallback to earlier rank.
32+ if ( sol . score > existing . score || ( sol . score === existing . score && sol . rank < existing . rank ) ) {
33+ byFacility . set ( key , sol ) ;
34+ }
35+ }
36+ return Array . from ( byFacility . values ( ) ) . sort ( ( a , b ) => a . rank - b . rank ) ;
37+ }
38+
2139function SessionRestoredBanner ( {
2240 savedAt,
2341 onClear,
@@ -53,7 +71,7 @@ export function MatchView({ okhId, autoRun = false }: Props) {
5371 // selectedIds: local state seeded from sessionStorage
5472 const [ selectedIds , setSelectedIdsState ] = useState < Set < string > > ( ( ) => {
5573 try {
56- const raw = sessionStorage . getItem ( KEY_SELECTED ) ;
74+ const raw = sessionStorage . getItem ( MATCH_SESSION . selected ) ;
5775 return raw ? new Set ( JSON . parse ( raw ) as string [ ] ) : new Set < string > ( ) ;
5876 } catch {
5977 return new Set < string > ( ) ;
@@ -63,11 +81,12 @@ export function MatchView({ okhId, autoRun = false }: Props) {
6381 const setSelectedIds = useCallback ( ( ids : Set < string > ) => {
6482 setSelectedIdsState ( ids ) ;
6583 try {
66- sessionStorage . setItem ( KEY_SELECTED , JSON . stringify ( Array . from ( ids ) ) ) ;
84+ sessionStorage . setItem ( MATCH_SESSION . selected , JSON . stringify ( Array . from ( ids ) ) ) ;
6785 } catch { /* quota exceeded */ }
6886 } , [ ] ) ;
6987
70- const autoRunFired = useRef ( false ) ;
88+ /** Prevents duplicate autorun for the same URL okh_id (e.g. React StrictMode remount). */
89+ const autorunTargetRef = useRef < string | null > ( null ) ;
7190 // Keep a stable ref so the autoRun effect doesn't re-fire on every render
7291 // when `trigger` (recreated each render) changes reference.
7392 const triggerRef = useRef < typeof trigger | null > ( null ) ;
@@ -87,13 +106,13 @@ export function MatchView({ okhId, autoRun = false }: Props) {
87106 processingTime,
88107 expandedRank,
89108 toggleExpanded,
90- matchOkhId : _sessionOkhId ,
91109 savedAt,
92110 reset,
93- } = useMatch ( ) ;
111+ } = useMatch ( okhId ) ;
94112
95113 // Keep ref in sync so autoRun effect always calls the latest trigger.
96114 triggerRef . current = trigger ;
115+ const displaySolutions = useMemo ( ( ) => dedupeSolutionsByFacility ( solutions ) , [ solutions ] ) ;
97116
98117 const { data : okhDetail } = useQuery ( {
99118 queryKey : [ "okh-detail-for-rfq" , okhId ] ,
@@ -103,10 +122,10 @@ export function MatchView({ okhId, autoRun = false }: Props) {
103122 } ) ;
104123
105124 const handleSelect = useCallback (
106- ( facilityId : string , checked : boolean ) => {
125+ ( rowId : string , checked : boolean ) => {
107126 const next = new Set ( selectedIds ) ;
108- if ( checked ) next . add ( facilityId ) ;
109- else next . delete ( facilityId ) ;
127+ if ( checked ) next . add ( rowId ) ;
128+ else next . delete ( rowId ) ;
110129 setSelectedIds ( next ) ;
111130 } ,
112131 [ selectedIds , setSelectedIds ]
@@ -118,22 +137,27 @@ export function MatchView({ okhId, autoRun = false }: Props) {
118137 setSelectedIds ( new Set < string > ( ) ) ;
119138 } , [ reset , setSelectedIds ] ) ;
120139
140+ // Reset autorun latch when switching designs so each okh_id can autorun once.
141+ useEffect ( ( ) => {
142+ autorunTargetRef . current = null ;
143+ } , [ okhId ] ) ;
144+
121145 // Auto-trigger match when navigated here from "Run Match ⚡".
122146 // Skip if we already have session results for this exact OKH — the user
123147 // can explicitly re-run via the "Re-run Match" button if they want fresh results.
124148 // `trigger` is intentionally accessed via ref so this effect is NOT re-run
125149 // every render (trigger changes reference on every render).
126150 useEffect ( ( ) => {
127- if ( autoRun && okhId && ! autoRunFired . current && ! hasResult ) {
128- autoRunFired . current = true ;
129- triggerRef . current ?. ( okhId ) ;
130- }
151+ if ( ! autoRun || ! okhId || hasResult ) return ;
152+ if ( autorunTargetRef . current === okhId ) return ;
153+ autorunTargetRef . current = okhId ;
154+ triggerRef . current ?. ( okhId ) ;
131155 // eslint-disable-next-line react-hooks/exhaustive-deps
132156 } , [ autoRun , okhId , hasResult ] ) ;
133157
134158 const handleGenerateRfq = useCallback ( ( ) => {
135- const selectedSolutions = solutions . filter ( ( s ) =>
136- selectedIds . has ( s . facility_id )
159+ const selectedSolutions = displaySolutions . filter ( ( s ) =>
160+ selectedIds . has ( solutionRowId ( s ) )
137161 ) ;
138162 const state : RfqNavigationState = {
139163 okhId : okhId ! ,
@@ -143,7 +167,16 @@ export function MatchView({ okhId, autoRun = false }: Props) {
143167 solutions : selectedSolutions ,
144168 } ;
145169 navigate ( "/rfq" , { state } ) ;
146- } , [ solutions , selectedIds , okhId , okhDetail , navigate ] ) ;
170+ } , [ displaySolutions , selectedIds , okhId , okhDetail , navigate ] ) ;
171+
172+ // Prune selected ids when a newer run returns fewer/different unique rows.
173+ useEffect ( ( ) => {
174+ const valid = new Set ( displaySolutions . map ( ( s ) => solutionRowId ( s ) ) ) ;
175+ const next = new Set ( Array . from ( selectedIds ) . filter ( ( id ) => valid . has ( id ) ) ) ;
176+ if ( next . size !== selectedIds . size ) {
177+ setSelectedIds ( next ) ;
178+ }
179+ } , [ displaySolutions , selectedIds , setSelectedIds ] ) ;
147180
148181 if ( ! okhId ) {
149182 return (
@@ -282,40 +315,41 @@ export function MatchView({ okhId, autoRun = false }: Props) {
282315 < div >
283316 < div className = "mb-3 flex items-center justify-between" >
284317 < h2 className = "text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400" >
285- { solutions . length } candidate{ solutions . length !== 1 ? "s" : "" } — tick to select, click to expand
318+ { displaySolutions . length } candidate{ displaySolutions . length !== 1 ? "s" : "" } — tick to select, click to expand
286319 </ h2 >
287- { solutions . length > 0 && (
320+ { displaySolutions . length > 0 && (
288321 < button
289322 onClick = { ( ) => {
290- if ( selectedIds . size === solutions . length ) {
323+ if ( selectedIds . size === displaySolutions . length ) {
291324 setSelectedIds ( new Set < string > ( ) ) ;
292325 } else {
293- setSelectedIds ( new Set ( solutions . map ( ( s ) => s . facility_id ) ) ) ;
326+ setSelectedIds ( new Set ( displaySolutions . map ( ( s ) => solutionRowId ( s ) ) ) ) ;
294327 }
295328 } }
296329 className = "text-xs text-indigo-600 hover:underline dark:text-indigo-400"
297330 >
298- { selectedIds . size === solutions . length ? "Deselect all" : "Select all" }
331+ { selectedIds . size === displaySolutions . length ? "Deselect all" : "Select all" }
299332 </ button >
300333 ) }
301334 </ div >
302335
303- { solutions . length === 0 ? (
336+ { displaySolutions . length === 0 ? (
304337 < EmptyState
305338 icon = "🔍"
306339 heading = "No results returned"
307340 body = "The match completed but found no candidate solutions."
308341 />
309342 ) : (
310343 < div className = "space-y-3" >
311- { solutions . map ( ( sol ) => (
344+ { displaySolutions . map ( ( sol ) => (
312345 < MatchResultCard
313- key = { sol . facility_id }
346+ key = { solutionRowId ( sol ) }
314347 solution = { sol }
348+ selectionId = { solutionRowId ( sol ) }
315349 isExpanded = { expandedRank === sol . rank }
316350 onToggle = { ( ) => toggleExpanded ( sol . rank ) }
317351 solutionId = { solutionId }
318- isSelected = { selectedIds . has ( sol . facility_id ) }
352+ isSelected = { selectedIds . has ( solutionRowId ( sol ) ) }
319353 onSelect = { handleSelect }
320354 />
321355 ) ) }
0 commit comments