feat(configuration): add BSID to NumberFormatScope and update related UI

This commit is contained in:
Nati
2026-08-29 09:53:30 +00:00
parent e1f33be0e2
commit b8dc7274e6
3 changed files with 56 additions and 89 deletions

View File

@@ -38,6 +38,7 @@ const SCOPES: { value: NumberFormatScope; label: string }[] = [
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
{ value: 'BTC_NUMBER', label: 'BTC Number' },
{ value: 'BSID', label: 'Biometric Subject ID (BSID)' },
];
const scopeLabel = (scope: NumberFormatScope) =>

View File

@@ -51,7 +51,8 @@ export interface UpdateProfessionPayload {
export type NumberFormatScope =
| 'SEAFARER_NUMBER'
| 'SEAMAN_BOOK_NUMBER'
| 'BTC_NUMBER';
| 'BTC_NUMBER'
| 'BSID';
/**
* The shape of a generated identifier — prefix, optional year, separator and

View File

@@ -1,9 +1,8 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
CopyButton,
Group,
Loader,
Paper,
@@ -12,41 +11,20 @@ import {
Text,
ThemeIcon,
Title,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconDownload,
IconCheck,
IconCopy,
IconFingerprint,
IconIdBadge2,
IconInfoCircle,
IconScan,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { authStorage } from '@ema-platform/auth';
import { useCurrentProfile } from '@ema-platform/auth';
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> = {
FINGERPRINT: 'Fingerprint',
@@ -54,44 +32,17 @@ const MODALITY_LABEL: Record<string, string> = {
};
/**
* View-only: what's enrolled, plus a printable slip. Capture stays
* counter-side with a scanner — there is no self-enrollment flow here.
* View-only: the seafarer's BSID and what is enrolled against it. Capture
* stays counter-side with a scanner — there is no self-enrollment flow here.
*/
export function BiometricsPage() {
const { t } = useTranslation();
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const openPreview = async () => {
setBusy(true);
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);
}
};
// The BSID lives on the profile, stamped by staff once a capture is
// confirmed — it is not a property of any one enrollment, so it is read
// from the profile rather than from the rows below.
const { profile, isLoading: profileLoading } = useCurrentProfile();
const bsid = profile?.bsid ?? null;
const rows = enrollments ?? [];
@@ -109,6 +60,45 @@ export function BiometricsPage() {
</Alert>
<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 ? (
<Group justify="center" py="lg">
<Loader size="sm" />
@@ -147,34 +137,9 @@ export function BiometricsPage() {
</Card>
))}
</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>
)}
</Paper>
<PdfPreviewModal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
url={previewUrl ?? ''}
title={t('biometrics.title', 'Biometrics')}
/>
</Stack>
);
}