feat: implement ModalFooter component for consistent modal action layout

This commit is contained in:
estifanos
2026-08-05 08:50:38 +00:00
parent c8df8e1578
commit bbae230ad5
21 changed files with 152 additions and 74 deletions

View File

@@ -44,7 +44,7 @@ import {
useUpdateLicenseTemplateMutation, useUpdateLicenseTemplateMutation,
type LicenseTemplate, type LicenseTemplate,
} from '@ema-platform/api'; } 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 { authStorage, usePermissions } from '@ema-platform/auth';
import { PERMISSIONS } from '../../../layouts/nav-config'; 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.', 'Starts from the live design, or the built-in layout if this type has none.',
)} )}
</Text> </Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={() => setNewOpen(false)}> <Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</Button> </Button>
@@ -550,7 +550,7 @@ export function CertificateDesignerPage() {
> >
{t('designer.create', 'Create')} {t('designer.create', 'Create')}
</Button> </Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
</Container> </Container>

View File

@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react'; 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 { import {
useGetCertificationsQuery, useGetCertificationsQuery,
useCreateCertificationMutation, useCreateCertificationMutation,
@@ -199,10 +199,10 @@ export function CertificationPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm"> <Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text> <Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button> <Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button> <Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
</Group> </ModalFooter>
</Modal> </Modal>
</Stack> </Stack>
); );

View File

@@ -33,6 +33,7 @@ import {
useErrorHandler, useErrorHandler,
AdvancedTable, AdvancedTable,
useServerTable, useServerTable,
ModalFooter,
type AdvancedColumn, type AdvancedColumn,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import { LocationPage } from "../../location/pages/LocationPage"; import { LocationPage } from "../../location/pages/LocationPage";
@@ -388,14 +389,14 @@ function ProfessionTab() {
name: deleteTarget?.name?.[locale] ?? "", name: deleteTarget?.name?.[locale] ?? "",
})} })}
</Text> </Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm"> <Button variant="default" onClick={closeDelete} size="sm">
{t("configuration.cancel")} {t("configuration.cancel")}
</Button> </Button>
<Button color="red" onClick={confirmDeleteProf} size="sm"> <Button color="red" onClick={confirmDeleteProf} size="sm">
{t("configuration.delete")} {t("configuration.delete")}
</Button> </Button>
</Group> </ModalFooter>
</Modal> </Modal>
</> </>
); );

View File

@@ -40,7 +40,7 @@ import {
IconX, IconX,
IconDetails IconDetails
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { notify, useErrorHandler } from "@ema-platform/ui"; import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
import { import {
useGetExamQuery, useGetExamQuery,
useUpdateExamMutation, useUpdateExamMutation,
@@ -494,14 +494,14 @@ export function ExamDetailPage() {
mode="manual" mode="manual"
actions={setWhatAction} actions={setWhatAction}
/> />
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm"> <Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")} {t("exam.cancel")}
</Button> </Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}> <Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")} {t("exam.saveAssignments")}
</Button> </Button>
</Group> </ModalFooter>
</> </>
) : ( ) : (
<> <>
@@ -531,14 +531,14 @@ export function ExamDetailPage() {
onChange={setDraftQuestions} onChange={setDraftQuestions}
mode="random" mode="random"
/> />
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm"> <Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")} {t("exam.cancel")}
</Button> </Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}> <Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")} {t("exam.saveAssignments")}
</Button> </Button>
</Group> </ModalFooter>
</> </>
)} )}
</Stack> </Stack>

View File

@@ -31,7 +31,7 @@ import {
IconClipboardList, IconClipboardList,
IconDetails, IconDetails,
} from "@tabler/icons-react"; } 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 { useGetCertificationsQuery } from "../../certification/api/certification-api";
import { import {
useGetExamsQuery, useGetExamsQuery,
@@ -600,14 +600,14 @@ export function ExamPage() {
name: deleteTarget?.title?.[locale] ?? "", name: deleteTarget?.title?.[locale] ?? "",
})} })}
</Text> </Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm"> <Button variant="default" onClick={closeDelete} size="sm">
{t("exam.cancel")} {t("exam.cancel")}
</Button> </Button>
<Button color="red" onClick={handleDelete} size="sm"> <Button color="red" onClick={handleDelete} size="sm">
{t("exam.delete")} {t("exam.delete")}
</Button> </Button>
</Group> </ModalFooter>
</Modal> </Modal>
</Stack> </Stack>
); );

View File

@@ -3,7 +3,6 @@ import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Group,
Modal, Modal,
Select, Select,
Stack, Stack,
@@ -13,6 +12,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import type { ResolvedAction } from '../config/actions'; import type { ResolvedAction } from '../config/actions';
/** Reason codes offered per action. Free text is always available too. */ /** Reason codes offered per action. Free text is always available too. */
@@ -290,7 +290,7 @@ export function DecisionConfirmModal({
/> />
)} )}
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</Button> </Button>
@@ -310,7 +310,7 @@ export function DecisionConfirmModal({
> >
{t(action.labelKey)} {t(action.labelKey)}
</Button> </Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
); );

View File

@@ -57,7 +57,7 @@ import {
useScheduleInspectionMutation, useScheduleInspectionMutation,
type RemarkTargetType, type RemarkTargetType,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { ErrorState } from '@ema-platform/ui'; import { ErrorState, ModalFooter } from '@ema-platform/ui';
import { usePermissions } from '@ema-platform/auth'; import { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks'; import { useAppSelector } from '../../../store/hooks';
import { DecisionBar } from '../components/DecisionBar'; import { DecisionBar } from '../components/DecisionBar';
@@ -894,7 +894,7 @@ export function LicenseReviewPage() {
value={inspectionDate} value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)} onChange={(e) => setInspectionDate(e.currentTarget.value)}
/> />
<Group justify="flex-end"> <ModalFooter>
<Tooltip <Tooltip
label={t('review.pickDate', 'Pick a date and time first')} label={t('review.pickDate', 'Pick a date and time first')}
disabled={Boolean(inspectionDate)} disabled={Boolean(inspectionDate)}
@@ -924,7 +924,7 @@ export function LicenseReviewPage() {
> >
<IconCheck size={18} /> <IconCheck size={18} />
</ActionIcon> </ActionIcon>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
@@ -942,7 +942,7 @@ export function LicenseReviewPage() {
autosize autosize
minRows={3} minRows={3}
/> />
<Group grow> <ModalFooter grow>
<ActionIcon <ActionIcon
variant="light" variant="light"
color="teal" color="teal"
@@ -989,7 +989,7 @@ export function LicenseReviewPage() {
> >
<IconX size={18} /> <IconX size={18} />
</ActionIcon> </ActionIcon>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
</Container> </Container>

View File

@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react'; import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; 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 { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail'; import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm'; import { LocationForm } from '../components/LocationForm';
@@ -226,14 +226,14 @@ export function LocationPage() {
name: selectedLocation?.names[locale] ?? '', name: selectedLocation?.names[locale] ?? '',
})} })}
</Text> </Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeDeleteModal} size="sm"> <Button variant="default" onClick={closeDeleteModal} size="sm">
{t('location.cancel')} {t('location.cancel')}
</Button> </Button>
<Button color="red" onClick={handleDeleteConfirm} size="sm"> <Button color="red" onClick={handleDeleteConfirm} size="sm">
{t('location.delete')} {t('location.delete')}
</Button> </Button>
</Group> </ModalFooter>
</Modal> </Modal>
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} /> <LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />

View File

@@ -26,7 +26,7 @@ import {
IconInfoCircle, IconInfoCircle,
IconLock, IconLock,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; import { notify, ModalFooter } from '@ema-platform/ui';
import { import {
extractErrorMessage, extractErrorMessage,
localized, localized,
@@ -364,14 +364,14 @@ function FeeEditModal({
</> </>
)} )}
<Group justify="flex-end" mt="xs"> <ModalFooter mt="xs">
<Button variant="default" onClick={onClose} disabled={isLoading}> <Button variant="default" onClick={onClose} disabled={isLoading}>
Cancel Cancel
</Button> </Button>
<Button onClick={save} loading={isLoading}> <Button onClick={save} loading={isLoading}>
Save fees Save fees
</Button> </Button>
</Group> </ModalFooter>
</Stack> </Stack>
)} )}
</Modal> </Modal>

View File

@@ -18,7 +18,7 @@ import {
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react'; 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 { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { import {
useGetQuestionsQuery, useGetQuestionsQuery,
@@ -255,10 +255,10 @@ export function QuestionPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm"> <Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
<Text mb="md">{t('question.deleteConfirmText')}</Text> <Text mb="md">{t('question.deleteConfirmText')}</Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button> <Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button> <Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
</Group> </ModalFooter>
</Modal> </Modal>
</Stack> </Stack>
); );

View File

@@ -17,7 +17,7 @@ import {
Alert, Alert,
} from '@mantine/core'; } from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; 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 { useApiQuery } from '@ema-platform/api';
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api'; import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
import type { Exam } from '../../exam/types/exam'; import type { Exam } from '../../exam/types/exam';
@@ -195,12 +195,12 @@ export function RecordResultModal({
size="sm" size="sm"
/> />
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button> <Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}> <Button onClick={handleSave} size="sm" loading={isSaving}>
{t('result.saveResult')} {t('result.saveResult')}
</Button> </Button>
</Group> </ModalFooter>
</> </>
)} )}

View File

@@ -36,7 +36,7 @@ import {
IconChartBar, IconChartBar,
IconSearch, IconSearch,
} from '@tabler/icons-react'; } 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 type { BilingualValue } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api'; import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api'; import { useGetExamsQuery } from '../../exam/api/exam-api';
@@ -462,7 +462,7 @@ export function ResultPage() {
</> </>
)} )}
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button> <Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button <Button
onClick={handleDetailSave} onClick={handleDetailSave}
@@ -472,7 +472,7 @@ export function ResultPage() {
> >
{t('result.save')} {t('result.save')}
</Button> </Button>
</Group> </ModalFooter>
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text> <Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
@@ -481,10 +481,10 @@ export function ResultPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm"> <Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
<Text mb="md">{t('result.deleteConfirmText')}</Text> <Text mb="md">{t('result.deleteConfirmText')}</Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button> <Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button> <Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
</Group> </ModalFooter>
</Modal> </Modal>
{/* Choose exam, then record */} {/* Choose exam, then record */}
@@ -501,10 +501,10 @@ export function ResultPage() {
searchable searchable
required required
/> />
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button> <Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button> <Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -30,7 +30,7 @@ import {
IconSettings, IconSettings,
IconTrash, IconTrash,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; import { notify, ModalFooter } from '@ema-platform/ui';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -495,12 +495,12 @@ export function VesselRegistrationFormBuilderPage() {
onChange={(e) => setDraftRequired(e.currentTarget.checked)} onChange={(e) => setDraftRequired(e.currentTarget.checked)}
/> />
<Divider /> <Divider />
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button> <Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
<Button color="teal" onClick={saveField}> <Button color="teal" onClick={saveField}>
{isNew ? 'Add Field' : 'Save Changes'} {isNew ? 'Add Field' : 'Save Changes'}
</Button> </Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Drawer> </Drawer>
@@ -516,10 +516,10 @@ export function VesselRegistrationFormBuilderPage() {
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form? Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
This cannot be undone. This cannot be undone.
</Text> </Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button> <Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button color="red" onClick={confirmDelete}>Delete Field</Button> <Button color="red" onClick={confirmDelete}>Delete Field</Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
</Stack> </Stack>

View File

@@ -49,6 +49,7 @@ import {
type FieldErrors, type FieldErrors,
type ValidationIssue, type ValidationIssue,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { ModalFooter } from '@ema-platform/ui';
import { ConfigDrivenSection } from '../components/ConfigDrivenSection'; import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
import { DocumentSlots } from '../components/DocumentSlots'; import { DocumentSlots } from '../components/DocumentSlots';
import { StaffEvidence } from '../components/StaffEvidence'; import { StaffEvidence } from '../components/StaffEvidence';
@@ -617,17 +618,19 @@ export function LicenseApplicationPage() {
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })} onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
min={0} min={0}
/> />
<Button <ModalFooter>
onClick={async () => { <Button
if (!newStaff.fullName.trim() || !staffModal) return; onClick={async () => {
await addStaff({ id: appId, roleKey: staffModal, ...newStaff }); if (!newStaff.fullName.trim() || !staffModal) return;
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 }); await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
setStaffModal(null); setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
refetch(); setStaffModal(null);
}} refetch();
> }}
Add >
</Button> Add
</Button>
</ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
</Container> </Container>

View File

@@ -19,7 +19,7 @@ import {
useGetMyOperatorTypesQuery, useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation, useUpdateMyOperatorTypesMutation,
} from '@ema-platform/api'; } 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 * 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 Applications already filed carry on as they are, and licences
already issued to you stay valid and can still be renewed. already issued to you stay valid and can still be renewed.
</Text> </Text>
<Group justify="flex-end" gap="sm"> <ModalFooter gap="sm">
<Button <Button
variant="default" variant="default"
onClick={() => setConfirmingRemoval(false)} onClick={() => setConfirmingRemoval(false)}
@@ -209,7 +209,7 @@ export function OperationsFormContent({
<Button color="orange" loading={saving} onClick={persist}> <Button color="orange" loading={saving} onClick={persist}>
Remove and save Remove and save
</Button> </Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
</Stack> </Stack>

View File

@@ -46,7 +46,7 @@ import {
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom'; 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'; import type { Seafarer } from './SeafarerRegistryPage';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -479,10 +479,10 @@ function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () =>
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} /> <AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
</SimpleGrid> </SimpleGrid>
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" /> <Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
<Group justify="flex-end" mt="sm"> <ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button> <Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button> <Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
); );
@@ -503,10 +503,10 @@ function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () =>
</SimpleGrid> </SimpleGrid>
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" /> <Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} /> <Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
<Group justify="flex-end" mt="sm"> <ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button> <Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button> <Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
); );
@@ -533,10 +533,10 @@ function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: ()
<AmharicDatePicker label="To" value={toDate} onChange={setToDate} /> <AmharicDatePicker label="To" value={toDate} onChange={setToDate} />
</SimpleGrid> </SimpleGrid>
<TextInput label="Engagement Port" placeholder="Port name" /> <TextInput label="Engagement Port" placeholder="Port name" />
<Group justify="flex-end" mt="sm"> <ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button> <Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button> <Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
); );
@@ -560,10 +560,10 @@ function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => voi
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} /> <AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
</SimpleGrid> </SimpleGrid>
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" /> <Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
<Group justify="flex-end" mt="sm"> <ModalFooter mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button> <Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button> <Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
</Group> </ModalFooter>
</Stack> </Stack>
</Modal> </Modal>
); );

View File

@@ -93,6 +93,22 @@ export const portalTheme = createTheme({
Textarea: { defaultProps: { radius: 'md' } }, Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } }, Select: { defaultProps: { radius: 'md' } },
PasswordInput: { 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: { other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)', heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',

View File

@@ -31,6 +31,24 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)', md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)', 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: { other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)', heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
}, },

View File

@@ -1,5 +1,6 @@
export * from "./lib/input/BilingualInput"; export * from "./lib/input/BilingualInput";
export * from "./lib/feedback/ConfirmModal"; export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert"; export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify"; export * from "./lib/feedback/notify";
export * from "./lib/feedback/FeatureUnavailable"; export * from "./lib/feedback/FeatureUnavailable";

View File

@@ -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 { interface ConfirmModalProps {
opened: boolean; opened: boolean;
@@ -26,14 +27,14 @@ export function ConfirmModal({
<Text size="sm" mb="xl"> <Text size="sm" mb="xl">
{message} {message}
</Text> </Text>
<Group justify="flex-end"> <ModalFooter>
<Button variant="subtle" onClick={onClose} disabled={loading}> <Button variant="subtle" onClick={onClose} disabled={loading}>
{cancelLabel} {cancelLabel}
</Button> </Button>
<Button color="red" onClick={onConfirm} loading={loading}> <Button color="red" onClick={onConfirm} loading={loading}>
{confirmLabel} {confirmLabel}
</Button> </Button>
</Group> </ModalFooter>
</Modal> </Modal>
); );
} }

View File

@@ -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<GroupProps, 'style'> & { style?: CSSProperties }) {
return (
<Group
justify="flex-end"
{...props}
style={{
position: 'sticky',
bottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
marginInline: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
marginBottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
padding: 'var(--mb-padding, var(--mantine-spacing-md))',
background: 'var(--mantine-color-body)',
borderTop: '1px solid var(--mantine-color-default-border)',
zIndex: 1,
...style,
}}
>
{children}
</Group>
);
}