feat: integrate PdfPreviewModal across apps

This commit is contained in:
estifanos
2026-08-19 08:59:08 +00:00
parent 4d87d41e55
commit b860e57227
7 changed files with 118 additions and 38 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api';
@@ -13,12 +13,14 @@ interface PreviewArgs {
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
* are what you see. Opened as a blob into `PdfPreviewModal` rather than a new
* tab, so the designer never loses their place.
*/
export function useTemplatePreview() {
const { t } = useTranslation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return useCallback(
const open = useCallback(
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
@@ -38,10 +40,7 @@ export function useTemplatePreview() {
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
setPreviewUrl(URL.createObjectURL(await response.blob()));
} catch (err) {
notifications.show({
color: 'red',
@@ -52,4 +51,13 @@ export function useTemplatePreview() {
},
[t],
);
const close = useCallback(() => {
setPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current);
return null;
});
}, []);
return { previewUrl, open, close };
}

View File

@@ -31,7 +31,7 @@ import {
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar';
@@ -86,7 +86,7 @@ export function CertificateDesignerPage() {
const draft = useTemplateDraft(templates);
const run = useDesignerActions();
const openPreview = useTemplatePreview();
const { previewUrl, open: openPreview, close: closePreview } = useTemplatePreview();
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
@@ -377,6 +377,13 @@ export function CertificateDesignerPage() {
}, t('designer.created', 'Draft created'))
}
/>
<PdfPreviewModal
opened={Boolean(previewUrl)}
onClose={closePreview}
url={previewUrl ?? ''}
title={t('designer.preview', 'Preview')}
/>
</Container>
);
}

View File

@@ -23,7 +23,12 @@ import {
IconPaperclip,
IconStethoscope,
} from '@tabler/icons-react';
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
import {
AdvancedTable,
notify,
PdfPreviewModal,
type AdvancedColumn,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -109,6 +114,9 @@ function AttachmentsModal({
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const files = useMemo(
() => (attachments ?? []).flatMap((a) => a.files ?? []),
@@ -156,10 +164,9 @@ function AttachmentsModal({
size="compact-xs"
variant="light"
leftSection={<IconEye size={14} />}
component="a"
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
setPreview({ url: file.url as string, title: file.originalName })
}
>
{t('recordVerification.view', 'View')}
</Button>
@@ -173,6 +180,12 @@ function AttachmentsModal({
))
)}
</Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Modal>
);
}

View File

@@ -8,7 +8,6 @@ import {
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Mock data
@@ -353,21 +353,12 @@ export function CertificatesPage() {
)}
</Paper>
{/* Preview modal */}
<Modal
<PdfPreviewModal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
size="95vw"
radius="lg"
fullScreen
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
url={previewUrl ?? ''}
title={previewTitle}
/>
</Modal>
</Stack>
);
}

View File

@@ -23,6 +23,7 @@ import {
type DocumentRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -61,6 +62,9 @@ export function DocumentSlots({
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const resetRefs = useRef<Record<string, () => void>>({});
const required = requirements.filter(
@@ -100,6 +104,7 @@ export function DocumentSlots({
{required.map((requirement) => {
const existing = attachments.find((a) => a.documentKey === requirement.key);
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
@@ -153,13 +158,16 @@ export function DocumentSlots({
</div>
<Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && (
{fileUrl && (
<Button
size="xs"
variant="subtle"
component="a"
href={existing.files[0].url}
target="_blank"
onClick={() =>
setPreview({
url: fileUrl,
title: localized(requirement.name),
})
}
>
{t('licensing.documents.view')}
</Button>
@@ -198,6 +206,12 @@ export function DocumentSlots({
</Card>
);
})}
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Stack>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Button, FileButton, Group, Loader } from '@mantine/core';
import { ActionIcon, Button, FileButton, Group, Loader } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { IconCheck, IconEye } from '@tabler/icons-react';
import {
useLocalized,
uploadDocument,
@@ -9,6 +9,7 @@ import {
type StaffEvidenceRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -31,6 +32,9 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
ownerId: staffId,
});
const [busy, setBusy] = useState<string | null>(null);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const localized = useLocalized();
const { t } = useTranslation();
@@ -39,12 +43,14 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
return (
<Group gap="xs">
{evidence.map((item) => {
const uploaded = attachments.some(
const attachment = attachments.find(
(a) => a.documentKey === item.docKey && a.files?.length,
);
const uploaded = Boolean(attachment);
const fileUrl = attachment?.files?.[0]?.url;
return (
<Group key={item.docKey} gap={4} wrap="nowrap">
<FileButton
key={item.docKey}
accept="application/pdf,image/jpeg,image/png"
onChange={async (file) => {
if (!file) return;
@@ -89,8 +95,27 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
</Button>
)}
</FileButton>
{uploaded && fileUrl && (
<ActionIcon
size="sm"
variant="subtle"
aria-label={t('licensing.documents.view', 'View')}
onClick={() =>
setPreview({ url: fileUrl, title: localized(item.label) })
}
>
<IconEye size={14} />
</ActionIcon>
)}
</Group>
);
})}
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Group>
);
}

View File

@@ -28,7 +28,13 @@ import {
} from '@tabler/icons-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui';
import {
AdvancedTable,
AmharicDatePicker,
notify,
PdfPreviewModal,
useServerTable,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -74,6 +80,9 @@ function EvidenceModal({
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const files = (attachments ?? []).flatMap((a) => a.files);
@@ -91,7 +100,14 @@ function EvidenceModal({
<Group key={file.id} gap="xs">
<IconPaperclip size={16} />
{file.url ? (
<Anchor href={file.url} target="_blank" size="sm">
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
setPreview({ url: file.url as string, title: file.originalName })
}
>
{file.originalName}
</Anchor>
) : (
@@ -101,6 +117,12 @@ function EvidenceModal({
))
)}
</Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Modal>
);
}