diff --git a/packages/app-builder/public/locales/en/cases.json b/packages/app-builder/public/locales/en/cases.json index 4f1f659f7..b190e244f 100644 --- a/packages/app-builder/public/locales/en/cases.json +++ b/packages/app-builder/public/locales/en/cases.json @@ -5,6 +5,13 @@ "case.tags": "Tags", "case.contributors": "Contributors", "case.decisions": "Decisions", + "case.files": "Files", + "case.file.name": "File name", + "case.file.extension": "Extension", + "case.file.added_date": "Added on", + "case.file.download": "Download", + "case.file.downloading": "Downloading...", + "case.file.errors.downloading_decisions_link": "An unknown error as occured while generating the download link. Please try again later.", "case.inbox": "Inbox", "case.inboxes": "Inboxes", "case.status.open": "open", @@ -18,6 +25,8 @@ "case_detail.informations": "Informations", "case_detail.decisions_count_one": "{{count}} decision", "case_detail.decisions_count_other": "{{count}} decisions", + "case_detail.files_count_one": "{{count}} files", + "case_detail.files_count_other": "{{count}} files", "case_detail.history": "History", "case_detail.unknown_user": "unknown user", "case_detail.unknown_tag": "unknown tag", diff --git a/packages/app-builder/src/components/Cases/CaseFiles.tsx b/packages/app-builder/src/components/Cases/CaseFiles.tsx new file mode 100644 index 000000000..4b495e5c4 --- /dev/null +++ b/packages/app-builder/src/components/Cases/CaseFiles.tsx @@ -0,0 +1,142 @@ +import { + AlreadyDownloadingError, + useDownloadCaseFiles, +} from '@app-builder/services/DownloadCaseFilesService'; +import { formatDateTime } from '@app-builder/utils/format'; +import { type ColumnDef, getCoreRowModel } from '@tanstack/react-table'; +import { type CaseFile } from 'marble-api'; +import { useMemo } from 'react'; +import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { last } from 'remeda'; +import { ClientOnly } from 'remix-utils'; +import { Button, Collapsible, Table, useVirtualTable } from 'ui-design-system'; + +import { casesI18n } from './cases-i18n'; + +export function CaseFiles({ files }: { files: CaseFile[] }) { + const { t } = useTranslation(casesI18n); + + return ( + + + + + {t('cases:case.files')} + + + {t('cases:case_detail.files_count', { + count: files.length, + })} + + + + + + + + ); +} + +function FilesList({ files }: { files: CaseFile[] }) { + const { + t, + i18n: { language }, + } = useTranslation(casesI18n); + + const columns = useMemo[]>( + () => [ + { + id: 'file_name', + accessorKey: 'file_name', + header: t('cases:case.file.name'), + size: 120, + }, + { + id: 'extension', + size: 40, + header: t('cases:case.file.extension'), + cell: ({ cell }) => { + return last(cell.row.original.file_name.split('.'))?.toUpperCase(); + }, + }, + { + id: 'created_at', + header: t('cases:case.file.added_date'), + size: 40, + cell: ({ cell }) => { + return formatDateTime(cell.row.original.created_at, { + language, + timeStyle: undefined, + }); + }, + }, + { + id: 'link', + accessorKey: 'file_name', + header: t('cases:case.file.download'), + size: 40, + cell: ({ cell }) => { + return ; + }, + }, + ], + [language, t], + ); + + const { table, getBodyProps, rows, getContainerProps } = useVirtualTable({ + data: files, + columns, + columnResizeMode: 'onChange', + getCoreRowModel: getCoreRowModel(), + enableSorting: false, + }); + + // useDownloadCaseFiles() + + return ( + + + + {rows.map((row) => { + return ; + })} + + + ); +} + +function FileLink({ caseFileId }: { caseFileId: string }) { + const { downloadCaseFile, downloadingCaseFile } = useDownloadCaseFiles( + caseFileId, + { + onError: (e) => { + if (e instanceof AlreadyDownloadingError) { + // Already downloading, do nothing + return; + } + toast.error(t('cases:case.file.errors.downloading_decisions_link')); + }, + }, + ); + const { t } = useTranslation(casesI18n); + + return ( + + {() => ( + { + void downloadCaseFile(); + }} + name="download" + disabled={downloadingCaseFile} + > + {downloadingCaseFile + ? t('cases:case.file.downloading') + : t('cases:case.file.download')} + + )} + + ); +} diff --git a/packages/app-builder/src/routes/__builder/cases/$caseId.tsx b/packages/app-builder/src/routes/__builder/cases/$caseId.tsx index 8bbbbfe54..421239563 100644 --- a/packages/app-builder/src/routes/__builder/cases/$caseId.tsx +++ b/packages/app-builder/src/routes/__builder/cases/$caseId.tsx @@ -9,6 +9,7 @@ import { CaseInformation, casesI18n, } from '@app-builder/components/Cases'; +import { CaseFiles } from '@app-builder/components/Cases/CaseFiles'; import { isForbiddenHttpError, isNotFoundHttpError } from '@app-builder/models'; import { AddComment } from '@app-builder/routes/ressources/cases/add-comment'; import { EditCaseStatus } from '@app-builder/routes/ressources/cases/edit-status'; @@ -83,6 +84,7 @@ export default function CasePage() { + diff --git a/packages/app-builder/src/services/DownloadCaseFilesService.ts b/packages/app-builder/src/services/DownloadCaseFilesService.ts new file mode 100644 index 000000000..bce031cc2 --- /dev/null +++ b/packages/app-builder/src/services/DownloadCaseFilesService.ts @@ -0,0 +1,98 @@ +import { DownloadError } from '@app-builder/utils/download-blob'; +import { UnknownError } from '@app-builder/utils/unknown-error'; +import { useState } from 'react'; +import { z } from 'zod'; + +import { useBackendInfo } from './auth/auth.client'; +import { clientServices } from './init.client'; + +export class AlreadyDownloadingError extends Error {} +export class FetchLinkError extends Error {} +type DownloadFileError = + | AlreadyDownloadingError + | FetchLinkError + | DownloadError + | UnknownError; + +const fileDownloadUrlSchema = z.object({ + url: z.string(), +}); + +export function useDownloadCaseFiles( + caseFileId: string, + { onError }: { onError?: (error: DownloadFileError) => void } = {}, +) { + const [downloading, setDownloading] = useState(false); + const { backendUrl, accessToken } = useBackendInfo( + clientServices.authenticationClientService, + ); + + const downloadCaseFile = async () => { + try { + if (downloading) { + throw new AlreadyDownloadingError( + 'Internal error: Already downloading', + ); + } + setDownloading(true); + + const downloadLink = `${backendUrl}/cases/files/${encodeURIComponent( + caseFileId, + )}/download_link`; + const response = await fetch(downloadLink, { + method: 'GET', + headers: { + Authorization: `Bearer ${await accessToken()}`, + }, + }); + + if (!response.ok) { + throw new FetchLinkError( + 'Internal error: Failed to download file: ' + response.statusText, + ); + } + const { url } = fileDownloadUrlSchema.parse(await response.json()); + await openFileLink(url); + } catch (error) { + if ( + error instanceof AlreadyDownloadingError || + error instanceof FetchLinkError || + error instanceof DownloadError + ) { + onError?.(error); + } else { + onError?.(new UnknownError(error)); + } + } finally { + setDownloading(false); + } + }; + + return { + downloadCaseFile, + downloadingCaseFile: downloading, + }; +} + +const TIME_TO_OPEN_DOWNLOAD_MODALE = 150; + +async function openFileLink(url: string) { + return new Promise((resolve, reject) => { + try { + const a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + + const clickHandler = () => { + setTimeout(() => { + resolve(); + }, TIME_TO_OPEN_DOWNLOAD_MODALE); + }; + + a.addEventListener('click', clickHandler); + a.click(); + } catch (error) { + reject(new DownloadError(error)); + } + }); +}