feat(signature): add signature functionality for officers and seafarers

- Implemented MySignaturePad component for officers to draw and upload their signatures.
- Added signature API endpoints for managing officer signatures in the backoffice.
- Created SignaturePad component for seafarers to manage their specimen signatures.
- Updated ProfilePage to include signature tabs for both officers and seafarers.
- Added translations for signature-related terms in English and Amharic.
- Introduced RankTier options for seafarer registration.
- Added tests for canvas-point utility to ensure accurate pointer mapping on signature pad.
This commit is contained in:
Nati
2026-08-29 06:52:14 +00:00
parent 9841092ddc
commit 890aca44f4
18 changed files with 677 additions and 3 deletions

View File

@@ -0,0 +1,42 @@
import { baseApi } from '@ema-platform/api';
/**
* The officer's own signing signature — drawn onto certificates they approve
* (`{{signatureImage}}`), as distinct from the seafarer's specimen signature
* that the portal manages.
*
* Scoped to the caller: the API resolves the employee record from the token,
* so no employee id is passed and nobody can upload on another's behalf.
*/
const signatureApi = baseApi
.enhanceEndpoints({ addTagTypes: ['MyEmployeeSignature'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMyEmployeeSignature: builder.query<{ url: string | null }, void>({
query: () => ({ url: '/employee-signatures/me' }),
providesTags: ['MyEmployeeSignature'],
}),
uploadMyEmployeeSignature: builder.mutation<{ id: string }, File>({
query: (file) => {
const body = new FormData();
body.append('file', file);
// No Content-Type header: fetch sets it with the multipart boundary.
return { url: '/employee-signatures/me', method: 'POST', body };
},
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
}),
deleteMyEmployeeSignature: builder.mutation<{ removed: boolean }, void>({
query: () => ({ url: '/employee-signatures/me', method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : ['MyEmployeeSignature']),
}),
}),
overrideExisting: false,
});
export const {
useGetMyEmployeeSignatureQuery,
useUploadMyEmployeeSignatureMutation,
useDeleteMyEmployeeSignatureMutation,
} = signatureApi;

View File

@@ -0,0 +1,26 @@
import { SignaturePad } from '@ema-platform/ui';
import {
useDeleteMyEmployeeSignatureMutation,
useGetMyEmployeeSignatureQuery,
useUploadMyEmployeeSignatureMutation,
} from '../api/signature-api';
/** The signature drawn onto certificates this officer approves. */
export function MySignaturePad() {
const { data, isLoading } = useGetMyEmployeeSignatureQuery();
const [upload, { isLoading: isUploading }] =
useUploadMyEmployeeSignatureMutation();
const [remove, { isLoading: isDeleting }] =
useDeleteMyEmployeeSignatureMutation();
return (
<SignaturePad
currentUrl={data?.url ?? null}
isLoading={isLoading}
isUploading={isUploading}
isDeleting={isDeleting}
onUpload={(file) => upload(file).unwrap()}
onDelete={() => remove().unwrap()}
/>
);
}

View File

@@ -32,6 +32,7 @@ import {
IconMail,
IconMoon,
IconSettings,
IconSignature,
IconShieldLock,
IconSun,
IconUser,
@@ -43,12 +44,18 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import {
ActiveSessions,
LICENSE_PERMISSIONS,
setUser,
usePermissions,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setLayoutMode } from '../../../store/preferences.slice';
import type { LayoutMode } from '../../../store/preferences.slice';
import { MySignaturePad } from '../components/MySignaturePad';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
@@ -77,6 +84,10 @@ export function ProfilePage() {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { handleError } = useErrorHandler();
// Only officers who approve applications ever sign a certificate, so nobody
// else is asked for a signature they would never use.
const { can } = usePermissions();
const canSign = can([LICENSE_PERMISSIONS.APPROVE_APPLICATION]);
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
@@ -318,6 +329,11 @@ export function ProfilePage() {
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
</Tabs.Tab>
{canSign && (
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
{t('profile.tabs.signature')}
</Tabs.Tab>
)}
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -405,6 +421,15 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Signature (drawn onto certificates this officer approves) ---- */}
{canSign && (
<Tabs.Panel value="signature" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<MySignaturePad />
</Paper>
</Tabs.Panel>
)}
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Stack gap="lg">