mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge pull request #12 from Tria-plc/Refactor
feat: implement permission-based access control across various pages …
This commit is contained in:
@@ -47,7 +47,7 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { EmptyState, ErrorState, ModalFooter, 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 { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
/** Same resolution the shared RTK Query baseQuery uses. */
|
/** Same resolution the shared RTK Query baseQuery uses. */
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core';
|
|||||||
import { IconUserCheck } from '@tabler/icons-react';
|
import { IconUserCheck } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||||
|
|
||||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||||
@@ -78,14 +79,19 @@ export function examCandidateColumns(
|
|||||||
label: t('exam.candidates.record'),
|
label: t('exam.candidates.record'),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<RequirePermission
|
||||||
size="compact-xs"
|
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
|
||||||
variant="light"
|
hideOnly
|
||||||
leftSection={<IconUserCheck size={12} />}
|
|
||||||
onClick={() => handlers.onRecord(row.original)}
|
|
||||||
>
|
>
|
||||||
{t('exam.candidates.record')}
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconUserCheck size={12} />}
|
||||||
|
onClick={() => handlers.onRecord(row.original)}
|
||||||
|
>
|
||||||
|
{t('exam.candidates.record')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core';
|
|||||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||||
@@ -70,14 +71,19 @@ export function examIncidentColumns(
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
|
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
|
||||||
<Button
|
<RequirePermission
|
||||||
size="compact-xs"
|
anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAM_INCIDENTS]}
|
||||||
variant="light"
|
hideOnly
|
||||||
leftSection={<IconAlertTriangle size={12} />}
|
|
||||||
onClick={() => handlers.onResolve(row.original)}
|
|
||||||
>
|
>
|
||||||
{t('exam.incidents.resolve')}
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconAlertTriangle size={12} />}
|
||||||
|
onClick={() => handlers.onResolve(row.original)}
|
||||||
|
>
|
||||||
|
{t('exam.incidents.resolve')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
|||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import {
|
import {
|
||||||
useGetExamIncidentsQuery,
|
useGetExamIncidentsQuery,
|
||||||
useGetExamRegistrationsQuery,
|
useGetExamRegistrationsQuery,
|
||||||
@@ -122,15 +123,20 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
|||||||
<Paper withBorder radius="lg" p="lg">
|
<Paper withBorder radius="lg" p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Title order={5}>{t('exam.incidents.section')}</Title>
|
<Title order={5}>{t('exam.incidents.section')}</Title>
|
||||||
<Button
|
<RequirePermission
|
||||||
size="xs"
|
anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAM_INCIDENTS]}
|
||||||
variant="light"
|
hideOnly
|
||||||
color="orange"
|
|
||||||
leftSection={<IconPlus size={14} />}
|
|
||||||
onClick={() => setAddOpen(true)}
|
|
||||||
>
|
>
|
||||||
{t('exam.incidents.add')}
|
<Button
|
||||||
</Button>
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
|
{t('exam.incidents.add')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{(incidents ?? []).length === 0 ? (
|
{(incidents ?? []).length === 0 ? (
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import {
|
import {
|
||||||
useGetExamQuery,
|
useGetExamQuery,
|
||||||
useUpdateExamMutation,
|
useUpdateExamMutation,
|
||||||
@@ -393,14 +394,16 @@ export function ExamDetailPage() {
|
|||||||
<Title order={5}>
|
<Title order={5}>
|
||||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||||
</Title>
|
</Title>
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
variant="light"
|
<Button
|
||||||
size="xs"
|
variant="light"
|
||||||
leftSection={<IconPlus size={14} />}
|
size="xs"
|
||||||
onClick={openAssignModal}
|
leftSection={<IconPlus size={14} />}
|
||||||
>
|
onClick={openAssignModal}
|
||||||
{t("exam.manageQuestions")}
|
>
|
||||||
</Button>
|
{t("exam.manageQuestions")}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
{(exam.questions ?? []).length === 0 ? (
|
{(exam.questions ?? []).length === 0 ? (
|
||||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||||
@@ -461,9 +464,11 @@ export function ExamDetailPage() {
|
|||||||
<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}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
{t("exam.saveAssignments")}
|
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||||
</Button>
|
{t("exam.saveAssignments")}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -478,9 +483,11 @@ export function ExamDetailPage() {
|
|||||||
size="xs"
|
size="xs"
|
||||||
style={{ width: 80 }}
|
style={{ width: 80 }}
|
||||||
/>
|
/>
|
||||||
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
{t('exam.randomSelect')}
|
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
|
||||||
</Button>
|
{t('exam.randomSelect')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
<QuestionAssigner
|
<QuestionAssigner
|
||||||
available={eligibleQuestions}
|
available={eligibleQuestions}
|
||||||
@@ -492,9 +499,11 @@ export function ExamDetailPage() {
|
|||||||
<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}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
{t("exam.saveAssignments")}
|
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||||
</Button>
|
{t("exam.saveAssignments")}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ActionIcon, Group } from "@mantine/core";
|
|||||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
import type { Exam } from "../../types/exam";
|
import type { Exam } from "../../types/exam";
|
||||||
|
|
||||||
export function examActionsColumn(
|
export function examActionsColumn(
|
||||||
@@ -17,22 +18,24 @@ export function examActionsColumn(
|
|||||||
align: "right",
|
align: "right",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ActionIcon
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="blue"
|
variant="subtle"
|
||||||
size="sm"
|
color="blue"
|
||||||
onClick={() => handlers.onEdit(row.original)}
|
size="sm"
|
||||||
>
|
onClick={() => handlers.onEdit(row.original)}
|
||||||
<IconEdit size={14} />
|
>
|
||||||
</ActionIcon>
|
<IconEdit size={14} />
|
||||||
<ActionIcon
|
</ActionIcon>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="red"
|
variant="subtle"
|
||||||
size="sm"
|
color="red"
|
||||||
onClick={() => handlers.onDelete(row.original)}
|
size="sm"
|
||||||
>
|
onClick={() => handlers.onDelete(row.original)}
|
||||||
<IconTrash size={14} />
|
>
|
||||||
</ActionIcon>
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</RequirePermission>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||||
import {
|
import {
|
||||||
useGetExamsQuery,
|
useGetExamsQuery,
|
||||||
@@ -450,14 +451,16 @@ export function ExamPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||||
variant="light"
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
variant="light"
|
||||||
onClick={() => setShowForm(true)}
|
leftSection={<IconPlus size={16} />}
|
||||||
size="sm"
|
onClick={() => setShowForm(true)}
|
||||||
>
|
size="sm"
|
||||||
{t("exam.add")}
|
>
|
||||||
</Button>
|
{t("exam.add")}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
|||||||
import { IconShieldCog } from '@tabler/icons-react';
|
import { IconShieldCog } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
||||||
ACTIVE: 'green',
|
ACTIVE: 'green',
|
||||||
@@ -84,16 +85,24 @@ export function licenseRegisterColumns(
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
actionsFor(row.original).length > 0 ? (
|
actionsFor(row.original).length > 0 ? (
|
||||||
<Tooltip label="Suspend / revoke / reinstate">
|
<RequirePermission
|
||||||
<Button
|
anyOf={[
|
||||||
size="compact-xs"
|
LICENSE_PERMISSIONS.SUSPEND_LICENSE,
|
||||||
variant="subtle"
|
LICENSE_PERMISSIONS.CANCEL_LICENSE,
|
||||||
leftSection={<IconShieldCog size={14} />}
|
]}
|
||||||
onClick={() => handlers.onStatus(row.original)}
|
hideOnly
|
||||||
>
|
>
|
||||||
Status
|
<Tooltip label="Suspend / revoke / reinstate">
|
||||||
</Button>
|
<Button
|
||||||
</Tooltip>
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={<IconShieldCog size={14} />}
|
||||||
|
onClick={() => handlers.onStatus(row.original)}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</RequirePermission>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
||||||
import { PERMISSIONS } from '../../../layouts/nav-config';
|
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where an action is rendered. One tier per action, decided here rather than
|
* Where an action is rendered. One tier per action, decided here rather than
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Button } from "@mantine/core";
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { LicenseApplication } from "@ema-platform/api";
|
import type { LicenseApplication } from "@ema-platform/api";
|
||||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
|
|
||||||
export function licenseQueueActionsColumn(
|
export function licenseQueueActionsColumn(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -19,13 +20,18 @@ export function licenseQueueActionsColumn(
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.assignedOfficerId === null &&
|
row.original.assignedOfficerId === null &&
|
||||||
row.original.status === "SUBMITTED" ? (
|
row.original.status === "SUBMITTED" ? (
|
||||||
<Button
|
<RequirePermission
|
||||||
size="xs"
|
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||||
loading={handlers.claiming}
|
hideOnly
|
||||||
onClick={() => handlers.onClaim(row.original.id)}
|
|
||||||
>
|
>
|
||||||
{t("queue.claim", "Claim")}
|
<Button
|
||||||
</Button>
|
size="xs"
|
||||||
|
loading={handlers.claiming}
|
||||||
|
onClick={() => handlers.onClaim(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("queue.claim", "Claim")}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
AmharicDatePicker,
|
AmharicDatePicker,
|
||||||
type AdvancedColumn,
|
type AdvancedColumn,
|
||||||
} from "@ema-platform/ui";
|
} from "@ema-platform/ui";
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
import {
|
import {
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
SAVED_VIEWS,
|
SAVED_VIEWS,
|
||||||
@@ -638,12 +639,17 @@ export function LicenseQueuePage() {
|
|||||||
>
|
>
|
||||||
{t("queue.export", "Export CSV")}
|
{t("queue.export", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
<RequirePermission
|
||||||
{t("queue.bulkClaim", {
|
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||||
count: selected.length,
|
hideOnly
|
||||||
defaultValue: "Claim {{count}}",
|
>
|
||||||
})}
|
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||||
</Button>
|
{t("queue.bulkClaim", {
|
||||||
|
count: selected.length,
|
||||||
|
defaultValue: "Claim {{count}}",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IconCheck, IconPaperclip, IconX } from '@tabler/icons-react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
export function medicalActionsColumn(
|
export function medicalActionsColumn(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -28,24 +29,29 @@ export function medicalActionsColumn(
|
|||||||
>
|
>
|
||||||
{t('recordVerification.evidence', 'Evidence')}
|
{t('recordVerification.evidence', 'Evidence')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<RequirePermission
|
||||||
size="compact-xs"
|
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
|
||||||
color="teal"
|
hideOnly
|
||||||
leftSection={<IconCheck size={14} />}
|
|
||||||
loading={handlers.ruling}
|
|
||||||
onClick={() => handlers.onVerify(row.original)}
|
|
||||||
>
|
>
|
||||||
{t('recordVerification.verify', 'Verify')}
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
<Button
|
color="teal"
|
||||||
size="compact-xs"
|
leftSection={<IconCheck size={14} />}
|
||||||
color="red"
|
loading={handlers.ruling}
|
||||||
variant="light"
|
onClick={() => handlers.onVerify(row.original)}
|
||||||
leftSection={<IconX size={14} />}
|
>
|
||||||
onClick={() => handlers.onReject(row.original)}
|
{t('recordVerification.verify', 'Verify')}
|
||||||
>
|
</Button>
|
||||||
{t('recordVerification.reject', 'Reject')}
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconX size={14} />}
|
||||||
|
onClick={() => handlers.onReject(row.original)}
|
||||||
|
>
|
||||||
|
{t('recordVerification.reject', 'Reject')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -75,24 +81,29 @@ export function seaServiceActionsColumn(
|
|||||||
>
|
>
|
||||||
{t('recordVerification.evidence', 'Evidence')}
|
{t('recordVerification.evidence', 'Evidence')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<RequirePermission
|
||||||
size="compact-xs"
|
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
|
||||||
color="teal"
|
hideOnly
|
||||||
leftSection={<IconCheck size={14} />}
|
|
||||||
loading={handlers.ruling}
|
|
||||||
onClick={() => handlers.onVerify(row.original)}
|
|
||||||
>
|
>
|
||||||
{t('recordVerification.verify', 'Verify')}
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
<Button
|
color="teal"
|
||||||
size="compact-xs"
|
leftSection={<IconCheck size={14} />}
|
||||||
color="red"
|
loading={handlers.ruling}
|
||||||
variant="light"
|
onClick={() => handlers.onVerify(row.original)}
|
||||||
leftSection={<IconX size={14} />}
|
>
|
||||||
onClick={() => handlers.onReject(row.original)}
|
{t('recordVerification.verify', 'Verify')}
|
||||||
>
|
</Button>
|
||||||
{t('recordVerification.reject', 'Reject')}
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconX size={14} />}
|
||||||
|
onClick={() => handlers.onReject(row.original)}
|
||||||
|
>
|
||||||
|
{t('recordVerification.reject', 'Reject')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IconEdit } from '@tabler/icons-react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { LicenseType } from '@ema-platform/api';
|
import type { LicenseType } from '@ema-platform/api';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
export function paymentConfigActionsColumn(
|
export function paymentConfigActionsColumn(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -13,14 +14,19 @@ export function paymentConfigActionsColumn(
|
|||||||
size: 90,
|
size: 90,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<RequirePermission
|
||||||
size="xs"
|
anyOf={[LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]}
|
||||||
variant="light"
|
hideOnly
|
||||||
leftSection={<IconEdit size={14} />}
|
|
||||||
onClick={() => handlers.onEdit(row.original)}
|
|
||||||
>
|
>
|
||||||
{t('paymentConfig.edit', 'Edit')}
|
<Button
|
||||||
</Button>
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconEdit size={14} />}
|
||||||
|
onClick={() => handlers.onEdit(row.original)}
|
||||||
|
>
|
||||||
|
{t('paymentConfig.edit', 'Edit')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group } from '@mantine/core';
|
|||||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { Question } from '../../types/question';
|
import type { Question } from '../../types/question';
|
||||||
|
|
||||||
export function questionActionsColumn(
|
export function questionActionsColumn(
|
||||||
@@ -22,43 +23,49 @@ export function questionActionsColumn(
|
|||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||||
size="compact-xs"
|
<Button
|
||||||
variant="light"
|
size="compact-xs"
|
||||||
leftSection={<IconSend size={12} />}
|
variant="light"
|
||||||
loading={handlers.isSubmittingReview}
|
leftSection={<IconSend size={12} />}
|
||||||
onClick={() => handlers.onSubmitForApproval(q)}
|
loading={handlers.isSubmittingReview}
|
||||||
>
|
onClick={() => handlers.onSubmitForApproval(q)}
|
||||||
{t('question.qc.submit')}
|
>
|
||||||
</Button>
|
{t('question.qc.submit')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
{q.status === 'PENDING_APPROVAL' && (
|
{q.status === 'PENDING_APPROVAL' && (
|
||||||
<>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||||
{t('question.qc.approve')}
|
{t('question.qc.approve')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||||
{t('question.qc.reject')}
|
{t('question.qc.reject')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
{q.status === 'APPROVED' && (
|
{q.status === 'APPROVED' && (
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_QUESTION]} hideOnly>
|
||||||
size="compact-xs"
|
<Button
|
||||||
variant="subtle"
|
size="compact-xs"
|
||||||
color="dark"
|
variant="subtle"
|
||||||
leftSection={<IconGavel size={12} />}
|
color="dark"
|
||||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
leftSection={<IconGavel size={12} />}
|
||||||
>
|
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||||
{t('question.qc.retire')}
|
>
|
||||||
</Button>
|
{t('question.qc.retire')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||||
<IconEdit size={14} />
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
||||||
</ActionIcon>
|
<IconEdit size={14} />
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
</ActionIcon>
|
||||||
<IconTrash size={14} />
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
||||||
</ActionIcon>
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||||
import {
|
import {
|
||||||
useGetQuestionsQuery,
|
useGetQuestionsQuery,
|
||||||
@@ -219,9 +220,11 @@ export function QuestionPage() {
|
|||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
<Title order={2}>{t('question.title')}</Title>
|
<Title order={2}>{t('question.title')}</Title>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
|
||||||
{t('question.addQuestion')}
|
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||||
</Button>
|
{t('question.addQuestion')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import { recordResultColumns } from './columns';
|
import { recordResultColumns } from './columns';
|
||||||
import { useCreateResultMutation } from '../../api/result-api';
|
import { useCreateResultMutation } from '../../api/result-api';
|
||||||
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
|
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
|
||||||
@@ -207,9 +208,11 @@ export function RecordResultModal({
|
|||||||
|
|
||||||
<ModalFooter>
|
<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}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
|
||||||
{t('result.saveResult')}
|
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||||
</Button>
|
{t('result.saveResult')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core';
|
|||||||
import { IconGavel } from '@tabler/icons-react';
|
import { IconGavel } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { ExamAppeal } from '../../types/result';
|
import type { ExamAppeal } from '../../types/result';
|
||||||
|
|
||||||
export function examAppealsColumns(
|
export function examAppealsColumns(
|
||||||
@@ -59,15 +60,17 @@ export function examAppealsColumns(
|
|||||||
label: t('result.appeals.decide'),
|
label: t('result.appeals.decide'),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.DECIDE_EXAM_APPEAL]} hideOnly>
|
||||||
size="compact-xs"
|
<Button
|
||||||
variant="light"
|
size="compact-xs"
|
||||||
color="grape"
|
variant="light"
|
||||||
leftSection={<IconGavel size={12} />}
|
color="grape"
|
||||||
onClick={() => handlers.onDecide(row.original)}
|
leftSection={<IconGavel size={12} />}
|
||||||
>
|
onClick={() => handlers.onDecide(row.original)}
|
||||||
{t('result.appeals.decide')}
|
>
|
||||||
</Button>
|
{t('result.appeals.decide')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Button, Group } from '@mantine/core';
|
|||||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { Result } from '../../types/result';
|
import type { Result } from '../../types/result';
|
||||||
|
|
||||||
export type QcAction = 'moderate' | 'approve' | 'return';
|
export type QcAction = 'moderate' | 'approve' | 'return';
|
||||||
@@ -23,31 +24,45 @@ export function resultActionsColumn(
|
|||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||||
<>
|
<>
|
||||||
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||||
{t('result.review.moderate')}
|
<Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||||
</Button>
|
{t('result.review.moderate')}
|
||||||
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
</Button>
|
||||||
{t('result.review.approve')}
|
</RequirePermission>
|
||||||
</Button>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||||
|
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||||
|
{t('result.review.approve')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||||
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
<RequirePermission
|
||||||
{t('result.review.return')}
|
anyOf={[
|
||||||
</Button>
|
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||||
|
LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT,
|
||||||
|
]}
|
||||||
|
hideOnly
|
||||||
|
>
|
||||||
|
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||||
|
{t('result.review.return')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
|
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
|
||||||
{t('result.action.viewEdit')}
|
{t('result.action.viewEdit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||||
size="xs"
|
<Button
|
||||||
variant="subtle"
|
size="xs"
|
||||||
color="red"
|
variant="subtle"
|
||||||
leftSection={<IconTrash size={13} />}
|
color="red"
|
||||||
onClick={() => handlers.onDelete(r)}
|
leftSection={<IconTrash size={13} />}
|
||||||
>
|
onClick={() => handlers.onDelete(r)}
|
||||||
{t('result.action.delete')}
|
>
|
||||||
</Button>
|
{t('result.action.delete')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable,
|
|||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import type { BilingualValue } from '@ema-platform/ui';
|
import type { BilingualValue } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import {
|
import {
|
||||||
useGetResultsQuery,
|
useGetResultsQuery,
|
||||||
useLazyGetResultQuery,
|
useLazyGetResultQuery,
|
||||||
@@ -300,17 +301,19 @@ export function ResultPage() {
|
|||||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
|
||||||
variant="light"
|
<Button
|
||||||
color="teal"
|
variant="light"
|
||||||
size="sm"
|
color="teal"
|
||||||
loading={isPublishing}
|
size="sm"
|
||||||
disabled={!examFilter}
|
loading={isPublishing}
|
||||||
leftSection={<IconSend size={15} />}
|
disabled={!examFilter}
|
||||||
onClick={handlePublish}
|
leftSection={<IconSend size={15} />}
|
||||||
>
|
onClick={handlePublish}
|
||||||
{t('result.review.publish')}
|
>
|
||||||
</Button>
|
{t('result.review.publish')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||||
{t('result.record')}
|
{t('result.record')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Button, Tooltip } from '@mantine/core';
|
|||||||
import { IconShieldCog } from '@tabler/icons-react';
|
import { IconShieldCog } from '@tabler/icons-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
import type { ProfileRow } from './columns';
|
import type { ProfileRow } from './columns';
|
||||||
|
|
||||||
export function seafarerStatusActionColumn(
|
export function seafarerStatusActionColumn(
|
||||||
@@ -13,16 +14,21 @@ export function seafarerStatusActionColumn(
|
|||||||
label: t('seafarerRegistry.columns.actions', 'Actions'),
|
label: t('seafarerRegistry.columns.actions', 'Actions'),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.seafarerNumber ? (
|
row.original.seafarerNumber ? (
|
||||||
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
|
<RequirePermission
|
||||||
<Button
|
anyOf={[LICENSE_PERMISSIONS.MANAGE_SEAFARER_STATUS]}
|
||||||
size="compact-xs"
|
hideOnly
|
||||||
variant="subtle"
|
>
|
||||||
leftSection={<IconShieldCog size={14} />}
|
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
|
||||||
onClick={() => handlers.onStatus(row.original)}
|
<Button
|
||||||
>
|
size="compact-xs"
|
||||||
{t('seafarerRegistry.statusAction', 'Status')}
|
variant="subtle"
|
||||||
</Button>
|
leftSection={<IconShieldCog size={14} />}
|
||||||
</Tooltip>
|
onClick={() => handlers.onStatus(row.original)}
|
||||||
|
>
|
||||||
|
{t('seafarerRegistry.statusAction', 'Status')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</RequirePermission>
|
||||||
) : null,
|
) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
|||||||
import { IconShieldCog } from '@tabler/icons-react';
|
import { IconShieldCog } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Vessel } from '@ema-platform/api';
|
import type { Vessel } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||||
REGISTERED: 'green',
|
REGISTERED: 'green',
|
||||||
@@ -76,19 +77,24 @@ export function vesselRegistrationQueueColumns(
|
|||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Tooltip label="Suspend / deregister / reinstate">
|
<RequirePermission
|
||||||
<Button
|
anyOf={[LICENSE_PERMISSIONS.MANAGE_VESSEL_STATUS]}
|
||||||
size="compact-xs"
|
hideOnly
|
||||||
variant="subtle"
|
>
|
||||||
leftSection={<IconShieldCog size={14} />}
|
<Tooltip label="Suspend / deregister / reinstate">
|
||||||
onClick={(e) => {
|
<Button
|
||||||
e.stopPropagation();
|
size="compact-xs"
|
||||||
handlers.onStatus(row.original);
|
variant="subtle"
|
||||||
}}
|
leftSection={<IconShieldCog size={14} />}
|
||||||
>
|
onClick={(e) => {
|
||||||
Status
|
e.stopPropagation();
|
||||||
</Button>
|
handlers.onStatus(row.original);
|
||||||
</Tooltip>
|
}}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,24 +26,13 @@ import {
|
|||||||
IconUserShield,
|
IconUserShield,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { NavSection } from '@ema-platform/ui';
|
import type { NavSection } from '@ema-platform/ui';
|
||||||
|
import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission keys mirrored from the API's `LICENSE_PERMISSIONS`.
|
* Every licence-type queue and its review workspace share one gate: the
|
||||||
*
|
* licence application queue permissions, granted as any-of.
|
||||||
* Kept as literals rather than imported: the backoffice bundle must not pull
|
|
||||||
* in server code, and these strings are a published contract — the IAM seed
|
|
||||||
* and every `PermissionGuard([...])` already read from the same list.
|
|
||||||
*/
|
*/
|
||||||
export const PERMISSIONS = {
|
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||||
VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue',
|
|
||||||
VIEW_APPLICATIONS: 'can:View:license-applications',
|
|
||||||
VIEW_LICENSES: 'can:View:licenses',
|
|
||||||
VIEW_LICENSE_TYPES: 'can:View:license-types',
|
|
||||||
VIEW_PAYMENTS: 'can:View:license-payments',
|
|
||||||
VIEW_TEMPLATES: 'can:View:license-templates',
|
|
||||||
UPDATE_TEMPLATE: 'can:update:license-template',
|
|
||||||
PUBLISH_TEMPLATE: 'can:publish:license-template',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The backoffice information architecture.
|
* The backoffice information architecture.
|
||||||
@@ -63,80 +52,92 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
to: '/licence-review',
|
to: '/licence-review',
|
||||||
label: 'nav.allApplications',
|
label: 'nav.allApplications',
|
||||||
icon: IconListCheck,
|
icon: IconListCheck,
|
||||||
permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE],
|
permissions: APPLICATION_QUEUE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// A disclosure, not a destination — each child deep-links the grid to
|
// A disclosure, not a destination — each child deep-links the grid to
|
||||||
// one type, which is a facet of the same workspace.
|
// one type, which is a facet of the same workspace.
|
||||||
label: 'nav.byType',
|
label: 'nav.byType',
|
||||||
icon: IconTruck,
|
icon: IconTruck,
|
||||||
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
|
permissions: APPLICATION_QUEUE,
|
||||||
children: [
|
children: [
|
||||||
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck },
|
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip },
|
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription },
|
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers },
|
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor },
|
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/licence-register',
|
to: '/licence-register',
|
||||||
label: 'nav.licenceRegister',
|
label: 'nav.licenceRegister',
|
||||||
icon: IconListCheck,
|
icon: IconListCheck,
|
||||||
permissions: [PERMISSIONS.VIEW_LICENSES],
|
permissions: [P.VIEW_LICENSES],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/certificate-designer',
|
to: '/certificate-designer',
|
||||||
label: 'nav.certificateDesigner',
|
label: 'nav.certificateDesigner',
|
||||||
icon: IconRosetteDiscountCheck,
|
icon: IconRosetteDiscountCheck,
|
||||||
permissions: [PERMISSIONS.VIEW_TEMPLATES],
|
permissions: [P.VIEW_TEMPLATES],
|
||||||
},
|
},
|
||||||
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff },
|
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff },
|
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
|
||||||
{
|
{
|
||||||
|
// Every figure on it is derived from the licence application queue.
|
||||||
to: '/logistics-head-dashboard',
|
to: '/logistics-head-dashboard',
|
||||||
label: 'nav.logisticsHeadDashboard',
|
label: 'nav.logisticsHeadDashboard',
|
||||||
icon: IconGauge,
|
icon: IconGauge,
|
||||||
|
permissions: APPLICATION_QUEUE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/payment-config',
|
to: '/payment-config',
|
||||||
label: 'nav.paymentConfig',
|
label: 'nav.paymentConfig',
|
||||||
icon: IconCreditCard,
|
icon: IconCreditCard,
|
||||||
permissions: [PERMISSIONS.VIEW_PAYMENTS],
|
permissions: [P.VIEW_PAYMENTS],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupSeafarer',
|
label: 'nav.groupSeafarer',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
|
||||||
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId },
|
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck },
|
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck },
|
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp },
|
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp },
|
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupVessels',
|
label: 'nav.groupVessels',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor },
|
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange },
|
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupExaminations',
|
label: 'nav.groupExaminations',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark, permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION] },
|
||||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
{
|
||||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
to: '/exams',
|
||||||
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel },
|
label: 'nav.exams',
|
||||||
|
icon: IconClipboardList,
|
||||||
|
permissions: [P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/exam-results',
|
||||||
|
label: 'nav.examResults',
|
||||||
|
icon: IconReport,
|
||||||
|
permissions: [P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT],
|
||||||
|
},
|
||||||
|
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,10 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||||
{
|
{
|
||||||
|
// Professions, locations and certifications have no dedicated keys;
|
||||||
|
// the config-view keys are the closest published contract.
|
||||||
to: '/configuration',
|
to: '/configuration',
|
||||||
label: 'nav.configuration',
|
label: 'nav.configuration',
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
permissions: [PERMISSIONS.VIEW_LICENSE_TYPES],
|
permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES],
|
||||||
},
|
},
|
||||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
@@ -8,6 +9,8 @@ import {
|
|||||||
ForgotPasswordPage,
|
ForgotPasswordPage,
|
||||||
SetPasswordPage,
|
SetPasswordPage,
|
||||||
OTPVerificationPage,
|
OTPVerificationPage,
|
||||||
|
RequirePermission,
|
||||||
|
LICENSE_PERMISSIONS as P,
|
||||||
} from '@ema-platform/auth';
|
} from '@ema-platform/auth';
|
||||||
import { AuthLayout } from '../layouts/AuthLayout';
|
import { AuthLayout } from '../layouts/AuthLayout';
|
||||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||||
@@ -38,6 +41,14 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
|
|||||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||||
|
|
||||||
|
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||||
|
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||||
|
|
||||||
|
/** Route gate: same keys as the route's nav item in nav-config.ts. */
|
||||||
|
const guard = (anyOf: string[], element: ReactNode) => (
|
||||||
|
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
|
||||||
|
);
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
element: <AuthLayout />,
|
element: <AuthLayout />,
|
||||||
@@ -60,41 +71,41 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
{ path: 'dashboard', element: <DashboardPage /> },
|
||||||
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
|
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
|
||||||
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
|
||||||
{ path: 'profile', element: <ProfilePage /> },
|
{ path: 'profile', element: <ProfilePage /> },
|
||||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
{ path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], <ConfigurationPage />) },
|
||||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||||
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
{ path: 'applications/:id', element: guard(APPLICATION_QUEUE, <ApplicationReviewPage />) },
|
||||||
// CoC/CoP review happens in the config-driven licence queue.
|
// CoC/CoP review happens in the config-driven licence queue.
|
||||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Endorsement review happens in the config-driven licence queue.
|
// Endorsement review happens in the config-driven licence queue.
|
||||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
|
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
{ path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
|
||||||
{ path: 'questions', element: <QuestionPage /> },
|
{ path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], <QuestionPage />) },
|
||||||
{ path: 'exams', element: <ExamPage /> },
|
{ path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamPage />) },
|
||||||
{ path: 'exams/:id', element: <ExamDetailPage /> },
|
{ path: 'exams/:id', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamDetailPage />) },
|
||||||
{ path: 'exam-results', element: <ResultPage /> },
|
{ path: 'exam-results', element: guard([P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT], <ResultPage />) },
|
||||||
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
|
{ path: 'exam-appeals', element: guard([P.DECIDE_EXAM_APPEAL], <ExamAppealsPage />) },
|
||||||
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
|
||||||
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
|
{ path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
|
||||||
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||||
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||||
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
|
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
|
||||||
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Config-driven review workspace, shared by every licence type.
|
// Config-driven review workspace, shared by every licence type.
|
||||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
{ path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], <CertificateDesignerPage />) },
|
||||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
{ path: 'licence-review', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
|
||||||
{ path: 'licence-register', element: <LicenseRegisterPage /> },
|
{ path: 'licence-register', element: guard([P.VIEW_LICENSES], <LicenseRegisterPage />) },
|
||||||
// Deep link into the grid with the type facet pinned, so "Freight
|
// Deep link into the grid with the type facet pinned, so "Freight
|
||||||
// Forwarder" in the nav is a filtered view rather than a page.
|
// Forwarder" in the nav is a filtered view rather than a page.
|
||||||
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
|
{ path: 'licence-review/type/:typeCode', element: guard(APPLICATION_QUEUE, <LicenseQueuePage />) },
|
||||||
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
|
{ path: 'licence-review/:id', element: guard(APPLICATION_QUEUE, <LicenseReviewPage />) },
|
||||||
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'shipping-agent-license', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'shipping-agent-license', element: <Navigate to="/licence-review" replace /> },
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { Badge, Button, Text } from '@mantine/core';
|
|||||||
import { IconCertificate } from '@tabler/icons-react';
|
import { IconCertificate } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||||
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
export function certificateColumns(deps: {
|
export function certificateColumns(deps: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
localized: (value: Bilingual | undefined) => string;
|
localized: (value: Bilingual | undefined) => string;
|
||||||
showDate: (value: string | null | undefined) => string;
|
showDate: (value: string | null | undefined) => string;
|
||||||
onDownload: (license: IssuedLicense) => void;
|
onDownload: (license: IssuedLicense) => void;
|
||||||
@@ -45,16 +48,17 @@ export function certificateColumns(deps: {
|
|||||||
header: '',
|
header: '',
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<Button
|
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? (
|
||||||
size="compact-xs"
|
<Button
|
||||||
variant="subtle"
|
size="compact-xs"
|
||||||
leftSection={<IconCertificate size={14} />}
|
variant="subtle"
|
||||||
onClick={() => deps.onDownload(row.original)}
|
leftSection={<IconCertificate size={14} />}
|
||||||
>
|
onClick={() => deps.onDownload(row.original)}
|
||||||
Download
|
>
|
||||||
</Button>
|
Download
|
||||||
),
|
</Button>
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
useGetMyMedicalCertificatesQuery,
|
useGetMyMedicalCertificatesQuery,
|
||||||
useGetMySeaTimeQuery,
|
useGetMySeaTimeQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { useCurrentProfile } from '@ema-platform/auth';
|
import { useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { certificateColumns } from './columns';
|
import { certificateColumns } from './columns';
|
||||||
@@ -76,6 +76,7 @@ export function CertificatesPage() {
|
|||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const issuedTable = useServerTable();
|
const issuedTable = useServerTable();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const registered =
|
const registered =
|
||||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||||
@@ -225,6 +226,7 @@ export function CertificatesPage() {
|
|||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
tableName="Issued certificates"
|
tableName="Issued certificates"
|
||||||
columns={certificateColumns({
|
columns={certificateColumns({
|
||||||
|
can,
|
||||||
localized,
|
localized,
|
||||||
showDate,
|
showDate,
|
||||||
onDownload: (license) => download(license.id),
|
onDownload: (license) => download(license.id),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Badge, Button, Text } from '@mantine/core';
|
|||||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Bilingual } from '@ema-platform/api';
|
import type { Bilingual } from '@ema-platform/api';
|
||||||
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
import type {
|
import type {
|
||||||
AttendanceStatus,
|
AttendanceStatus,
|
||||||
MyAppeal,
|
MyAppeal,
|
||||||
@@ -19,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function registrationColumns(deps: {
|
export function registrationColumns(deps: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
localized: (value: Bilingual | undefined) => string;
|
localized: (value: Bilingual | undefined) => string;
|
||||||
showDate: (value: string | null | undefined) => string;
|
showDate: (value: string | null | undefined) => string;
|
||||||
onDownloadSlip: (registration: MyRegistration) => void;
|
onDownloadSlip: (registration: MyRegistration) => void;
|
||||||
@@ -72,21 +75,24 @@ export function registrationColumns(deps: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Slip',
|
header: 'Slip',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<Button
|
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
|
||||||
size="compact-xs"
|
<Button
|
||||||
variant="light"
|
size="compact-xs"
|
||||||
leftSection={<IconFileText size={13} />}
|
variant="light"
|
||||||
onClick={() => deps.onDownloadSlip(row.original)}
|
leftSection={<IconFileText size={13} />}
|
||||||
>
|
onClick={() => deps.onDownloadSlip(row.original)}
|
||||||
Slip
|
>
|
||||||
</Button>
|
Slip
|
||||||
),
|
</Button>
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resultColumns(deps: {
|
export function resultColumns(deps: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
localized: (value: Bilingual | undefined) => string;
|
localized: (value: Bilingual | undefined) => string;
|
||||||
showDate: (value: string | null | undefined) => string;
|
showDate: (value: string | null | undefined) => string;
|
||||||
appeals: MyAppeal[];
|
appeals: MyAppeal[];
|
||||||
@@ -128,7 +134,7 @@ export function resultColumns(deps: {
|
|||||||
<Badge size="sm" variant="light" color="grape">
|
<Badge size="sm" variant="light" color="grape">
|
||||||
{appeal.appealNumber} · {appeal.status}
|
{appeal.appealNumber} · {appeal.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -138,7 +144,7 @@ export function resultColumns(deps: {
|
|||||||
>
|
>
|
||||||
Appeal
|
Appeal
|
||||||
</Button>
|
</Button>
|
||||||
);
|
) : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
openAuthedDocument,
|
openAuthedDocument,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
usePermissions,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
import { registrationColumns, resultColumns } from './columns';
|
import { registrationColumns, resultColumns } from './columns';
|
||||||
|
|
||||||
export interface OpenExam {
|
export interface OpenExam {
|
||||||
@@ -107,6 +112,7 @@ export function ExamsPage() {
|
|||||||
const [appealTrigger, { isLoading: appealing }] = useApiMutation();
|
const [appealTrigger, { isLoading: appealing }] = useApiMutation();
|
||||||
const registrationTable = useServerTable();
|
const registrationTable = useServerTable();
|
||||||
const resultTable = useServerTable();
|
const resultTable = useServerTable();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
|
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
|
||||||
|
|
||||||
@@ -211,14 +217,16 @@ export function ExamsPage() {
|
|||||||
Registered
|
Registered
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly>
|
||||||
size="compact-sm"
|
<Button
|
||||||
loading={registering}
|
size="compact-sm"
|
||||||
leftSection={<IconClipboardList size={14} />}
|
loading={registering}
|
||||||
onClick={() => register(exam)}
|
leftSection={<IconClipboardList size={14} />}
|
||||||
>
|
onClick={() => register(exam)}
|
||||||
Register
|
>
|
||||||
</Button>
|
Register
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -231,6 +239,7 @@ export function ExamsPage() {
|
|||||||
<AdvancedTable<MyRegistration>
|
<AdvancedTable<MyRegistration>
|
||||||
tableName="My registrations"
|
tableName="My registrations"
|
||||||
columns={registrationColumns({
|
columns={registrationColumns({
|
||||||
|
can,
|
||||||
localized,
|
localized,
|
||||||
showDate,
|
showDate,
|
||||||
onDownloadSlip: downloadSlip,
|
onDownloadSlip: downloadSlip,
|
||||||
@@ -250,6 +259,7 @@ export function ExamsPage() {
|
|||||||
<AdvancedTable<MyResult>
|
<AdvancedTable<MyResult>
|
||||||
tableName="My results"
|
tableName="My results"
|
||||||
columns={resultColumns({
|
columns={resultColumns({
|
||||||
|
can,
|
||||||
localized,
|
localized,
|
||||||
showDate,
|
showDate,
|
||||||
appeals: appeals ?? [],
|
appeals: appeals ?? [],
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import {
|
||||||
|
LICENSE_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renewal reuses the ordinary application wizard — a renewal is an
|
* Renewal reuses the ordinary application wizard — a renewal is an
|
||||||
@@ -109,36 +114,46 @@ export function LicenseCard({
|
|||||||
{showDate(license.expiryDate)}
|
{showDate(license.expiryDate)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Tooltip label="Download certificate">
|
<RequirePermission
|
||||||
<ActionIcon
|
anyOf={[
|
||||||
variant="light"
|
PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES,
|
||||||
radius="md"
|
PORTAL_PERMISSIONS.VIEW_COMPANY_LICENSES,
|
||||||
size="lg"
|
]}
|
||||||
loading={isDownloading}
|
hideOnly
|
||||||
onClick={onDownload}
|
>
|
||||||
>
|
<Tooltip label="Download certificate">
|
||||||
<IconDownload size={16} />
|
<ActionIcon
|
||||||
</ActionIcon>
|
variant="light"
|
||||||
</Tooltip>
|
radius="md"
|
||||||
|
size="lg"
|
||||||
|
loading={isDownloading}
|
||||||
|
onClick={onDownload}
|
||||||
|
>
|
||||||
|
<IconDownload size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Renewal opens inside the licence type's window and stays open after
|
{/* Renewal opens inside the licence type's window and stays open after
|
||||||
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
|
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
|
||||||
{renewable && (
|
{renewable && (
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
|
||||||
fullWidth
|
<Button
|
||||||
mt="sm"
|
fullWidth
|
||||||
size="xs"
|
mt="sm"
|
||||||
variant={expired ? 'filled' : 'light'}
|
size="xs"
|
||||||
color={expired ? 'orange' : undefined}
|
variant={expired ? 'filled' : 'light'}
|
||||||
loading={isRenewing}
|
color={expired ? 'orange' : undefined}
|
||||||
leftSection={<IconRefresh size={14} />}
|
loading={isRenewing}
|
||||||
onClick={onRenew}
|
leftSection={<IconRefresh size={14} />}
|
||||||
>
|
onClick={onRenew}
|
||||||
{expired
|
>
|
||||||
? 'Renew — this licence has expired'
|
{expired
|
||||||
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
? 'Renew — this licence has expired'
|
||||||
</Button>
|
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
useGetMyOperatorTypesQuery,
|
useGetMyOperatorTypesQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
|
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The licence catalogue an applicant chooses from, grouped by category.
|
* The licence catalogue an applicant chooses from, grouped by category.
|
||||||
@@ -308,16 +309,18 @@ function LicenseTypeCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
|
||||||
fullWidth
|
<Button
|
||||||
mt="sm"
|
fullWidth
|
||||||
size="xs"
|
mt="sm"
|
||||||
variant="light"
|
size="xs"
|
||||||
color={canApply ? undefined : 'gray'}
|
variant="light"
|
||||||
rightSection={<IconArrowRight size={14} />}
|
color={canApply ? undefined : 'gray'}
|
||||||
>
|
rightSection={<IconArrowRight size={14} />}
|
||||||
{canApply ? 'Start application' : 'Add to my operations'}
|
>
|
||||||
</Button>
|
{canApply ? 'Start application' : 'Add to my operations'}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ import {
|
|||||||
type Vessel,
|
type Vessel,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui';
|
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui';
|
||||||
import { useCurrentProfile } from '@ema-platform/auth';
|
import {
|
||||||
|
LICENSE_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
useCurrentProfile,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection';
|
import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection';
|
||||||
import { DocumentSlots } from '../components/DocumentSlots';
|
import { DocumentSlots } from '../components/DocumentSlots';
|
||||||
import { StaffEvidence } from '../components/StaffEvidence';
|
import { StaffEvidence } from '../components/StaffEvidence';
|
||||||
@@ -675,14 +680,23 @@ export function LicenseApplicationPage() {
|
|||||||
{active < steps.length - 1 ? (
|
{active < steps.length - 1 ? (
|
||||||
<Button onClick={handleContinue}>Continue</Button>
|
<Button onClick={handleContinue}>Continue</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<RequirePermission
|
||||||
color="teal"
|
anyOf={
|
||||||
loading={submitting || resubmitting}
|
isAdjusting
|
||||||
disabled={readOnly}
|
? [PORTAL_PERMISSIONS.RESUBMIT_APPLICATION]
|
||||||
onClick={handleSubmit}
|
: [LICENSE_PERMISSIONS.SUBMIT_APPLICATION]
|
||||||
|
}
|
||||||
|
hideOnly
|
||||||
>
|
>
|
||||||
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
|
<Button
|
||||||
</Button>
|
color="teal"
|
||||||
|
loading={submitting || resubmitting}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { IconDownload } from '@tabler/icons-react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { LicenseApplication } from '@ema-platform/api';
|
import type { LicenseApplication } from '@ema-platform/api';
|
||||||
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
export function applicationActionsColumn(
|
export function applicationActionsColumn(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
deps: {
|
deps: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
bypassEnabled: boolean;
|
bypassEnabled: boolean;
|
||||||
bypassing: boolean;
|
bypassing: boolean;
|
||||||
isPaying: boolean;
|
isPaying: boolean;
|
||||||
@@ -39,7 +42,11 @@ export function applicationActionsColumn(
|
|||||||
used to be "View", which opened the application wizard — so
|
used to be "View", which opened the application wizard — so
|
||||||
the one thing the applicant came back for was the one thing
|
the one thing the applicant came back for was the one thing
|
||||||
the button did not do. */}
|
the button did not do. */}
|
||||||
{app.status === 'CERTIFICATE_ISSUED' && (
|
{app.status === 'CERTIFICATE_ISSUED' &&
|
||||||
|
deps.can([
|
||||||
|
PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES,
|
||||||
|
PORTAL_PERMISSIONS.VIEW_COMPANY_LICENSES,
|
||||||
|
]) && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconDownload size={14} />}
|
leftSection={<IconDownload size={14} />}
|
||||||
@@ -48,6 +55,10 @@ export function applicationActionsColumn(
|
|||||||
{t('applications.actions.certificate')}
|
{t('applications.actions.certificate')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* In PAYMENT_PENDING this button initiates payment, so it needs
|
||||||
|
that grant; every other status it merely opens the wizard. */}
|
||||||
|
{(app.status !== 'PAYMENT_PENDING' ||
|
||||||
|
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
|
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
|
||||||
@@ -80,6 +91,7 @@ export function applicationActionsColumn(
|
|||||||
? t('applications.actions.certificate')
|
? t('applications.actions.certificate')
|
||||||
: t('applications.actions.view')}
|
: t('applications.actions.view')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ import {
|
|||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
LICENSE_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
usePermissions,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
import { applicationColumns } from './columns';
|
import { applicationColumns } from './columns';
|
||||||
import { applicationActionsColumn } from './actions';
|
import { applicationActionsColumn } from './actions';
|
||||||
import classes from '../MyApplicationsPage.module.css';
|
import classes from '../MyApplicationsPage.module.css';
|
||||||
@@ -96,6 +101,7 @@ export function MyApplicationsPage() {
|
|||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>(() =>
|
const [tab, setTab] = useState<Tab>(() =>
|
||||||
typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications',
|
typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications',
|
||||||
@@ -237,6 +243,7 @@ export function MyApplicationsPage() {
|
|||||||
const columns = [
|
const columns = [
|
||||||
...applicationColumns(t, { language: i18n.language, showDate, statusLabel }),
|
...applicationColumns(t, { language: i18n.language, showDate, statusLabel }),
|
||||||
applicationActionsColumn(t, {
|
applicationActionsColumn(t, {
|
||||||
|
can,
|
||||||
bypassEnabled: capabilities?.bypassEnabled ?? false,
|
bypassEnabled: capabilities?.bypassEnabled ?? false,
|
||||||
bypassing,
|
bypassing,
|
||||||
isPaying,
|
isPaying,
|
||||||
@@ -258,9 +265,11 @@ export function MyApplicationsPage() {
|
|||||||
{t('applications.subtitle')}
|
{t('applications.subtitle')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
|
||||||
{t('applications.newApplication')}
|
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
|
||||||
</Button>
|
{t('applications.newApplication')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { ActionIcon, Group, Tooltip } from '@mantine/core';
|
|||||||
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
|
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||||
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
export function seaServiceActionsColumn(handlers: {
|
export function seaServiceActionsColumn(handlers: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
onEvidence: (record: SeaServiceRecord) => void;
|
onEvidence: (record: SeaServiceRecord) => void;
|
||||||
onEdit: (record: SeaServiceRecord) => void;
|
onEdit: (record: SeaServiceRecord) => void;
|
||||||
onDelete: (record: SeaServiceRecord) => void;
|
onDelete: (record: SeaServiceRecord) => void;
|
||||||
@@ -25,25 +28,29 @@ export function seaServiceActionsColumn(handlers: {
|
|||||||
<IconPaperclip size={16} />
|
<IconPaperclip size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
|
{handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
|
||||||
<ActionIcon
|
<>
|
||||||
variant="subtle"
|
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
|
||||||
disabled={locked}
|
<ActionIcon
|
||||||
onClick={() => handlers.onEdit(record)}
|
variant="subtle"
|
||||||
>
|
disabled={locked}
|
||||||
<IconEdit size={16} />
|
onClick={() => handlers.onEdit(record)}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
<IconEdit size={16} />
|
||||||
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
|
</ActionIcon>
|
||||||
<ActionIcon
|
</Tooltip>
|
||||||
variant="subtle"
|
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
|
||||||
color="red"
|
<ActionIcon
|
||||||
disabled={locked}
|
variant="subtle"
|
||||||
onClick={() => handlers.onDelete(record)}
|
color="red"
|
||||||
>
|
disabled={locked}
|
||||||
<IconTrash size={16} />
|
onClick={() => handlers.onDelete(record)}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -51,6 +58,8 @@ export function seaServiceActionsColumn(handlers: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function medicalActionsColumn(handlers: {
|
export function medicalActionsColumn(handlers: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
onEvidence: (certificate: MedicalCertificate) => void;
|
onEvidence: (certificate: MedicalCertificate) => void;
|
||||||
onEdit: (certificate: MedicalCertificate) => void;
|
onEdit: (certificate: MedicalCertificate) => void;
|
||||||
onDelete: (certificate: MedicalCertificate) => void;
|
onDelete: (certificate: MedicalCertificate) => void;
|
||||||
@@ -72,25 +81,29 @@ export function medicalActionsColumn(handlers: {
|
|||||||
<IconPaperclip size={16} />
|
<IconPaperclip size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
|
{handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
|
||||||
<ActionIcon
|
<>
|
||||||
variant="subtle"
|
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
|
||||||
disabled={locked}
|
<ActionIcon
|
||||||
onClick={() => handlers.onEdit(certificate)}
|
variant="subtle"
|
||||||
>
|
disabled={locked}
|
||||||
<IconEdit size={16} />
|
onClick={() => handlers.onEdit(certificate)}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
<IconEdit size={16} />
|
||||||
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
|
</ActionIcon>
|
||||||
<ActionIcon
|
</Tooltip>
|
||||||
variant="subtle"
|
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
|
||||||
color="red"
|
<ActionIcon
|
||||||
disabled={locked}
|
variant="subtle"
|
||||||
onClick={() => handlers.onDelete(certificate)}
|
color="red"
|
||||||
>
|
disabled={locked}
|
||||||
<IconTrash size={16} />
|
onClick={() => handlers.onDelete(certificate)}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ import {
|
|||||||
useUpdateSeaServiceRecordMutation,
|
useUpdateSeaServiceRecordMutation,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
usePermissions,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
|
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
|
||||||
import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
|
import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
|
||||||
|
|
||||||
@@ -112,18 +117,20 @@ function EvidenceModal({
|
|||||||
</Group>
|
</Group>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<FileButton onChange={upload} accept="image/*,application/pdf">
|
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
|
||||||
{(props) => (
|
<FileButton onChange={upload} accept="image/*,application/pdf">
|
||||||
<Button
|
{(props) => (
|
||||||
{...props}
|
<Button
|
||||||
variant="light"
|
{...props}
|
||||||
loading={uploading}
|
variant="light"
|
||||||
leftSection={<IconFileUpload size={16} />}
|
loading={uploading}
|
||||||
>
|
leftSection={<IconFileUpload size={16} />}
|
||||||
Upload evidence
|
>
|
||||||
</Button>
|
Upload evidence
|
||||||
)}
|
</Button>
|
||||||
</FileButton>
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</RequirePermission>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -144,6 +151,7 @@ const EMPTY_SEA_SERVICE = {
|
|||||||
|
|
||||||
function SeaServiceTab() {
|
function SeaServiceTab() {
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
|
const { can } = usePermissions();
|
||||||
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
|
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
|
||||||
const { data: seaTime } = useGetMySeaTimeQuery();
|
const { data: seaTime } = useGetMySeaTimeQuery();
|
||||||
const [createRecord, { isLoading: creating }] =
|
const [createRecord, { isLoading: creating }] =
|
||||||
@@ -231,6 +239,7 @@ function SeaServiceTab() {
|
|||||||
const columns = [
|
const columns = [
|
||||||
...seaServiceColumns(showDate),
|
...seaServiceColumns(showDate),
|
||||||
seaServiceActionsColumn({
|
seaServiceActionsColumn({
|
||||||
|
can,
|
||||||
onEvidence: (record) => setEvidenceFor(record.id),
|
onEvidence: (record) => setEvidenceFor(record.id),
|
||||||
onEdit: openEdit,
|
onEdit: openEdit,
|
||||||
onDelete: remove,
|
onDelete: remove,
|
||||||
@@ -251,9 +260,11 @@ function SeaServiceTab() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
<RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
|
||||||
Add sea service
|
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||||
</Button>
|
Add sea service
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
{(records ?? []).length === 0 ? (
|
{(records ?? []).length === 0 ? (
|
||||||
<Paper withBorder p="xl" radius="md">
|
<Paper withBorder p="xl" radius="md">
|
||||||
@@ -387,6 +398,7 @@ const EMPTY_MEDICAL = {
|
|||||||
|
|
||||||
function MedicalTab() {
|
function MedicalTab() {
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
|
const { can } = usePermissions();
|
||||||
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
|
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
|
||||||
const [createCertificate, { isLoading: creating }] =
|
const [createCertificate, { isLoading: creating }] =
|
||||||
useCreateMedicalCertificateMutation();
|
useCreateMedicalCertificateMutation();
|
||||||
@@ -466,6 +478,7 @@ function MedicalTab() {
|
|||||||
const columns = [
|
const columns = [
|
||||||
...medicalColumns(showDate),
|
...medicalColumns(showDate),
|
||||||
medicalActionsColumn({
|
medicalActionsColumn({
|
||||||
|
can,
|
||||||
onEvidence: (certificate) => setEvidenceFor(certificate.id),
|
onEvidence: (certificate) => setEvidenceFor(certificate.id),
|
||||||
onEdit: openEdit,
|
onEdit: openEdit,
|
||||||
onDelete: remove,
|
onDelete: remove,
|
||||||
@@ -479,9 +492,11 @@ function MedicalTab() {
|
|||||||
STCW medical fitness certificates. An expired certificate blocks new
|
STCW medical fitness certificates. An expired certificate blocks new
|
||||||
applications that require one.
|
applications that require one.
|
||||||
</Text>
|
</Text>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
|
||||||
Add certificate
|
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||||
</Button>
|
Add certificate
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
{(certificates ?? []).length === 0 ? (
|
{(certificates ?? []).length === 0 ? (
|
||||||
<Paper withBorder p="xl" radius="md">
|
<Paper withBorder p="xl" radius="md">
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import {
|
|||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { useCurrentProfile } from '@ema-platform/auth';
|
import {
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
useCurrentProfile,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
|
|
||||||
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
||||||
|
|
||||||
@@ -244,13 +248,18 @@ export function SeafarerRegistrationPage() {
|
|||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
<Group mt="md">
|
<Group mt="md">
|
||||||
<Button
|
<RequirePermission
|
||||||
size="md"
|
anyOf={[PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]}
|
||||||
rightSection={<IconArrowRight size={18} />}
|
hideOnly
|
||||||
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
|
|
||||||
>
|
>
|
||||||
Start registration
|
<Button
|
||||||
</Button>
|
size="md"
|
||||||
|
rightSection={<IconArrowRight size={18} />}
|
||||||
|
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
|
||||||
|
>
|
||||||
|
Start registration
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { IssuedLicense, Vessel } from '@ema-platform/api';
|
import type { IssuedLicense, Vessel } from '@ema-platform/api';
|
||||||
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
INLAND_WATERWAY: 'Inland Waterway',
|
INLAND_WATERWAY: 'Inland Waterway',
|
||||||
@@ -19,6 +20,8 @@ const VESSEL_STATUS_COLORS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function vesselColumns(handlers: {
|
export function vesselColumns(handlers: {
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
licenseById: Map<string, IssuedLicense>;
|
licenseById: Map<string, IssuedLicense>;
|
||||||
onDownloadCertificate: (vessel: Vessel) => void;
|
onDownloadCertificate: (vessel: Vessel) => void;
|
||||||
onRenew: (vessel: Vessel) => void;
|
onRenew: (vessel: Vessel) => void;
|
||||||
@@ -102,39 +105,45 @@ export function vesselColumns(handlers: {
|
|||||||
handlers.licenseById.get(vessel.licenseId)?.renewable ?? false;
|
handlers.licenseById.get(vessel.licenseId)?.renewable ?? false;
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
<Tooltip label="Download certificate">
|
{handlers.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) && (
|
||||||
<Button
|
<Tooltip label="Download certificate">
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
leftSection={<IconCertificate size={14} />}
|
|
||||||
onClick={() => handlers.onDownloadCertificate(vessel)}
|
|
||||||
>
|
|
||||||
Certificate
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
{renewable && vessel.status === 'REGISTERED' && (
|
|
||||||
<Tooltip label="Renew the registration">
|
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="subtle"
|
||||||
leftSection={<IconRefresh size={14} />}
|
leftSection={<IconCertificate size={14} />}
|
||||||
onClick={() => handlers.onRenew(vessel)}
|
onClick={() => handlers.onDownloadCertificate(vessel)}
|
||||||
>
|
>
|
||||||
Renew
|
Certificate
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{renewable &&
|
||||||
|
vessel.status === 'REGISTERED' &&
|
||||||
|
handlers.can([PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]) && (
|
||||||
|
<Tooltip label="Renew the registration">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={() => handlers.onRenew(vessel)}
|
||||||
|
>
|
||||||
|
Renew
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{handlers.can([PORTAL_PERMISSIONS.REPORT_VESSEL_INCIDENT]) && (
|
||||||
|
<Tooltip label="Report accident / incident">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconAlertTriangle size={14} />}
|
||||||
|
onClick={() => handlers.onReportIncident(vessel)}
|
||||||
|
>
|
||||||
|
Incident
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip label="Report accident / incident">
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="orange"
|
|
||||||
leftSection={<IconAlertTriangle size={14} />}
|
|
||||||
onClick={() => handlers.onReportIncident(vessel)}
|
|
||||||
>
|
|
||||||
Incident
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ import {
|
|||||||
useGetMyVesselsQuery,
|
useGetMyVesselsQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import type { Vessel } from '@ema-platform/api';
|
import type { Vessel } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
usePermissions,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
import { vesselColumns } from './columns';
|
import { vesselColumns } from './columns';
|
||||||
|
|
||||||
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
|
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
|
||||||
@@ -135,6 +140,7 @@ export function VesselRegistrationPage() {
|
|||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const [incidentFor, setIncidentFor] = useState<Vessel | null>(null);
|
const [incidentFor, setIncidentFor] = useState<Vessel | null>(null);
|
||||||
const table = useServerTable();
|
const table = useServerTable();
|
||||||
|
const { can } = usePermissions();
|
||||||
const pagedVessels = table.paginate(vessels ?? []);
|
const pagedVessels = table.paginate(vessels ?? []);
|
||||||
|
|
||||||
const inFlight = (applications?.items ?? []).filter(
|
const inFlight = (applications?.items ?? []).filter(
|
||||||
@@ -186,12 +192,17 @@ export function VesselRegistrationPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Vessel Registration</Title>
|
<Title order={2}>Vessel Registration</Title>
|
||||||
<Button
|
<RequirePermission
|
||||||
leftSection={<IconPlus size={16} />}
|
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
|
||||||
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
|
hideOnly
|
||||||
>
|
>
|
||||||
Register a vessel
|
<Button
|
||||||
</Button>
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
|
||||||
|
>
|
||||||
|
Register a vessel
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- in-flight */}
|
{/* ----------------------------------------------------- in-flight */}
|
||||||
@@ -262,20 +273,26 @@ export function VesselRegistrationPage() {
|
|||||||
issues the registration certificate and enters the vessel in
|
issues the registration certificate and enters the vessel in
|
||||||
the national register.
|
the national register.
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<RequirePermission
|
||||||
mt="xs"
|
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
|
||||||
onClick={() =>
|
hideOnly
|
||||||
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Start registration
|
<Button
|
||||||
</Button>
|
mt="xs"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Start registration
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
tableName="My vessels"
|
tableName="My vessels"
|
||||||
columns={vesselColumns({
|
columns={vesselColumns({
|
||||||
|
can,
|
||||||
licenseById,
|
licenseById,
|
||||||
onDownloadCertificate: downloadCertificate,
|
onDownloadCertificate: downloadCertificate,
|
||||||
onRenew: renew,
|
onRenew: renew,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { IconFileText } from '@tabler/icons-react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||||
|
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||||
|
|
||||||
export function waiverLetterColumns(deps: {
|
export function waiverLetterColumns(deps: {
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
|
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
localized: (value: Bilingual | undefined) => string;
|
localized: (value: Bilingual | undefined) => string;
|
||||||
showDate: (value: string | null | undefined) => string;
|
showDate: (value: string | null | undefined) => string;
|
||||||
onDownload: (license: IssuedLicense) => void;
|
onDownload: (license: IssuedLicense) => void;
|
||||||
@@ -44,16 +47,17 @@ export function waiverLetterColumns(deps: {
|
|||||||
header: '',
|
header: '',
|
||||||
label: t('waiver.columns.actions', 'Actions'),
|
label: t('waiver.columns.actions', 'Actions'),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<Button
|
deps.can([PORTAL_PERMISSIONS.VIEW_WAIVER_LETTER]) ? (
|
||||||
size="compact-xs"
|
<Button
|
||||||
variant="light"
|
size="compact-xs"
|
||||||
leftSection={<IconFileText size={13} />}
|
variant="light"
|
||||||
onClick={() => deps.onDownload(row.original)}
|
leftSection={<IconFileText size={13} />}
|
||||||
>
|
onClick={() => deps.onDownload(row.original)}
|
||||||
{t('waiver.letter', 'Letter')}
|
>
|
||||||
</Button>
|
{t('waiver.letter', 'Letter')}
|
||||||
),
|
</Button>
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import {
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
RequirePermission,
|
||||||
|
usePermissions,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
import { waiverLetterColumns } from './columns';
|
import { waiverLetterColumns } from './columns';
|
||||||
|
|
||||||
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
||||||
@@ -46,6 +51,7 @@ export function WaiverPage() {
|
|||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const lettersTable = useServerTable();
|
const lettersTable = useServerTable();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
||||||
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||||
@@ -97,12 +103,14 @@ export function WaiverPage() {
|
|||||||
'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.',
|
'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_WAIVER]} hideOnly>
|
||||||
rightSection={<IconArrowRight size={16} />}
|
<Button
|
||||||
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
|
rightSection={<IconArrowRight size={16} />}
|
||||||
>
|
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
|
||||||
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
|
>
|
||||||
</Button>
|
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card withBorder radius="md" p="lg">
|
<Card withBorder radius="md" p="lg">
|
||||||
@@ -113,13 +121,15 @@ export function WaiverPage() {
|
|||||||
'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.',
|
'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_WAIVER]} hideOnly>
|
||||||
variant="light"
|
<Button
|
||||||
rightSection={<IconArrowRight size={16} />}
|
variant="light"
|
||||||
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
|
rightSection={<IconArrowRight size={16} />}
|
||||||
>
|
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
|
||||||
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
|
>
|
||||||
</Button>
|
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
@@ -172,6 +182,7 @@ export function WaiverPage() {
|
|||||||
tableName={t('waiver.issuedLetters', 'Issued waiver letters')}
|
tableName={t('waiver.issuedLetters', 'Issued waiver letters')}
|
||||||
columns={waiverLetterColumns({
|
columns={waiverLetterColumns({
|
||||||
t,
|
t,
|
||||||
|
can,
|
||||||
localized,
|
localized,
|
||||||
showDate,
|
showDate,
|
||||||
onDownload: (license) => download(license.id),
|
onDownload: (license) => download(license.id),
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { notify, AppHeader, AppSidebar } from "@ema-platform/ui";
|
import { notify, AppHeader, AppSidebar, filterByPermissions } from "@ema-platform/ui";
|
||||||
import type { NavItem } from "@ema-platform/ui";
|
import type { NavItem } from "@ema-platform/ui";
|
||||||
import { BrandMark, logout } from "@ema-platform/auth";
|
import {
|
||||||
|
BrandMark,
|
||||||
|
logout,
|
||||||
|
usePermissions,
|
||||||
|
LICENSE_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
} from "@ema-platform/auth";
|
||||||
import { baseApi, useGetUnseenNotificationsQuery } from "@ema-platform/api";
|
import { baseApi, useGetUnseenNotificationsQuery } from "@ema-platform/api";
|
||||||
import { SUPPORTED_LANGUAGES } from "../i18n/config";
|
import { SUPPORTED_LANGUAGES } from "../i18n/config";
|
||||||
import { useAppSelector } from "../store/hooks";
|
import { useAppSelector } from "../store/hooks";
|
||||||
@@ -30,6 +36,11 @@ const BADGE_POLL_MS = 60_000;
|
|||||||
|
|
||||||
type PortalNavItem = NavItem & { i18nKey: string };
|
type PortalNavItem = NavItem & { i18nKey: string };
|
||||||
|
|
||||||
|
// Grant keys the server computes per portal account type (seafarer, vessel
|
||||||
|
// owner, logistics rep). Items with no `permissions` are universal.
|
||||||
|
const P = PORTAL_PERMISSIONS;
|
||||||
|
const L = LICENSE_PERMISSIONS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grouped for scannability, with `soon` marking screens that have no backend
|
* Grouped for scannability, with `soon` marking screens that have no backend
|
||||||
* behind them yet. There are no per-licence-type entries: applicants pick a
|
* behind them yet. There are no per-licence-type entries: applicants pick a
|
||||||
@@ -56,26 +67,26 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
label: "nav.groupLicensing",
|
label: "nav.groupLicensing",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck },
|
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck, permissions: [L.VIEW_OWN_APPLICATIONS] },
|
||||||
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
|
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "nav.groupSeafarer",
|
label: "nav.groupSeafarer",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList },
|
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] },
|
||||||
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList },
|
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
|
||||||
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true },
|
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
|
||||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
|
||||||
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList },
|
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] },
|
||||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "nav.groupVessels",
|
label: "nav.groupVessels",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
|
||||||
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange },
|
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,20 +145,22 @@ export function PortalLayout() {
|
|||||||
pollingInterval: BADGE_POLL_MS,
|
pollingInterval: BADGE_POLL_MS,
|
||||||
refetchOnMountOrArgChange: false,
|
refetchOnMountOrArgChange: false,
|
||||||
});
|
});
|
||||||
|
const { permissions: granted, known } = usePermissions();
|
||||||
|
|
||||||
const sections = useMemo(
|
const sections = useMemo(() => {
|
||||||
() =>
|
const translated = NAV_SECTIONS.map((section) => ({
|
||||||
NAV_SECTIONS.map((section) => ({
|
label: section.label,
|
||||||
label: section.label,
|
items: section.items.map(({ i18nKey, ...rest }) => ({
|
||||||
items: section.items.map(({ i18nKey, ...rest }) => ({
|
...rest,
|
||||||
...rest,
|
label: t(i18nKey),
|
||||||
label: t(i18nKey),
|
badge:
|
||||||
badge:
|
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
|
||||||
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
|
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
[t, unseen?.count],
|
}));
|
||||||
);
|
// Unfiltered until the grant list has loaded — same fail-open rule as
|
||||||
|
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||||
|
return known ? filterByPermissions(translated, granted) : translated;
|
||||||
|
}, [t, unseen?.count, granted, known]);
|
||||||
|
|
||||||
// Breadcrumb trail
|
// Breadcrumb trail
|
||||||
const segments = location.pathname.split("/").filter(Boolean);
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ import {
|
|||||||
OTPVerificationPage,
|
OTPVerificationPage,
|
||||||
ForgotPasswordPage,
|
ForgotPasswordPage,
|
||||||
SetPasswordPage,
|
SetPasswordPage,
|
||||||
|
RequirePermission,
|
||||||
|
LICENSE_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
} from "@ema-platform/auth";
|
} from "@ema-platform/auth";
|
||||||
|
|
||||||
|
// Same grant keys as the matching PortalLayout nav items; the API enforces
|
||||||
|
// the real rule, this just keeps direct URLs consistent with the hidden nav.
|
||||||
|
const P = PORTAL_PERMISSIONS;
|
||||||
|
const L = LICENSE_PERMISSIONS;
|
||||||
|
|
||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
||||||
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
||||||
@@ -107,7 +115,14 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
||||||
|
|
||||||
// Config-driven licensing: one set of pages serves every licence type.
|
// Config-driven licensing: one set of pages serves every licence type.
|
||||||
{ path: "/licensing/applications", element: <MyApplicationsPage /> },
|
{
|
||||||
|
path: "/licensing/applications",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
|
||||||
|
<MyApplicationsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
// Telebirr returns the applicant to these.
|
// Telebirr returns the applicant to these.
|
||||||
{ path: "/payments/check", element: <PaymentCheckPage /> },
|
{ path: "/payments/check", element: <PaymentCheckPage /> },
|
||||||
@@ -117,19 +132,29 @@ export const router = createBrowserRouter([
|
|||||||
path: "/licensing/:typeCode/apply",
|
path: "/licensing/:typeCode/apply",
|
||||||
element: (
|
element: (
|
||||||
<RequireSeafarerProfile>
|
<RequireSeafarerProfile>
|
||||||
<LicenseApplicationPage />
|
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
|
||||||
|
<LicenseApplicationPage />
|
||||||
|
</RequirePermission>
|
||||||
</RequireSeafarerProfile>
|
</RequireSeafarerProfile>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/licensing/:typeCode/applications/:applicationId",
|
path: "/licensing/:typeCode/applications/:applicationId",
|
||||||
element: <LicenseApplicationPage />,
|
element: (
|
||||||
|
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
|
||||||
|
<LicenseApplicationPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
// Notification / email deep links arrive as /applications/<id>; resolve the
|
// Notification / email deep links arrive as /applications/<id>; resolve the
|
||||||
// licence type and forward to the canonical route.
|
// licence type and forward to the canonical route.
|
||||||
{
|
{
|
||||||
path: "/applications/:applicationId",
|
path: "/applications/:applicationId",
|
||||||
element: <ApplicationRedirectPage />,
|
element: (
|
||||||
|
<RequirePermission anyOf={[L.VIEW_OWN_APPLICATIONS]}>
|
||||||
|
<ApplicationRedirectPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Seafarer
|
// Seafarer
|
||||||
@@ -137,12 +162,28 @@ export const router = createBrowserRouter([
|
|||||||
path: "/seafarer-registration",
|
path: "/seafarer-registration",
|
||||||
element: (
|
element: (
|
||||||
<RequireSeafarerProfile>
|
<RequireSeafarerProfile>
|
||||||
<SeafarerRegistrationPage />
|
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
|
||||||
|
<SeafarerRegistrationPage />
|
||||||
|
</RequirePermission>
|
||||||
</RequireSeafarerProfile>
|
</RequireSeafarerProfile>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
|
{
|
||||||
{ path: "/exams", element: <ExamsPage /> },
|
path: "/seafarer/records",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
|
||||||
|
<MySeaRecordsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/exams",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_EXAM, P.APPLY_EXAM]}>
|
||||||
|
<ExamsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
// The public-facing registry was a hardcoded mock and does not belong in
|
// The public-facing registry was a hardcoded mock and does not belong in
|
||||||
// the applicant portal; officers browse seafarers in the backoffice.
|
// the applicant portal; officers browse seafarers in the backoffice.
|
||||||
{
|
{
|
||||||
@@ -156,17 +197,59 @@ export const router = createBrowserRouter([
|
|||||||
|
|
||||||
// Phase 1
|
// Phase 1
|
||||||
{ path: "/documents", element: <DocumentVaultPage /> },
|
{ path: "/documents", element: <DocumentVaultPage /> },
|
||||||
{ path: "/seaman-book", element: <SeamanBookPage /> },
|
{
|
||||||
{ path: "/seaman-book/apply", element: <SeamanBookApplicationPage /> },
|
path: "/seaman-book",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
|
||||||
|
<SeamanBookPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/seaman-book/apply",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
|
||||||
|
<SeamanBookApplicationPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: "/notifications", element: <NotificationsPage /> },
|
{ path: "/notifications", element: <NotificationsPage /> },
|
||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
{ path: "/certificates", element: <CertificatesPage /> },
|
{
|
||||||
{ path: "/certificates/apply", element: <CoCApplicationPage /> },
|
path: "/certificates",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||||
|
<CertificatesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/certificates/apply",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||||
|
<CoCApplicationPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{ path: "/endorsements", element: <EndorsementPage /> },
|
{
|
||||||
{ path: "/vessel-registration", element: <VesselRegistrationPage /> },
|
path: "/endorsements",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||||
|
<EndorsementPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/vessel-registration",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
|
||||||
|
<VesselRegistrationPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
// The registration wizard is the config-driven licensing flow; the old
|
// The registration wizard is the config-driven licensing flow; the old
|
||||||
// standalone wizard posted to endpoints that never existed.
|
// standalone wizard posted to endpoints that never existed.
|
||||||
{
|
{
|
||||||
@@ -177,7 +260,14 @@ export const router = createBrowserRouter([
|
|||||||
path: "/vessel-registration-dashboard",
|
path: "/vessel-registration-dashboard",
|
||||||
element: <Navigate to="/vessel-registration" replace />,
|
element: <Navigate to="/vessel-registration" replace />,
|
||||||
},
|
},
|
||||||
{ path: "/vessel-ownership-transfer", element: <VesselTransferPage /> },
|
{
|
||||||
|
path: "/vessel-ownership-transfer",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
|
||||||
|
<VesselTransferPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
// The nav item used to nest this under /vessel-registration, which made
|
// The nav item used to nest this under /vessel-registration, which made
|
||||||
// the sidebar's prefix-match (nav-utils.ts isItemActive) light up both
|
// the sidebar's prefix-match (nav-utils.ts isItemActive) light up both
|
||||||
// items at once. Kept as a redirect for old bookmarks/links.
|
// items at once. Kept as a redirect for old bookmarks/links.
|
||||||
@@ -260,15 +350,33 @@ export const router = createBrowserRouter([
|
|||||||
element: <Navigate to="/licensing/applications" replace />,
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
},
|
},
|
||||||
// Waiver has no backend yet, so it says so rather than pretending.
|
// Waiver has no backend yet, so it says so rather than pretending.
|
||||||
{ path: "/waiver", element: <WaiverPage /> },
|
{
|
||||||
|
path: "/waiver",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER]}>
|
||||||
|
<WaiverPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: "/waiver/apply", element: <Navigate to="/waiver" replace /> },
|
{ path: "/waiver/apply", element: <Navigate to="/waiver" replace /> },
|
||||||
|
|
||||||
// Vessel Registration
|
// Vessel Registration
|
||||||
{ path: "/vessel-registrations", element: <VesselRegistrationPage /> },
|
{
|
||||||
|
path: "/vessel-registrations",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
|
||||||
|
<VesselRegistrationPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
// { path: '/vessel-registrations/apply', element: <VesselRegistrationPage /> },
|
// { path: '/vessel-registrations/apply', element: <VesselRegistrationPage /> },
|
||||||
{
|
{
|
||||||
path: "/vessel-registrations/:id",
|
path: "/vessel-registrations/:id",
|
||||||
element: <VesselRegistrationStatusPage />,
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
|
||||||
|
<VesselRegistrationStatusPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
// General
|
// General
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export {
|
|||||||
} from "./lib/store/signup.slice";
|
} from "./lib/store/signup.slice";
|
||||||
export { usePermissions } from "./lib/hooks/usePermissions";
|
export { usePermissions } from "./lib/hooks/usePermissions";
|
||||||
export type { PermissionSet } from "./lib/hooks/usePermissions";
|
export type { PermissionSet } from "./lib/hooks/usePermissions";
|
||||||
|
export { RequirePermission } from "./lib/components/RequirePermission";
|
||||||
|
export {
|
||||||
|
LICENSE_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
} from "./lib/permissions.constants";
|
||||||
export {
|
export {
|
||||||
useCurrentProfile,
|
useCurrentProfile,
|
||||||
useGetMyProfileQuery,
|
useGetMyProfileQuery,
|
||||||
|
|||||||
39
libs/auth/src/lib/components/RequirePermission.tsx
Normal file
39
libs/auth/src/lib/components/RequirePermission.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
|
interface RequirePermissionProps {
|
||||||
|
/** Passes when the user holds ANY of these keys. */
|
||||||
|
anyOf: string[];
|
||||||
|
/** Route mode: where to send a denied user. Defaults to "/". */
|
||||||
|
redirectTo?: string;
|
||||||
|
/**
|
||||||
|
* Element mode: render nothing instead of redirecting. Use for buttons and
|
||||||
|
* page fragments; leave false for route elements.
|
||||||
|
*/
|
||||||
|
hideOnly?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission gate for routes, sections and buttons.
|
||||||
|
*
|
||||||
|
* Route usage: <Route element={<RequirePermission anyOf={[KEY]}><Page /></RequirePermission>} />
|
||||||
|
* Element usage: <RequirePermission anyOf={[KEY]} hideOnly><Button /></RequirePermission>
|
||||||
|
*
|
||||||
|
* While the permission list is still loading (`known === false`) it renders
|
||||||
|
* children — the API enforces the real rule, and a flash of a forbidden
|
||||||
|
* button costs at most a 403, whereas hiding everything flashes an empty app
|
||||||
|
* at every legitimate user on every load.
|
||||||
|
*/
|
||||||
|
export function RequirePermission({
|
||||||
|
anyOf,
|
||||||
|
redirectTo = '/',
|
||||||
|
hideOnly = false,
|
||||||
|
children,
|
||||||
|
}: RequirePermissionProps) {
|
||||||
|
const { can, known } = usePermissions();
|
||||||
|
|
||||||
|
if (!known || can(anyOf)) return <>{children}</>;
|
||||||
|
return hideOnly ? null : <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
@@ -85,6 +85,12 @@ export interface ProfileMeResponse {
|
|||||||
profile: CurrentProfile;
|
profile: CurrentProfile;
|
||||||
completeness: number;
|
completeness: number;
|
||||||
missing: ProfileField[];
|
missing: ProfileField[];
|
||||||
|
/**
|
||||||
|
* Every permission key the caller effectively holds (role + portal
|
||||||
|
* account-type + position grants), computed server-side. Feeds
|
||||||
|
* `usePermissions()`.
|
||||||
|
*/
|
||||||
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileApi = baseApi
|
const profileApi = baseApi
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useGetMyProfileQuery } from './useCurrentProfile';
|
||||||
import { authStorage } from '../utils/auth-storage';
|
|
||||||
|
|
||||||
interface TokenClaims {
|
|
||||||
permissions?: string[];
|
|
||||||
roles?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the claims out of the access token without verifying it.
|
|
||||||
*
|
|
||||||
* Verification is the API's job — this is only used to decide what to *show*.
|
|
||||||
* Every guarded route is enforced server-side by `PermissionGuard`, so the
|
|
||||||
* worst a wrong answer here can do is offer a menu item that then 403s.
|
|
||||||
*/
|
|
||||||
function decodeClaims(token: string | undefined): TokenClaims | null {
|
|
||||||
if (!token) return null;
|
|
||||||
const payload = token.split('.')[1];
|
|
||||||
if (!payload) return null;
|
|
||||||
try {
|
|
||||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
||||||
// The claim set is UTF-8; atob yields latin-1, so non-ASCII names would
|
|
||||||
// otherwise come back mangled.
|
|
||||||
const decoded = decodeURIComponent(
|
|
||||||
json
|
|
||||||
.split('')
|
|
||||||
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
||||||
.join(''),
|
|
||||||
);
|
|
||||||
return JSON.parse(decoded) as TokenClaims;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionSet {
|
export interface PermissionSet {
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
/** True if the user holds any one of `required`. Empty `required` = allowed. */
|
/** True if the user holds any one of `required`. Empty `required` = allowed. */
|
||||||
can: (required?: string[]) => boolean;
|
can: (required?: string[]) => boolean;
|
||||||
/**
|
/**
|
||||||
* Whether permissions could be read at all. When false, callers should show
|
* Whether the effective permission list has loaded. While false, `can()`
|
||||||
* everything rather than hide the whole application from someone whose token
|
* returns true so the shell does not flash an empty sidebar during the
|
||||||
* simply does not carry the claim.
|
* first fetch; callers gating destructive actions should also check
|
||||||
|
* `known` and disable rather than hide.
|
||||||
*/
|
*/
|
||||||
known: boolean;
|
known: boolean;
|
||||||
}
|
}
|
||||||
@@ -49,22 +17,22 @@ export interface PermissionSet {
|
|||||||
/**
|
/**
|
||||||
* What the signed-in user is allowed to do.
|
* What the signed-in user is allowed to do.
|
||||||
*
|
*
|
||||||
* Deliberately fails open: if the token carries no `permissions` claim we
|
* The list comes from `GET /profiles/me` (`permissions: string[]`), which the
|
||||||
* report `known: false` and `can()` returns true. Hiding navigation on a
|
* API computes per session: role grants, portal account-type grants and
|
||||||
* claim-shape mismatch would leave a legitimate officer staring at an empty
|
* position grants combined. The access token itself carries no permission
|
||||||
* sidebar with no way to tell why, whereas failing open costs at most a 403
|
* claims, so nothing is decoded client-side any more.
|
||||||
* on a link they should not have seen.
|
*
|
||||||
|
* Fails open only while loading (`known: false`); once the server has
|
||||||
|
* answered, `can()` is authoritative for show/hide. Enforcement remains
|
||||||
|
* server-side — the worst a wrong answer here can do is offer a control that
|
||||||
|
* then 403s.
|
||||||
*/
|
*/
|
||||||
export function usePermissions(): PermissionSet {
|
export function usePermissions(): PermissionSet {
|
||||||
// Re-read whenever the session changes rather than only on mount.
|
const { data, isSuccess } = useGetMyProfileQuery();
|
||||||
const token = useSelector(
|
|
||||||
(state: { auth?: { token?: string | null } }) => state.auth?.token,
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const claims = decodeClaims(token ?? authStorage.getToken());
|
const permissions = data?.permissions ?? [];
|
||||||
const permissions = claims?.permissions ?? [];
|
const known = isSuccess && Array.isArray(data?.permissions);
|
||||||
const known = Array.isArray(claims?.permissions);
|
|
||||||
const granted = new Set(permissions);
|
const granted = new Set(permissions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -76,5 +44,5 @@ export function usePermissions(): PermissionSet {
|
|||||||
return required.some((permission) => granted.has(permission));
|
return required.some((permission) => granted.has(permission));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [data?.permissions, isSuccess]);
|
||||||
}
|
}
|
||||||
|
|||||||
89
libs/auth/src/lib/permissions.constants.ts
Normal file
89
libs/auth/src/lib/permissions.constants.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Frontend mirror of the API's permission keys.
|
||||||
|
*
|
||||||
|
* Sources of truth in emaapi:
|
||||||
|
* - src/common/constants/licensing-permissions.constant.ts (backoffice duties)
|
||||||
|
* - src/common/constants/portal-permissions.constant.ts (Level 1 portal roles)
|
||||||
|
*
|
||||||
|
* Keys only — grants are computed server-side and arrive on
|
||||||
|
* `GET /profiles/me` as `permissions: string[]`. A typo here can only
|
||||||
|
* mis-hide UI; the API enforces the real thing.
|
||||||
|
*/
|
||||||
|
export const LICENSE_PERMISSIONS = {
|
||||||
|
CREATE_APPLICATION: "can:create:license-application",
|
||||||
|
VIEW_OWN_APPLICATIONS: "can:View:my-license-applications",
|
||||||
|
UPDATE_APPLICATION: "can:update:license-application",
|
||||||
|
SUBMIT_APPLICATION: "can:submit:license-application",
|
||||||
|
VIEW_APPLICATION_QUEUE: "can:View:license-application-queue",
|
||||||
|
VIEW_APPLICATIONS: "can:View:license-applications",
|
||||||
|
CLAIM_APPLICATION: "can:claim:license-application",
|
||||||
|
REVIEW_APPLICATION: "can:review:license-application",
|
||||||
|
EVALUATE_APPLICATION: "can:evaluate:license-application",
|
||||||
|
REQUEST_ADJUSTMENT: "can:request-adjustment:license-application",
|
||||||
|
APPROVE_APPLICATION: "can:approve:license-application",
|
||||||
|
REJECT_APPLICATION: "can:reject:license-application",
|
||||||
|
ASSIGN_APPLICATION: "can:assign:license-application",
|
||||||
|
HOLD_APPLICATION: "can:hold:license-application",
|
||||||
|
ESCALATE_APPLICATION: "can:escalate:license-application",
|
||||||
|
REVIEW_DOCUMENTS: "can:review:license-application-documents",
|
||||||
|
CREATE_INSPECTION: "can:create:inspection",
|
||||||
|
UPDATE_INSPECTION: "can:update:inspection",
|
||||||
|
VIEW_INSPECTIONS: "can:View:inspections",
|
||||||
|
CONFIRM_PAYMENT: "can:confirm:license-payment",
|
||||||
|
VIEW_PAYMENTS: "can:View:license-payments",
|
||||||
|
CREATE_LICENSE_TYPE: "can:create:license-type",
|
||||||
|
VIEW_LICENSE_TYPES: "can:View:license-types",
|
||||||
|
UPDATE_LICENSE_TYPE: "can:update:license-type",
|
||||||
|
DELETE_LICENSE_TYPE: "can:delete:license-type",
|
||||||
|
CREATE_TEMPLATE: "can:create:license-template",
|
||||||
|
VIEW_TEMPLATES: "can:View:license-templates",
|
||||||
|
UPDATE_TEMPLATE: "can:update:license-template",
|
||||||
|
PUBLISH_TEMPLATE: "can:publish:license-template",
|
||||||
|
VIEW_LICENSES: "can:View:licenses",
|
||||||
|
SUSPEND_LICENSE: "can:suspend:license",
|
||||||
|
CANCEL_LICENSE: "can:cancel:license",
|
||||||
|
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
|
||||||
|
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
|
||||||
|
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
|
||||||
|
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
|
||||||
|
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
|
||||||
|
APPROVE_QUESTION: "can:approve:exam-question",
|
||||||
|
AUTHOR_QUESTION: "can:author:exam-question",
|
||||||
|
MANAGE_EXAMS: "can:manage:exams",
|
||||||
|
RECORD_EXAM_ATTENDANCE: "can:record:exam-attendance",
|
||||||
|
MANAGE_EXAM_INCIDENTS: "can:manage:exam-incidents",
|
||||||
|
RECORD_EXAM_RESULT: "can:record:exam-result",
|
||||||
|
MODERATE_EXAM_RESULT: "can:moderate:exam-result",
|
||||||
|
APPROVE_EXAM_RESULT: "can:approve:exam-result",
|
||||||
|
PUBLISH_EXAM_RESULT: "can:publish:exam-result",
|
||||||
|
DECIDE_EXAM_APPEAL: "can:decide:exam-appeal",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PORTAL_PERMISSIONS = {
|
||||||
|
VIEW_OWN_PROFILE: "can:View:own-profile",
|
||||||
|
EDIT_OWN_PROFILE: "can:edit:own-profile",
|
||||||
|
RESUBMIT_APPLICATION: "can:resubmit:license-application",
|
||||||
|
UPLOAD_DOCUMENTS: "can:upload:own-documents",
|
||||||
|
VIEW_OWN_DOCUMENTS: "can:View:own-documents",
|
||||||
|
REPLACE_DOCUMENTS: "can:replace:own-documents",
|
||||||
|
INITIATE_PAYMENT: "can:initiate:own-payment",
|
||||||
|
VIEW_OWN_PAYMENTS: "can:View:own-payments",
|
||||||
|
VIEW_NOTIFICATIONS: "can:View:own-notifications",
|
||||||
|
APPLY_SEAFARER_REGISTRATION: "can:apply:seafarer-registration",
|
||||||
|
ADD_SEA_SERVICE: "can:add:own-sea-service",
|
||||||
|
EDIT_SEA_SERVICE: "can:edit:own-sea-service",
|
||||||
|
VIEW_OWN_SEA_SERVICE: "can:View:own-sea-service",
|
||||||
|
UPLOAD_MEDICAL: "can:upload:own-medical-certificate",
|
||||||
|
VIEW_OWN_MEDICAL: "can:View:own-medical-certificate",
|
||||||
|
APPLY_SEAFARER_CERTIFICATE: "can:apply:seafarer-certificate",
|
||||||
|
APPLY_EXAM: "can:apply:exam",
|
||||||
|
VIEW_OWN_EXAM: "can:View:own-exam",
|
||||||
|
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
|
||||||
|
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
|
||||||
|
VIEW_OWN_VESSELS: "can:View:own-vessels",
|
||||||
|
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",
|
||||||
|
APPLY_LOGISTICS_LICENSE: "can:apply:logistics-license",
|
||||||
|
VIEW_COMPANY_LICENSES: "can:View:company-licenses",
|
||||||
|
APPLY_WAIVER: "can:apply:waiver",
|
||||||
|
VIEW_WAIVER_LETTER: "can:View:own-waiver-letter",
|
||||||
|
} as const;
|
||||||
Reference in New Issue
Block a user