11import SearchResult from './SearchResult' ;
22import { useSearchResults , useConsolidatedPolling } from '../../hooks/useCaseSearch' ;
33import { SearchResult as SearchResultType } from '../../../../shared/types' ;
4- import { useEffect , useMemo } from 'react' ;
4+ import { useEffect , useMemo , useState , useRef } from 'react' ;
5+ import { ArrowDownTrayIcon , CheckIcon , ClipboardDocumentIcon } from '@heroicons/react/24/outline' ;
6+ import { ZipCaseClient } from '../../services/ZipCaseClient' ;
57
68type DisplayItem = SearchResultType | 'divider' ;
79
@@ -12,6 +14,11 @@ function CaseResultItem({ searchResult }: { searchResult: SearchResultType }) {
1214export default function SearchResultsList ( ) {
1315 const { data, isLoading, isError, error } = useSearchResults ( ) ;
1416
17+ const [ copied , setCopied ] = useState ( false ) ;
18+ const copiedTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
19+
20+ const [ isExporting , setIsExporting ] = useState ( false ) ;
21+
1522 // Extract batches and create a flat display list with dividers
1623 const displayItems = useMemo ( ( ) => {
1724 if ( ! data || ! data . results || ! data . searchBatches ) {
@@ -58,6 +65,34 @@ export default function SearchResultsList() {
5865 // Use the consolidated polling approach for all non-terminal cases
5966 const polling = useConsolidatedPolling ( ) ;
6067
68+ // Function to copy all case numbers to clipboard
69+ const copyCaseNumbers = async ( ) => {
70+ if ( ! searchResults || searchResults . length === 0 ) {
71+ return ;
72+ }
73+
74+ // Extract case numbers, sort them alphanumerically, and join with newlines
75+ const caseNumbers = searchResults
76+ . map ( result => result . zipCase . caseNumber )
77+ . sort ( )
78+ . join ( '\n' ) ;
79+
80+ try {
81+ await navigator . clipboard . writeText ( caseNumbers ) ;
82+ setCopied ( true ) ;
83+
84+ // Clear any existing timeout
85+ if ( copiedTimeoutRef . current ) {
86+ clearTimeout ( copiedTimeoutRef . current ) ;
87+ }
88+
89+ // Reset copied state after 2 seconds
90+ copiedTimeoutRef . current = setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
91+ } catch ( err ) {
92+ console . error ( 'Failed to copy case numbers:' , err ) ;
93+ }
94+ } ;
95+
6196 // Start/stop polling based on whether we have non-terminal cases
6297 useEffect ( ( ) => {
6398 if ( searchResults . length > 0 ) {
@@ -80,6 +115,47 @@ export default function SearchResultsList() {
80115 } ;
81116 } , [ searchResults , polling ] ) ;
82117
118+ // Clean up timeout on unmount
119+ useEffect ( ( ) => {
120+ return ( ) => {
121+ if ( copiedTimeoutRef . current ) {
122+ clearTimeout ( copiedTimeoutRef . current ) ;
123+ }
124+ } ;
125+ } , [ ] ) ;
126+
127+ const handleExport = async ( ) => {
128+ const caseNumbers = searchResults . map ( r => r . zipCase . caseNumber ) ;
129+ if ( caseNumbers . length === 0 ) return ;
130+
131+ setIsExporting ( true ) ;
132+
133+ // Set a timeout to reset the exporting state after 10 seconds
134+ const timeoutId = setTimeout ( ( ) => {
135+ setIsExporting ( false ) ;
136+ } , 10000 ) ;
137+
138+ try {
139+ const client = new ZipCaseClient ( ) ;
140+ await client . cases . export ( caseNumbers ) ;
141+ } catch ( error ) {
142+ console . error ( 'Export failed:' , error ) ;
143+ } finally {
144+ clearTimeout ( timeoutId ) ;
145+ setIsExporting ( false ) ;
146+ }
147+ } ;
148+
149+ const isExportEnabled = useMemo ( ( ) => {
150+ if ( searchResults . length === 0 ) return false ;
151+ const terminalStates = [ 'complete' , 'failed' , 'notFound' ] ;
152+ return searchResults . every ( r => terminalStates . includes ( r . zipCase . fetchStatus . status ) ) ;
153+ } , [ searchResults ] ) ;
154+
155+ const exportableCount = useMemo ( ( ) => {
156+ return searchResults . filter ( r => r . zipCase . fetchStatus . status !== 'notFound' ) . length ;
157+ } , [ searchResults ] ) ;
158+
83159 if ( isError ) {
84160 console . error ( 'Error in useSearchResults:' , error ) ;
85161 }
@@ -102,7 +178,69 @@ export default function SearchResultsList() {
102178 < >
103179 { displayItems . length > 0 ? (
104180 < div className = "mt-8" >
105- < h3 className = "text-base font-semibold text-gray-900" > Search Results</ h3 >
181+ < div className = "flex justify-between items-center" >
182+ < h3 className = "text-base font-semibold text-gray-900" > Search Results</ h3 >
183+ < div className = "flex gap-2" >
184+ < button
185+ type = "button"
186+ onClick = { handleExport }
187+ disabled = { ! isExportEnabled || isExporting }
188+ className = { `inline-flex items-center gap-x-1.5 rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ${
189+ isExportEnabled && ! isExporting
190+ ? 'bg-white text-gray-900 ring-gray-300 hover:bg-gray-50'
191+ : 'bg-gray-100 text-gray-400 ring-gray-200 cursor-not-allowed'
192+ } `}
193+ title = {
194+ isExportEnabled
195+ ? `Export ${ exportableCount } case${ exportableCount === 1 ? '' : 's' } `
196+ : 'Wait for all cases to finish processing before exporting'
197+ }
198+ >
199+ { isExporting ? (
200+ < svg
201+ className = "animate-spin -ml-0.5 h-5 w-5 text-gray-400"
202+ xmlns = "http://www.w3.org/2000/svg"
203+ fill = "none"
204+ viewBox = "0 0 24 24"
205+ >
206+ < circle
207+ className = "opacity-25"
208+ cx = "12"
209+ cy = "12"
210+ r = "10"
211+ stroke = "currentColor"
212+ strokeWidth = "4"
213+ > </ circle >
214+ < path
215+ className = "opacity-75"
216+ fill = "currentColor"
217+ d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
218+ > </ path >
219+ </ svg >
220+ ) : (
221+ < ArrowDownTrayIcon
222+ className = { `-ml-0.5 h-5 w-5 ${ isExportEnabled ? 'text-gray-400' : 'text-gray-300' } ` }
223+ aria-hidden = "true"
224+ />
225+ ) }
226+ Export
227+ </ button >
228+ < button
229+ onClick = { copyCaseNumbers }
230+ className = { `inline-flex items-center gap-x-2 rounded-md bg-white px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset hover:bg-gray-50 ${
231+ copied ? 'text-green-700 ring-green-600' : 'text-gray-900 ring-gray-300'
232+ } `}
233+ aria-label = "Copy all case numbers"
234+ >
235+ { copied ? (
236+ < CheckIcon className = "h-5 w-5 text-green-600" aria-hidden = "true" />
237+ ) : (
238+ < ClipboardDocumentIcon className = "h-5 w-5 text-gray-400" aria-hidden = "true" />
239+ ) }
240+ Copy Case Numbers
241+ </ button >
242+ </ div >
243+ </ div >
106244 < div className = "mt-4" >
107245 { displayItems . map ( ( item , index ) => (
108246 < div key = { item === 'divider' ? `divider-${ index } ` : item . zipCase . caseNumber } >
0 commit comments