Skip to content

Commit 6482c09

Browse files
committed
#129 add export function to frontend
1 parent 2727b86 commit 6482c09

4 files changed

Lines changed: 181 additions & 16 deletions

File tree

frontend/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@tanstack/react-query": "^5.69.0",
2424
"aws-amplify": "^6.14.1",
2525
"clsx": "^2.1.1",
26+
"date-fns": "^4.1.0",
2627
"framer-motion": "^12.4.7",
2728
"react": "^18.3.1",
2829
"react-dom": "^18.3.1",

frontend/src/components/app/SearchResultsList.tsx

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import SearchResult from './SearchResult';
22
import { useSearchResults, useConsolidatedPolling } from '../../hooks/useCaseSearch';
33
import { SearchResult as SearchResultType } from '../../../../shared/types';
44
import { useEffect, useMemo, useState, useRef } from 'react';
5-
import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline';
5+
import { ArrowDownTrayIcon, CheckIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
6+
import { ZipCaseClient } from '../../services/ZipCaseClient';
67

78
type DisplayItem = SearchResultType | 'divider';
89

@@ -12,9 +13,12 @@ function CaseResultItem({ searchResult }: { searchResult: SearchResultType }) {
1213

1314
export default function SearchResultsList() {
1415
const { data, isLoading, isError, error } = useSearchResults();
16+
1517
const [copied, setCopied] = useState(false);
1618
const copiedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
1719

20+
const [isExporting, setIsExporting] = useState(false);
21+
1822
// Extract batches and create a flat display list with dividers
1923
const displayItems = useMemo(() => {
2024
if (!data || !data.results || !data.searchBatches) {
@@ -111,7 +115,7 @@ export default function SearchResultsList() {
111115
};
112116
}, [searchResults, polling]);
113117

114-
// Cleanup timeout on unmount
118+
// Clean up timeout on unmount
115119
useEffect(() => {
116120
return () => {
117121
if (copiedTimeoutRef.current) {
@@ -120,6 +124,38 @@ export default function SearchResultsList() {
120124
};
121125
}, []);
122126

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+
123159
if (isError) {
124160
console.error('Error in useSearchResults:', error);
125161
}
@@ -144,20 +180,66 @@ export default function SearchResultsList() {
144180
<div className="mt-8">
145181
<div className="flex justify-between items-center">
146182
<h3 className="text-base font-semibold text-gray-900">Search Results</h3>
147-
<button
148-
onClick={copyCaseNumbers}
149-
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 ${
150-
copied ? 'text-green-700 ring-green-600' : 'text-gray-900 ring-gray-300'
151-
}`}
152-
aria-label="Copy all case numbers"
153-
>
154-
{copied ? (
155-
<CheckIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
156-
) : (
157-
<ClipboardDocumentIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
158-
)}
159-
Copy Case Numbers
160-
</button>
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>
161243
</div>
162244
<div className="mt-4">
163245
{displayItems.map((item, index) => (

frontend/src/services/ZipCaseClient.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fetchAuthSession } from '@aws-amplify/core';
2+
import { format } from 'date-fns';
23
import { API_URL } from '../aws-exports';
34
import {
45
ApiKeyResponse,
@@ -111,8 +112,78 @@ export class ZipCaseClient {
111112
get: async (caseNumber: string): Promise<ZipCaseResponse<SearchResult>> => {
112113
return await this.request<SearchResult>(`/case/${caseNumber}`, { method: 'GET' });
113114
},
115+
116+
export: async (caseNumbers: string[]): Promise<void> => {
117+
return await this.download('/export', {
118+
method: 'POST',
119+
data: { caseNumbers },
120+
});
121+
},
114122
};
115123

124+
/**
125+
* Helper method to handle file downloads
126+
*/
127+
private async download(endpoint: string, options: { method?: string; data?: unknown } = {}): Promise<void> {
128+
const { method = 'GET', data } = options;
129+
const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
130+
const url = `${this.baseUrl}/${path}`;
131+
132+
try {
133+
const session = await fetchAuthSession();
134+
const token = session.tokens?.accessToken;
135+
136+
if (!token) {
137+
throw new Error('No authentication token available');
138+
}
139+
140+
const requestOptions: RequestInit = {
141+
method,
142+
headers: {
143+
'Content-Type': 'application/json',
144+
Authorization: `Bearer ${token.toString()}`,
145+
},
146+
};
147+
148+
if (method !== 'GET' && data) {
149+
requestOptions.body = JSON.stringify(data);
150+
}
151+
152+
const response = await fetch(url, requestOptions);
153+
154+
if (!response.ok) {
155+
throw new Error(`Download failed with status ${response.status}`);
156+
}
157+
158+
const blob = await response.blob();
159+
const downloadUrl = window.URL.createObjectURL(blob);
160+
const a = document.createElement('a');
161+
a.href = downloadUrl;
162+
163+
const contentDisposition = response.headers.get('Content-Disposition');
164+
165+
// Generate a default filename with local timestamp
166+
const timestamp = format(new Date(), 'yyyyMMdd-HHmmss');
167+
let filename = `ZipCase-Export-${timestamp}.xlsx`;
168+
169+
if (contentDisposition) {
170+
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
171+
if (filenameMatch && filenameMatch.length === 2) {
172+
filename = filenameMatch[1];
173+
}
174+
}
175+
176+
a.download = filename;
177+
document.body.appendChild(a);
178+
a.click();
179+
window.URL.revokeObjectURL(downloadUrl);
180+
document.body.removeChild(a);
181+
} catch (error) {
182+
console.error('Download error:', error);
183+
throw error;
184+
}
185+
}
186+
116187
/**
117188
* Core request method that handles all API interactions
118189
*/

0 commit comments

Comments
 (0)