Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@tanstack/react-query": "^5.69.0",
"aws-amplify": "^6.14.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.4.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
114 changes: 98 additions & 16 deletions frontend/src/components/app/SearchResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import SearchResult from './SearchResult';
import { useSearchResults, useConsolidatedPolling } from '../../hooks/useCaseSearch';
import { SearchResult as SearchResultType } from '../../../../shared/types';
import { useEffect, useMemo, useState, useRef } from 'react';
import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline';
import { ArrowDownTrayIcon, CheckIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
import { ZipCaseClient } from '../../services/ZipCaseClient';

type DisplayItem = SearchResultType | 'divider';

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

export default function SearchResultsList() {
const { data, isLoading, isError, error } = useSearchResults();

const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const [isExporting, setIsExporting] = useState(false);

// Extract batches and create a flat display list with dividers
const displayItems = useMemo(() => {
if (!data || !data.results || !data.searchBatches) {
Expand Down Expand Up @@ -111,7 +115,7 @@ export default function SearchResultsList() {
};
}, [searchResults, polling]);

// Cleanup timeout on unmount
// Clean up timeout on unmount
useEffect(() => {
return () => {
if (copiedTimeoutRef.current) {
Expand All @@ -120,6 +124,38 @@ export default function SearchResultsList() {
};
}, []);

const handleExport = async () => {
const caseNumbers = searchResults.map(r => r.zipCase.caseNumber);
if (caseNumbers.length === 0) return;

setIsExporting(true);

// Set a timeout to reset the exporting state after 10 seconds
const timeoutId = setTimeout(() => {
setIsExporting(false);
}, 10000);

try {
const client = new ZipCaseClient();
await client.cases.export(caseNumbers);
} catch (error) {
console.error('Export failed:', error);
} finally {
clearTimeout(timeoutId);
setIsExporting(false);
}
};

const isExportEnabled = useMemo(() => {
if (searchResults.length === 0) return false;
const terminalStates = ['complete', 'failed', 'notFound'];
return searchResults.every(r => terminalStates.includes(r.zipCase.fetchStatus.status));
}, [searchResults]);

const exportableCount = useMemo(() => {
return searchResults.filter(r => r.zipCase.fetchStatus.status !== 'notFound').length;
}, [searchResults]);

if (isError) {
console.error('Error in useSearchResults:', error);
}
Expand All @@ -144,20 +180,66 @@ export default function SearchResultsList() {
<div className="mt-8">
<div className="flex justify-between items-center">
<h3 className="text-base font-semibold text-gray-900">Search Results</h3>
<button
onClick={copyCaseNumbers}
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 ${
copied ? 'text-green-700 ring-green-600' : 'text-gray-900 ring-gray-300'
}`}
aria-label="Copy all case numbers"
>
{copied ? (
<CheckIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
) : (
<ClipboardDocumentIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
)}
Copy Case Numbers
</button>
<div className="flex gap-2">
<button
type="button"
onClick={handleExport}
disabled={!isExportEnabled || isExporting}
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 ${
isExportEnabled && !isExporting
? 'bg-white text-gray-900 ring-gray-300 hover:bg-gray-50'
: 'bg-gray-100 text-gray-400 ring-gray-200 cursor-not-allowed'
}`}
title={
isExportEnabled
? `Export ${exportableCount} case${exportableCount === 1 ? '' : 's'}`
: 'Wait for all cases to finish processing before exporting'
}
>
{isExporting ? (
<svg
className="animate-spin -ml-0.5 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
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"
></path>
</svg>
) : (
<ArrowDownTrayIcon
className={`-ml-0.5 h-5 w-5 ${isExportEnabled ? 'text-gray-400' : 'text-gray-300'}`}
aria-hidden="true"
/>
)}
Export
</button>
<button
onClick={copyCaseNumbers}
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 ${
copied ? 'text-green-700 ring-green-600' : 'text-gray-900 ring-gray-300'
}`}
aria-label="Copy all case numbers"
>
{copied ? (
<CheckIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
) : (
<ClipboardDocumentIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
)}
Copy Case Numbers
</button>
</div>
</div>
<div className="mt-4">
{displayItems.map((item, index) => (
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/services/ZipCaseClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fetchAuthSession } from '@aws-amplify/core';
import { format } from 'date-fns';
import { API_URL } from '../aws-exports';
import {
ApiKeyResponse,
Expand Down Expand Up @@ -111,8 +112,78 @@ export class ZipCaseClient {
get: async (caseNumber: string): Promise<ZipCaseResponse<SearchResult>> => {
return await this.request<SearchResult>(`/case/${caseNumber}`, { method: 'GET' });
},

export: async (caseNumbers: string[]): Promise<void> => {
return await this.download('/export', {
method: 'POST',
data: { caseNumbers },
});
},
};

/**
* Helper method to handle file downloads
*/
private async download(endpoint: string, options: { method?: string; data?: unknown } = {}): Promise<void> {
const { method = 'GET', data } = options;
const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
const url = `${this.baseUrl}/${path}`;

try {
const session = await fetchAuthSession();
const token = session.tokens?.accessToken;

if (!token) {
throw new Error('No authentication token available');
}

const requestOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token.toString()}`,
},
};

if (method !== 'GET' && data) {
requestOptions.body = JSON.stringify(data);
}

const response = await fetch(url, requestOptions);

if (!response.ok) {
throw new Error(`Download failed with status ${response.status}`);
}

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;

const contentDisposition = response.headers.get('Content-Disposition');

// Generate a default filename with local timestamp
const timestamp = format(new Date(), 'yyyyMMdd-HHmmss');
let filename = `ZipCase-Export-${timestamp}.xlsx`;

if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
if (filenameMatch && filenameMatch.length === 2) {
filename = filenameMatch[1];
}
}

a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
} catch (error) {
console.error('Download error:', error);
throw error;
}
}

/**
* Core request method that handles all API interactions
*/
Expand Down
Loading