mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 11:08:13 +00:00
feat(configuration): add BSID to NumberFormatScope and update related UI
This commit is contained in:
@@ -38,6 +38,7 @@ const SCOPES: { value: NumberFormatScope; label: string }[] = [
|
|||||||
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
|
||||||
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
|
||||||
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
{ value: 'BTC_NUMBER', label: 'BTC Number' },
|
||||||
|
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const scopeLabel = (scope: NumberFormatScope) =>
|
const scopeLabel = (scope: NumberFormatScope) =>
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export interface UpdateProfessionPayload {
|
|||||||
export type NumberFormatScope =
|
export type NumberFormatScope =
|
||||||
| 'SEAFARER_NUMBER'
|
| 'SEAFARER_NUMBER'
|
||||||
| 'SEAMAN_BOOK_NUMBER'
|
| 'SEAMAN_BOOK_NUMBER'
|
||||||
| 'BTC_NUMBER';
|
| 'BTC_NUMBER'
|
||||||
|
| 'BSID';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape of a generated identifier — prefix, optional year, separator and
|
* The shape of a generated identifier — prefix, optional year, separator and
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
|
CopyButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -12,41 +11,20 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import {
|
import {
|
||||||
IconDownload,
|
IconCheck,
|
||||||
|
IconCopy,
|
||||||
IconFingerprint,
|
IconFingerprint,
|
||||||
|
IconIdBadge2,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconScan,
|
IconScan,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { authStorage } from '@ema-platform/auth';
|
import { useCurrentProfile } from '@ema-platform/auth';
|
||||||
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
|
||||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
|
||||||
|
|
||||||
const API_BASE =
|
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
|
||||||
'http://localhost:3000/api';
|
|
||||||
|
|
||||||
async function fetchCertificate(): Promise<Blob> {
|
|
||||||
const token = authStorage.getToken();
|
|
||||||
if (!token) throw new Error('No auth token found');
|
|
||||||
const res = await fetch(`${API_BASE}/biometric-enrollments/mine/certificate`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch certificate (${res.status})`);
|
|
||||||
return res.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, filename: string) {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODALITY_LABEL: Record<string, string> = {
|
const MODALITY_LABEL: Record<string, string> = {
|
||||||
FINGERPRINT: 'Fingerprint',
|
FINGERPRINT: 'Fingerprint',
|
||||||
@@ -54,44 +32,17 @@ const MODALITY_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View-only: what's enrolled, plus a printable slip. Capture stays
|
* View-only: the seafarer's BSID and what is enrolled against it. Capture
|
||||||
* counter-side with a scanner — there is no self-enrollment flow here.
|
* stays counter-side with a scanner — there is no self-enrollment flow here.
|
||||||
*/
|
*/
|
||||||
export function BiometricsPage() {
|
export function BiometricsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
// The BSID lives on the profile, stamped by staff once a capture is
|
||||||
const [busy, setBusy] = useState(false);
|
// confirmed — it is not a property of any one enrollment, so it is read
|
||||||
|
// from the profile rather than from the rows below.
|
||||||
const openPreview = async () => {
|
const { profile, isLoading: profileLoading } = useCurrentProfile();
|
||||||
setBusy(true);
|
const bsid = profile?.bsid ?? null;
|
||||||
try {
|
|
||||||
setPreviewUrl(URL.createObjectURL(await fetchCertificate()));
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
color: 'red',
|
|
||||||
title: 'Error',
|
|
||||||
message: err instanceof Error ? err.message : 'Could not load certificate',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
downloadBlob(await fetchCertificate(), 'biometric-enrollment-certificate.pdf');
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
color: 'red',
|
|
||||||
title: 'Error',
|
|
||||||
message: err instanceof Error ? err.message : 'Could not download certificate',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = enrollments ?? [];
|
const rows = enrollments ?? [];
|
||||||
|
|
||||||
@@ -109,6 +60,45 @@ export function BiometricsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="xl">
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="sm" mb={rows.length || isLoading ? 'lg' : 0} align="flex-start">
|
||||||
|
<ThemeIcon size={40} radius="md" color="indigo" variant="light">
|
||||||
|
<IconIdBadge2 size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
|
{t('biometrics.bsidLabel', 'Biometric Subject ID')}
|
||||||
|
</Text>
|
||||||
|
{profileLoading ? (
|
||||||
|
<Loader size="xs" mt={6} />
|
||||||
|
) : bsid ? (
|
||||||
|
<CopyButton value={bsid} timeout={1500}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? t('biometrics.copied', 'Copied') : t('biometrics.copy', 'Copy')}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<UnstyledButton onClick={copy}>
|
||||||
|
<Group gap={6} align="center">
|
||||||
|
<Text ff="monospace" fw={700} fz="lg">
|
||||||
|
{bsid}
|
||||||
|
</Text>
|
||||||
|
{copied ? <IconCheck size={15} /> : <IconCopy size={15} />}
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed" mt={2}>
|
||||||
|
{t(
|
||||||
|
'biometrics.bsidPending',
|
||||||
|
'Not issued yet. Your BSID is generated once your enrolment is confirmed at the counter.',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="lg">
|
<Group justify="center" py="lg">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
@@ -147,34 +137,9 @@ export function BiometricsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
leftSection={busy ? <Loader size={12} /> : <IconInfoCircle size={12} />}
|
|
||||||
onClick={openPreview}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{t('biometrics.view', 'View certificate')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconDownload size={12} />}
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{t('biometrics.download', 'Download')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<PdfPreviewModal
|
|
||||||
opened={!!previewUrl}
|
|
||||||
onClose={() => setPreviewUrl(null)}
|
|
||||||
url={previewUrl ?? ''}
|
|
||||||
title={t('biometrics.title', 'Biometrics')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user