diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
index 46b801648..d98ef0ed0 100644
--- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
+++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx
@@ -44,7 +44,7 @@ import {
useUpdateLicenseTemplateMutation,
type LicenseTemplate,
} from '@ema-platform/api';
-import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
+import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth';
import { PERMISSIONS } from '../../../layouts/nav-config';
@@ -529,7 +529,7 @@ export function CertificateDesignerPage() {
'Starts from the live design, or the built-in layout if this type has none.',
)}
-
+
@@ -550,7 +550,7 @@ export function CertificateDesignerPage() {
>
{t('designer.create', 'Create')}
-
+
diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx
index a7ab26d0e..bd6163ae1 100644
--- a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx
+++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx
@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
-import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
+import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
@@ -199,10 +199,10 @@ export function CertificationPage() {
{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}
-
+
-
+
);
diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx
index 6f01c73a5..c8e624dd0 100644
--- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx
+++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx
@@ -33,6 +33,7 @@ import {
useErrorHandler,
AdvancedTable,
useServerTable,
+ ModalFooter,
type AdvancedColumn,
} from "@ema-platform/ui";
import { LocationPage } from "../../location/pages/LocationPage";
@@ -388,14 +389,14 @@ function ProfessionTab() {
name: deleteTarget?.name?.[locale] ?? "",
})}
-
+
-
+
>
);
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
index ce77b3eef..2ad3478f2 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
@@ -40,7 +40,7 @@ import {
IconX,
IconDetails
} from "@tabler/icons-react";
-import { notify, useErrorHandler } from "@ema-platform/ui";
+import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
import {
useGetExamQuery,
useUpdateExamMutation,
@@ -494,14 +494,14 @@ export function ExamDetailPage() {
mode="manual"
actions={setWhatAction}
/>
-
+
-
+
>
) : (
<>
@@ -531,14 +531,14 @@ export function ExamDetailPage() {
onChange={setDraftQuestions}
mode="random"
/>
-
+
-
+
>
)}
diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
index 086ade3f7..f49686075 100644
--- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
+++ b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
@@ -31,7 +31,7 @@ import {
IconClipboardList,
IconDetails,
} from "@tabler/icons-react";
-import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from "@ema-platform/ui";
+import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from "@ema-platform/ui";
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
import {
useGetExamsQuery,
@@ -600,14 +600,14 @@ export function ExamPage() {
name: deleteTarget?.title?.[locale] ?? "",
})}
-
+
-
+
);
diff --git a/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx b/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
index d9df085d5..2c34fc9cd 100644
--- a/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
+++ b/apps/backoffice/src/app/features/license-review/components/DecisionConfirmModal.tsx
@@ -3,7 +3,6 @@ import {
Alert,
Button,
Checkbox,
- Group,
Modal,
Select,
Stack,
@@ -13,6 +12,7 @@ import {
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
+import { ModalFooter } from '@ema-platform/ui';
import type { ResolvedAction } from '../config/actions';
/** Reason codes offered per action. Free text is always available too. */
@@ -290,7 +290,7 @@ export function DecisionConfirmModal({
/>
)}
-
+
@@ -310,7 +310,7 @@ export function DecisionConfirmModal({
>
{t(action.labelKey)}
-
+
);
diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx
index fe5713c3a..490e6a988 100644
--- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx
+++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx
@@ -57,7 +57,7 @@ import {
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
-import { ErrorState } from '@ema-platform/ui';
+import { ErrorState, ModalFooter } from '@ema-platform/ui';
import { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { DecisionBar } from '../components/DecisionBar';
@@ -894,7 +894,7 @@ export function LicenseReviewPage() {
value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)}
/>
-
+
-
+
@@ -942,7 +942,7 @@ export function LicenseReviewPage() {
autosize
minRows={3}
/>
-
+
-
+
diff --git a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx
index 6e0fec706..0e2954a82 100644
--- a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx
+++ b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx
@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
-import { notify, useErrorHandler } from '@ema-platform/ui';
+import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
@@ -226,14 +226,14 @@ export function LocationPage() {
name: selectedLocation?.names[locale] ?? '',
})}
-
+
-
+
diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx
index 0f48fef41..4f425cfe7 100644
--- a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx
+++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx
@@ -26,7 +26,7 @@ import {
IconInfoCircle,
IconLock,
} from '@tabler/icons-react';
-import { notify } from '@ema-platform/ui';
+import { notify, ModalFooter } from '@ema-platform/ui';
import {
extractErrorMessage,
localized,
@@ -364,14 +364,14 @@ function FeeEditModal({
>
)}
-
+
-
+
)}
diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
index 2e1dcaf8e..643d0fb50 100644
--- a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
+++ b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
@@ -18,7 +18,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
-import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
+import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import {
useGetQuestionsQuery,
@@ -255,10 +255,10 @@ export function QuestionPage() {
{t('question.deleteConfirmText')}
-
+
-
+
);
diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx
index 34bed6acb..8b8335e02 100644
--- a/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx
+++ b/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx
@@ -17,7 +17,7 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
-import { notify, useErrorHandler } from '@ema-platform/ui';
+import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import { useApiQuery } from '@ema-platform/api';
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
import type { Exam } from '../../exam/types/exam';
@@ -195,12 +195,12 @@ export function RecordResultModal({
size="sm"
/>
-
+
-
+
>
)}
diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
index 927928202..162eb9aee 100644
--- a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
+++ b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx
@@ -36,7 +36,7 @@ import {
IconChartBar,
IconSearch,
} from '@tabler/icons-react';
-import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
+import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
@@ -462,7 +462,7 @@ export function ResultPage() {
>
)}
-
+
-
+
) : (
{t('result.noData')}
@@ -481,10 +481,10 @@ export function ResultPage() {
{t('result.deleteConfirmText')}
-
+
-
+
{/* Choose exam, then record */}
@@ -501,10 +501,10 @@ export function ResultPage() {
searchable
required
/>
-
+
-
+
diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx
index cb89cf001..37b9e3f97 100644
--- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx
+++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx
@@ -30,7 +30,7 @@ import {
IconSettings,
IconTrash,
} from '@tabler/icons-react';
-import { notify } from '@ema-platform/ui';
+import { notify, ModalFooter } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
@@ -495,12 +495,12 @@ export function VesselRegistrationFormBuilderPage() {
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
/>
-
+
-
+
@@ -516,10 +516,10 @@ export function VesselRegistrationFormBuilderPage() {
Are you sure you want to remove {deleteTarget?.label} from the form?
This cannot be undone.
-
+
-
+
diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
index ede020f0c..6f265f1dc 100644
--- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
+++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
@@ -49,6 +49,7 @@ import {
type FieldErrors,
type ValidationIssue,
} from '@ema-platform/api';
+import { ModalFooter } from '@ema-platform/ui';
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
import { DocumentSlots } from '../components/DocumentSlots';
import { StaffEvidence } from '../components/StaffEvidence';
@@ -617,17 +618,19 @@ export function LicenseApplicationPage() {
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
min={0}
/>
-
+
+
+
diff --git a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx
index cc25ed0b8..a41668275 100644
--- a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx
+++ b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx
@@ -19,7 +19,7 @@ import {
useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation,
} from '@ema-platform/api';
-import { notify } from '@ema-platform/ui';
+import { notify, ModalFooter } from '@ema-platform/ui';
/**
* The applicant's modes of operation — what they do, and therefore which
@@ -199,7 +199,7 @@ export function OperationsFormContent({
Applications already filed carry on as they are, and licences
already issued to you stay valid and can still be renewed.
-
+
+
diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx
index 9fa5061d3..d226e6ce0 100644
--- a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx
+++ b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx
@@ -46,7 +46,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
-import { CountrySelect, notify, useErrorHandler } from '@ema-platform/ui';
+import { CountrySelect, notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
@@ -479,10 +479,10 @@ function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () =>
-
+
Cancel
{ notify.success('Training record added (demo).'); onClose(); }}>Save
-
+
);
@@ -503,10 +503,10 @@ function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () =>
-
+
Cancel
{ notify.success('Medical record added (demo).'); onClose(); }}>Save
-
+
);
@@ -533,10 +533,10 @@ function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: ()
-
+
Cancel
{ notify.success('Sea service record added (demo).'); onClose(); }}>Save
-
+
);
@@ -560,10 +560,10 @@ function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => voi
-
+
Cancel
{ notify.success('Certification added (demo).'); onClose(); }}>Save
-
+
);
diff --git a/apps/portal/src/app/theme/portalTheme.ts b/apps/portal/src/app/theme/portalTheme.ts
index f8badb5e2..01bba6e3c 100644
--- a/apps/portal/src/app/theme/portalTheme.ts
+++ b/apps/portal/src/app/theme/portalTheme.ts
@@ -93,6 +93,22 @@ export const portalTheme = createTheme({
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
+ // Mantine's stock scroll wrapper (NativeScrollArea) discards the
+ // max-height it's handed unless scrollAreaComponent is set, so a modal
+ // taller than the viewport just gets clipped with no way to scroll it.
+ // Making the body the scrollport here fixes every Modal/Drawer at once.
+ Modal: {
+ styles: {
+ content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
+ body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
+ },
+ },
+ Drawer: {
+ styles: {
+ content: { display: 'flex', flexDirection: 'column' },
+ body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
+ },
+ },
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
diff --git a/libs/shared/src/lib/theme/ema-theme.ts b/libs/shared/src/lib/theme/ema-theme.ts
index de3b3ca72..6122e2b36 100644
--- a/libs/shared/src/lib/theme/ema-theme.ts
+++ b/libs/shared/src/lib/theme/ema-theme.ts
@@ -31,6 +31,24 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
+ components: {
+ // Mantine's stock scroll wrapper (NativeScrollArea) discards the
+ // max-height it's handed unless scrollAreaComponent is set, so a modal
+ // taller than the viewport just gets clipped with no way to scroll it.
+ // Making the body the scrollport here fixes every Modal/Drawer at once.
+ Modal: {
+ styles: {
+ content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
+ body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
+ },
+ },
+ Drawer: {
+ styles: {
+ content: { display: 'flex', flexDirection: 'column' },
+ body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
+ },
+ },
+ },
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index b422c6340..73614e114 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -1,5 +1,6 @@
export * from "./lib/input/BilingualInput";
export * from "./lib/feedback/ConfirmModal";
+export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";
export * from "./lib/feedback/FeatureUnavailable";
diff --git a/libs/ui/src/lib/feedback/ConfirmModal.tsx b/libs/ui/src/lib/feedback/ConfirmModal.tsx
index 2463c73d0..737e38d3e 100644
--- a/libs/ui/src/lib/feedback/ConfirmModal.tsx
+++ b/libs/ui/src/lib/feedback/ConfirmModal.tsx
@@ -1,4 +1,5 @@
-import { Modal, Button, Group, Text } from '@mantine/core';
+import { Modal, Button, Text } from '@mantine/core';
+import { ModalFooter } from './ModalFooter';
interface ConfirmModalProps {
opened: boolean;
@@ -26,14 +27,14 @@ export function ConfirmModal({
{message}
-
+
{cancelLabel}
{confirmLabel}
-
+
);
}
diff --git a/libs/ui/src/lib/feedback/ModalFooter.tsx b/libs/ui/src/lib/feedback/ModalFooter.tsx
new file mode 100644
index 000000000..82dbc0296
--- /dev/null
+++ b/libs/ui/src/lib/feedback/ModalFooter.tsx
@@ -0,0 +1,38 @@
+import { Group, type GroupProps } from '@mantine/core';
+import type { CSSProperties } from 'react';
+
+/**
+ * Action row pinned to the bottom of a modal.
+ *
+ * Modal bodies scroll (see the Modal styles in the theme), which would carry the
+ * confirm button off-screen on a tall modal. Sticking to the bottom of the
+ * scrollport keeps the decision reachable from any scroll position. Negative
+ * margins bleed the background over the body's padding so nothing shows through
+ * underneath; on a modal short enough not to scroll this renders identically to
+ * a plain Group.
+ */
+export function ModalFooter({
+ children,
+ style,
+ ...props
+}: Omit & { style?: CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}