mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 05:58:19 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
|
||||
|
||||
async function generateCertificate(profileId: string): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
SaveState,
|
||||
} from '../types/exam-attempt';
|
||||
|
||||
/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
|
||||
const MAY_SIT = ['PRESENT', 'LATE'];
|
||||
|
||||
const ESSAY_DEBOUNCE_MS = 1500;
|
||||
|
||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||
@@ -83,6 +86,19 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
setErrorMessage('You are not registered for this examination.');
|
||||
return;
|
||||
}
|
||||
// Attendance gates the sitting, and the exam list already hides "Take
|
||||
// exam" for these — this is the direct-link path. The server refuses
|
||||
// either way (ExamAttemptService.MAY_SIT); this only makes the refusal
|
||||
// legible instead of a raw error key on a Start button that never works.
|
||||
if (!MAY_SIT.includes(registration.attendanceStatus)) {
|
||||
setViewState('error');
|
||||
setErrorMessage(
|
||||
registration.attendanceStatus === 'REGISTERED'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: 'Your attendance record does not permit sitting this examination.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mineData) {
|
||||
seedFrom(mineData);
|
||||
return;
|
||||
@@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
}).unwrap();
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||
const key = extractErrorMessage(error, 'Could not start the exam.');
|
||||
notify.error(
|
||||
key === 'attendance_not_confirmed'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: key === 'candidate_not_present'
|
||||
? 'Your attendance record does not permit sitting this examination.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
@@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
/**
|
||||
* Attendance rulings that permit sitting the paper — an allow-list mirroring
|
||||
* the server's (ExamAttemptService.MAY_SIT). The deny-list this replaced named
|
||||
* only ABSENT/WITHDRAWN/DISQUALIFIED, so the default REGISTERED fell through
|
||||
* and "Take exam" appeared before any invigilator had confirmed the candidate
|
||||
* was there. LATE counts: a late arrival is present, just not on time.
|
||||
*/
|
||||
const MAY_SIT: AttendanceStatus[] = ['PRESENT', 'LATE'];
|
||||
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
@@ -115,9 +122,26 @@ export function registrationColumns(
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const eligible =
|
||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
// Say why the exam is shut rather than rendering an empty cell: the
|
||||
// candidate is waiting on an invigilator, and silence reads as a bug.
|
||||
if (row.original.attendanceStatus === 'REGISTERED') {
|
||||
return (
|
||||
<Tooltip label={t('exams.columns.awaitingAttendanceHint')} multiline w={240}>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('exams.columns.awaitingAttendance')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (!MAY_SIT.includes(row.original.attendanceStatus)) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t('exams.columns.notSitting')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (exam?.status !== 'ACTIVE') return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
@@ -59,6 +59,36 @@ export function useRenewLicense() {
|
||||
return { renewLicense, isRenewing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Damaged/Reissue reuses the same wizard, application kind REISSUE — the
|
||||
* "Damage Information" step and the Reissue document set only appear because
|
||||
* the created application carries that kind, exactly the way RENEWAL's own
|
||||
* fields do above.
|
||||
*/
|
||||
export function useReissueLicense() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [createApplication, { isLoading: isReissuing }] =
|
||||
useCreateApplicationMutation();
|
||||
|
||||
async function reissueLicense(license: IssuedLicense) {
|
||||
const typeKey = license.licenseType?.key;
|
||||
if (!typeKey) return;
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: typeKey,
|
||||
kind: 'REISSUE',
|
||||
previousLicenseId: license.id,
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('licensing.card.reissueFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return { reissueLicense, isReissuing };
|
||||
}
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
const ms = new Date(date).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
@@ -68,14 +98,18 @@ export function LicenseCard({
|
||||
license,
|
||||
isDownloading,
|
||||
isRenewing,
|
||||
isReissuing,
|
||||
onDownload,
|
||||
onRenew,
|
||||
onReissue,
|
||||
}: {
|
||||
license: IssuedLicense;
|
||||
isDownloading: boolean;
|
||||
isRenewing: boolean;
|
||||
isReissuing?: boolean;
|
||||
onDownload: () => void;
|
||||
onRenew: () => void;
|
||||
onReissue?: () => void;
|
||||
}) {
|
||||
// The API computes both in the authority's timezone; the local fallbacks are
|
||||
// only for a cached response from before those fields existed.
|
||||
@@ -87,6 +121,7 @@ export function LicenseCard({
|
||||
// badge.
|
||||
const current = license.status === 'ACTIVE' && !expired;
|
||||
const renewable = license.renewable ?? false;
|
||||
const reissuable = license.reissuable ?? false;
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
@@ -179,6 +214,25 @@ export function LicenseCard({
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{/* Damaged/Reissue has no window — a lost or damaged document can be
|
||||
replaced at any point in its validity, unlike Renewal above. */}
|
||||
{reissuable && onReissue && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="xs"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
loading={isReissuing}
|
||||
leftSection={<IconAlertTriangle size={14} />}
|
||||
onClick={onReissue}
|
||||
>
|
||||
{t('licensing.card.reportDamaged')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { IssuancePeriod } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Read-only view of the pickup appointment a team leader assigned (spec
|
||||
* §19-21 — the office decides who comes in when, the applicant doesn't pick
|
||||
* a slot). Shown once payment is confirmed and the licence type prints once
|
||||
* and hands the document over in person.
|
||||
*/
|
||||
export function PickupSchedulingPanel({
|
||||
scheduledDate,
|
||||
scheduledPeriod,
|
||||
}: {
|
||||
scheduledDate: string | null;
|
||||
scheduledPeriod: IssuancePeriod | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconCalendarEvent size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
{t('pickup.title')}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{scheduledDate ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
{t('pickup.scheduledFor', {
|
||||
date: scheduledDate,
|
||||
period:
|
||||
scheduledPeriod === 'AFTERNOON'
|
||||
? t('pickup.afternoon')
|
||||
: t('pickup.morning'),
|
||||
})}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('pickup.setByOffice')}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('pickup.awaitingSchedule')}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PickupSchedulingPanel;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
PORTAL_PERMISSIONS,
|
||||
RequirePermission,
|
||||
useCurrentProfile,
|
||||
usePermissions,
|
||||
} from "@ema-platform/auth";
|
||||
import { ApplicationSummary } from "../components/ApplicationSummary";
|
||||
import {
|
||||
@@ -74,6 +76,7 @@ import {
|
||||
fillFromVessel,
|
||||
} from "../components/ConfigDrivenSection";
|
||||
import { DocumentSlots } from "../components/DocumentSlots";
|
||||
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
|
||||
import { StaffEvidence } from "../components/StaffEvidence";
|
||||
import { useAppSelector } from "../../../store/hooks";
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
@@ -117,9 +120,14 @@ export function LicenseApplicationPage() {
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
const { profile } = useCurrentProfile();
|
||||
const { can: hasPermission, known: permissionsKnown } = usePermissions();
|
||||
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
|
||||
// here rather than deeper down since it's the shared source of draft state.
|
||||
const { data: vessels } = useGetMyVesselsQuery();
|
||||
// Skipped for accounts without VIEW_OWN_VESSELS (e.g. freight forwarders):
|
||||
// the API 403s for them, since vessels belong to VESSEL_OWNER accounts.
|
||||
const { data: vessels } = useGetMyVesselsQuery(undefined, {
|
||||
skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
|
||||
});
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
|
||||
@@ -394,14 +402,20 @@ export function LicenseApplicationPage() {
|
||||
const staffLocked = roundIsItemised && !hasStaffRemarks;
|
||||
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
// short instead of showing a page per section. A Damaged/Reissue
|
||||
// application skips Staff and Documents outright — it asks nothing beyond
|
||||
// the Damage Information step, regardless of what the licence type
|
||||
// otherwise requires for a new application or renewal.
|
||||
const isReissue = application?.kind === 'REISSUE';
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
hasStaff: isReissue ? false : (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
hasDocuments: !isReissue,
|
||||
language: i18n.language,
|
||||
applicationKind: application?.kind,
|
||||
}),
|
||||
[config, draft, i18n.language],
|
||||
[config, draft, i18n.language, application?.kind, isReissue],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => steps.flatMap((step) => step.sections),
|
||||
@@ -720,6 +734,11 @@ export function LicenseApplicationPage() {
|
||||
>
|
||||
{STATUS_LABELS[application.status]}
|
||||
</Badge>
|
||||
{detail?.issuedLicenseStatus === "SUPERSEDED" && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("licensing.certificateSuperseded", "Certificate superseded")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="md" align="center">
|
||||
@@ -759,6 +778,17 @@ export function LicenseApplicationPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config.licenseType.requiresIssuanceScheduling &&
|
||||
(application.status === "PAYMENT_CONFIRMED" ||
|
||||
application.status === "SCHEDULED") && (
|
||||
<Box mb="md">
|
||||
<PickupSchedulingPanel
|
||||
scheduledDate={application.scheduledIssuanceDate}
|
||||
scheduledPeriod={application.scheduledIssuancePeriod}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showSummary && editableWhileSubmitted && (
|
||||
<Alert
|
||||
color="blue"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Box, Progress, Text } from '@mantine/core';
|
||||
import { Badge, Box, Group, Progress, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
@@ -6,10 +6,23 @@ import {
|
||||
STATUS_PROGRESS,
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const KIND_LABEL: Record<ApplicationKind, string> = {
|
||||
NEW: 'applications.table.kindNew',
|
||||
RENEWAL: 'applications.table.kindRenewal',
|
||||
REISSUE: 'applications.table.kindReissue',
|
||||
};
|
||||
|
||||
const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||
NEW: 'blue',
|
||||
RENEWAL: 'teal',
|
||||
REISSUE: 'orange',
|
||||
};
|
||||
|
||||
export function applicationColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
@@ -23,9 +36,16 @@ export function applicationColumns(
|
||||
header: t('applications.table.licence'),
|
||||
cell: ({ row }) => (
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.licenseType?.name, deps.language) || '—'}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.licenseType?.name, deps.language) || '—'}
|
||||
</Text>
|
||||
{row.original.kind !== 'NEW' && (
|
||||
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
|
||||
{t(KIND_LABEL[row.original.kind])}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../../components/LicenseCard';
|
||||
import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard';
|
||||
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useRetakeExamMutation,
|
||||
type ApplicationKind,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
@@ -102,6 +103,7 @@ export function MyApplicationsPage() {
|
||||
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
const { reissueLicense, isReissuing } = useReissueLicense();
|
||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||
const { can } = usePermissions();
|
||||
|
||||
@@ -207,10 +209,13 @@ export function MyApplicationsPage() {
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
|
||||
const [kindFilter, setKindFilter] = useState<ApplicationKind | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
|
||||
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
|
||||
const hasFilters = Boolean(
|
||||
search || statusFilter || kindFilter || dateFrom || dateTo || bucketFilter,
|
||||
);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
|
||||
const counts = useMemo(() => {
|
||||
@@ -228,6 +233,7 @@ export function MyApplicationsPage() {
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
if (statusFilter && app.status !== statusFilter) return false;
|
||||
if (kindFilter && app.kind !== kindFilter) return false;
|
||||
// Drafts have no submittedAt, so date filtering falls back to createdAt
|
||||
// rather than silently excluding every draft from a date-ranged search.
|
||||
const at = app.submittedAt ?? app.createdAt;
|
||||
@@ -245,13 +251,14 @@ export function MyApplicationsPage() {
|
||||
const bAt = b.submittedAt ?? b.createdAt;
|
||||
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
|
||||
});
|
||||
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
|
||||
}, [allItems, search, statusFilter, kindFilter, dateFrom, dateTo, bucketFilter]);
|
||||
|
||||
const page = paginate(items);
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
setStatusFilter(null);
|
||||
setKindFilter(null);
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setBucketFilter(null);
|
||||
@@ -385,6 +392,22 @@ export function MyApplicationsPage() {
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label={t('applications.filters.kind')}
|
||||
placeholder={t('applications.filters.any')}
|
||||
data={[
|
||||
{ value: 'NEW', label: t('applications.table.kindNew') },
|
||||
{ value: 'RENEWAL', label: t('applications.table.kindRenewal') },
|
||||
{ value: 'REISSUE', label: t('applications.table.kindReissue') },
|
||||
]}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter(v as ApplicationKind | null);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
clearable
|
||||
w={160}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t('applications.filters.from')}
|
||||
value={dateFrom}
|
||||
@@ -475,8 +498,10 @@ export function MyApplicationsPage() {
|
||||
license={license}
|
||||
isDownloading={isDownloadingCert}
|
||||
isRenewing={isRenewing}
|
||||
isReissuing={isReissuing}
|
||||
onDownload={() => downloadCertificate(license.id)}
|
||||
onRenew={() => renewLicense(license)}
|
||||
onReissue={() => reissueLicense(license)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -22,11 +22,15 @@ import {
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconPrinter,
|
||||
IconRefresh,
|
||||
IconReplace,
|
||||
IconShield,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
@@ -34,6 +38,8 @@ import {
|
||||
useGetMySeafarerDocumentsQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||
useRenewSeafarerDocumentMutation,
|
||||
useReplaceSeafarerDocumentMutation,
|
||||
type SeafarerDocument,
|
||||
type SeafarerDocumentStatus,
|
||||
} from "@ema-platform/api";
|
||||
@@ -73,9 +79,20 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
|
||||
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
|
||||
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
|
||||
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
|
||||
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||
const activeStep = stageIndexFor(document.status);
|
||||
|
||||
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
|
||||
try {
|
||||
await action().unwrap();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
notifications.show({ color: "red", title: "Request failed", message: extractErrorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
try {
|
||||
const { url } = await getDownload(document.id).unwrap();
|
||||
@@ -102,9 +119,16 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
<Group gap="xs">
|
||||
{document.requestKind !== "NEW" && (
|
||||
<Badge color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]} variant="outline" size="lg">
|
||||
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
||||
@@ -165,9 +189,29 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
|
||||
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
|
||||
</span>
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={renewing}
|
||||
onClick={() => renewOrReplace(() => renew(document.id))}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconReplace size={14} />}
|
||||
loading={replacing}
|
||||
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
|
||||
>
|
||||
Report Lost/Damaged
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -193,6 +193,7 @@ export const am: Translations = {
|
||||
search: 'ፈልግ',
|
||||
searchPlaceholder: 'ቁጥር ወይም አመልካች',
|
||||
status: 'ሁኔታ',
|
||||
kind: 'ዓይነት',
|
||||
any: 'ማንኛውም',
|
||||
from: 'ከ',
|
||||
to: 'እስከ',
|
||||
@@ -216,6 +217,9 @@ export const am: Translations = {
|
||||
applicant: 'አመልካች',
|
||||
progress: 'ደረጃ',
|
||||
applicationNumber: 'የማመልከቻ ቁጥር',
|
||||
kindNew: 'አዲስ',
|
||||
kindRenewal: 'እድሳት',
|
||||
kindReissue: 'ምትክ',
|
||||
},
|
||||
actions: {
|
||||
continue: 'ቀጥል',
|
||||
@@ -626,6 +630,28 @@ export const am: Translations = {
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
fayda: {
|
||||
continueWith: "በፋይዳ ይቀጥሉ",
|
||||
orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ",
|
||||
verifiedTitle: "በፋይዳ ተረጋግጧል",
|
||||
verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።",
|
||||
discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ",
|
||||
fieldVerified: "ከፋይዳ",
|
||||
fieldConflict: "በሌላ መለያ ተይዟል",
|
||||
conflictBody:
|
||||
"አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።",
|
||||
brandTitle: "በፋይዳ በማረጋገጥ ላይ",
|
||||
brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።",
|
||||
verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…",
|
||||
failedTitle: "ማረጋገጡ አልተጠናቀቀም",
|
||||
backToSignup: "ወደ ምዝገባ ተመለስ",
|
||||
cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።",
|
||||
rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።",
|
||||
invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።",
|
||||
sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።",
|
||||
stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
@@ -801,6 +827,7 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
licensing: {
|
||||
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
|
||||
vesselPicker: {
|
||||
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
|
||||
},
|
||||
@@ -825,6 +852,8 @@ export const am: Translations = {
|
||||
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
|
||||
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
|
||||
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
|
||||
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
|
||||
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
|
||||
status: {
|
||||
ACTIVE: 'የፀና',
|
||||
EXPIRED: 'ጊዜው ያለፈበት',
|
||||
@@ -859,6 +888,15 @@ export const am: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
pickup: {
|
||||
title: 'የሰነድ መረከቢያ',
|
||||
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
|
||||
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
|
||||
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
|
||||
morning: 'ጠዋት',
|
||||
afternoon: 'ከሰዓት በኋላ',
|
||||
},
|
||||
|
||||
certificates: {
|
||||
title: "የእኔ የምስክር ወረቀቶች",
|
||||
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
|
||||
@@ -1012,6 +1050,10 @@ export const am: Translations = {
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
|
||||
awaitingAttendanceHint:
|
||||
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
|
||||
notSitting: 'አይፈተኑም',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
|
||||
@@ -193,6 +193,7 @@ export const en = {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Number or applicant',
|
||||
status: 'Status',
|
||||
kind: 'Type',
|
||||
any: 'Any',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
@@ -216,6 +217,9 @@ export const en = {
|
||||
applicant: 'Applicant',
|
||||
progress: 'Progress',
|
||||
applicationNumber: 'Application №',
|
||||
kindNew: 'New',
|
||||
kindRenewal: 'Renewal',
|
||||
kindReissue: 'Replacement',
|
||||
},
|
||||
actions: {
|
||||
continue: 'Continue',
|
||||
@@ -627,6 +631,28 @@ export const en = {
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
fayda: {
|
||||
continueWith: 'Continue with Fayda',
|
||||
orFillManually: 'or fill in your details',
|
||||
verifiedTitle: 'Verified with Fayda',
|
||||
verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.',
|
||||
discard: 'Clear these details and fill the form myself',
|
||||
fieldVerified: 'From Fayda',
|
||||
fieldConflict: 'Already used by another account',
|
||||
conflictBody:
|
||||
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
|
||||
brandTitle: 'Verifying with Fayda',
|
||||
brandSubtitle: 'One moment while we confirm your identity.',
|
||||
verifying: 'Verifying your Fayda identity\u2026',
|
||||
failedTitle: 'Verification incomplete',
|
||||
backToSignup: 'Back to sign up',
|
||||
cancelled: 'Fayda verification was cancelled. You can still sign up manually.',
|
||||
rejected: 'Fayda could not verify your identity. Please try again.',
|
||||
invalidCallback: 'This verification link is incomplete. Please start again.',
|
||||
sessionLost: 'Your verification session has expired. Please start again.',
|
||||
stateMismatch: 'This verification could not be trusted. Please start again.',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
@@ -802,6 +828,7 @@ export const en = {
|
||||
},
|
||||
|
||||
licensing: {
|
||||
certificateSuperseded: 'Certificate superseded',
|
||||
vesselPicker: {
|
||||
placeholder: 'Select a registered vessel',
|
||||
},
|
||||
@@ -826,6 +853,8 @@ export const en = {
|
||||
renewDays_one: 'Renew — expires in {{count}} day',
|
||||
renewDays_other: 'Renew — expires in {{count}} days',
|
||||
renewFailed: 'Could not start the renewal',
|
||||
reportDamaged: 'Report damaged / request replacement',
|
||||
reissueFailed: 'Could not start the replacement request',
|
||||
status: {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRED: 'Expired',
|
||||
@@ -860,6 +889,15 @@ export const en = {
|
||||
},
|
||||
},
|
||||
|
||||
pickup: {
|
||||
title: 'Document Pickup',
|
||||
scheduledFor: 'Visit the office on {{date}} ({{period}}) to collect your document.',
|
||||
setByOffice: 'This appointment was scheduled by the licensing office.',
|
||||
awaitingSchedule: 'The licensing office will assign a pickup date once your payment is confirmed.',
|
||||
morning: 'Morning',
|
||||
afternoon: 'Afternoon',
|
||||
},
|
||||
|
||||
certificates: {
|
||||
title: 'My Certificates',
|
||||
loading: 'Loading Certificates…',
|
||||
@@ -1015,6 +1053,10 @@ export const en = {
|
||||
timeExpired: 'Time expired',
|
||||
resumeExam: 'Resume exam',
|
||||
takeExam: 'Take exam',
|
||||
awaitingAttendance: 'Awaiting attendance',
|
||||
awaitingAttendanceHint:
|
||||
'An invigilator must confirm you are present before the exam opens.',
|
||||
notSitting: 'Not sitting',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
|
||||
import {
|
||||
LoginPage,
|
||||
SignupPage,
|
||||
FaydaCallbackPage,
|
||||
OTPVerificationPage,
|
||||
ForgotPasswordPage,
|
||||
SetPasswordPage,
|
||||
@@ -68,6 +69,16 @@ export const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
|
||||
// Where Fayda returns the applicant. Public by necessity — they have no
|
||||
// account yet. It redeems the code and hands control back to /signup.
|
||||
//
|
||||
// Two paths for one page: whichever is registered with Fayda has to match the
|
||||
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
|
||||
// bare /callback. The descriptive path is kept so the route still reads as
|
||||
// part of signup once that can be changed.
|
||||
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
|
||||
{ path: "/callback", element: <FaydaCallbackPage /> },
|
||||
|
||||
// Completes the forgot-password flow; the reset message links here. The
|
||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||
// first-time-credential variant — one page serves both.
|
||||
|
||||
Reference in New Issue
Block a user