mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: integrate PdfPreviewModal across apps
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user